diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index c9c7a2d3a..894ae2ffc 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -13,6 +13,10 @@ "skill-pack:check": "node scripts/check-skill-pack.mjs", "skill-pack:sync": "node scripts/check-skill-pack.mjs --write", "skill-pack:test": "node --test scripts/check-skill-pack.test.mjs", + "bundled-resources:check": "node scripts/check-package-layout.mjs", + "bundled-resources:sync": "node scripts/check-package-layout.mjs --write", + "bundled-resources:prepare": "node scripts/prepare-bundled-resources.mjs", + "bundled-resources:test": "node --test scripts/prepare-bundled-resources.test.mjs", "llm-status": "node scripts/run-cli-with-config.mjs --llm-status", "agent-task": "node scripts/run-cli-with-config.mjs --agent-task", "config": "node scripts/game-creator-config-wizard.mjs", @@ -24,7 +28,7 @@ "agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-tool-plan-handoff-runner-kill", "agent-runtime:steer-real-e2e": "node scripts/agent-runtime-steer-real-e2e.mjs", "agent-runtime:steer-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite steer-runner-kill", - "typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && node scripts/check-config.mjs" + "typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && npm run bundled-resources:check && node scripts/check-config.mjs" }, "dependencies": { "@cubone/react-file-manager": "^1.35.0", diff --git a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs new file mode 100755 index 000000000..d3a5e1237 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -0,0 +1,477 @@ +#!/usr/bin/env node +// 随包资源声明门禁:把 build_support/package-layout.json(唯一人工声明)渲染成 +// build_support/package-layout.generated.rs(Rust 编译期常量),并校验声明结构与不变量。 +// +// 用法: +// node scripts/check-package-layout.mjs 校验生成结果是否与声明一致(不一致 exit 1) +// node scripts/check-package-layout.mjs --write 重新生成 +// +// 设计约束:Rust 侧不解析 JSON(避免运行期解析与生命周期妥协),只使用本脚本产出的常量; +// Node 侧准备步骤直接读同一份 JSON。因此本门禁是“单一声明”的机械保障。 + +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const APP_ROOT = path.resolve(SCRIPT_DIR, '..'); +const SRC_TAURI = path.join(APP_ROOT, 'src-tauri'); +const DECLARATION_PATH = path.join( + SRC_TAURI, + 'build_support/package-layout.json', +); +const GENERATED_PATH = path.join( + SRC_TAURI, + 'build_support/package-layout.generated.rs', +); + +const EXPECTED_SCHEMA = 'agc-package-layout.v1'; + +class DeclarationError extends Error {} + +function fail(message) { + throw new DeclarationError(message); +} + +function expectObject(value, at) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + fail(`${at} 必须是对象`); + } + return value; +} + +function expectArray(value, at) { + if (!Array.isArray(value)) { + fail(`${at} 必须是数组`); + } + return value; +} + +function expectString(value, at) { + if (typeof value !== 'string' || value.length === 0) { + fail(`${at} 必须是非空字符串`); + } + return value; +} + +function expectBoolean(value, at) { + if (typeof value !== 'boolean') { + fail(`${at} 必须是布尔值`); + } + return value; +} + +function expectStringArray(value, at) { + return expectArray(value, at).map((item, index) => + expectString(item, `${at}[${index}]`), + ); +} + +function optionalStringArray(source, key, at) { + if (source[key] === undefined) { + return []; + } + return expectStringArray(source[key], `${at}.${key}`); +} + +function expectInteger(value, at) { + if (!Number.isInteger(value) || value < 0) { + fail(`${at} 必须是非负整数`); + } + return value; +} + +// JSON 字符串字面量与 Rust 字符串字面量几乎一致,唯一差异是控制字符的 \uXXXX 与 \u{XX}。 +function rustString(value) { + return JSON.stringify(value).replace(/\\u([0-9a-fA-F]{4})/g, '\\u{$1}'); +} + +function rustStrings(values) { + return `&[${values.map(rustString).join(', ')}]`; +} + +function indent(level) { + return ' '.repeat(level); +} + +function renderStruct(name, fields, level = 0) { + const body = fields + .map(([key, rendered]) => `${indent(level + 1)}${key}: ${rendered},`) + .join('\n'); + return `${name} {\n${body}\n${indent(level)}}`; +} + +function parseDeclaration(raw) { + const root = expectObject(JSON.parse(raw), 'root'); + if (root.schema !== EXPECTED_SCHEMA) { + fail( + `不支持的声明 schema:${String(root.schema)}(期望 ${EXPECTED_SCHEMA})`, + ); + } + const layoutVersion = expectInteger(root.layoutVersion, 'layoutVersion'); + + const codex = expectObject(root.codex, 'codex'); + const targets = expectArray(codex.targets, 'codex.targets').map( + (entry, index) => { + const at = `codex.targets[${index}]`; + const parsed = expectObject(entry, at); + const files = expectStringArray(parsed.files, `${at}.files`); + if (files.length === 0) { + fail(`${at}.files 不能为空`); + } + const executable = expectString(parsed.executable, `${at}.executable`); + if (!files.includes(executable)) { + fail(`${at}.executable 必须属于组件白名单:${executable}`); + } + return { + target: expectString(parsed.target, `${at}.target`), + platform: expectString(parsed.platform, `${at}.platform`), + directory: expectString(parsed.directory, `${at}.directory`), + executable, + files, + }; + }, + ); + const seenTargets = new Set(); + for (const entry of targets) { + if (seenTargets.has(entry.target)) { + fail(`codex.targets 重复声明目标 ${entry.target}`); + } + seenTargets.add(entry.target); + } + + const noticeSources = expectArray( + codex.noticeSources, + 'codex.noticeSources', + ).map((entry, index) => { + const at = `codex.noticeSources[${index}]`; + const parsed = expectObject(entry, at); + return { + targets: expectStringArray(parsed.targets, `${at}.targets`), + source: expectString(parsed.source, `${at}.source`), + preserve: expectBoolean(parsed.preserve, `${at}.preserve`), + }; + }); + for (const entry of targets) { + const covered = noticeSources.filter((notice) => + notice.targets.includes(entry.target), + ); + if (covered.length !== 1) { + fail( + `目标 ${entry.target} 必须且只能有一条第三方声明来源(实际 ${covered.length} 条)`, + ); + } + } + + const universalGroups = expectArray( + codex.universalGroups, + 'codex.universalGroups', + ).map((entry, index) => { + const at = `codex.universalGroups[${index}]`; + const parsed = expectObject(entry, at); + const groupTargets = expectStringArray(parsed.targets, `${at}.targets`); + for (const target of groupTargets) { + if (!seenTargets.has(target)) { + fail(`${at}.targets 含未声明目标 ${target}`); + } + } + return { + name: expectString(parsed.name, `${at}.name`), + directory: expectString(parsed.directory, `${at}.directory`), + targets: groupTargets, + }; + }); + + const plugins = expectObject(root.plugins, 'plugins'); + const subdirectories = expectArray( + plugins.subdirectories, + 'plugins.subdirectories', + ).map((entry, index) => { + const at = `plugins.subdirectories[${index}]`; + const parsed = expectObject(entry, at); + return { + path: expectString(parsed.path, `${at}.path`), + targetContains: optionalStringArray(parsed, 'targetContains', at), + targets: optionalStringArray(parsed, 'targets', at), + features: optionalStringArray(parsed, 'features', at), + }; + }); + const libraryStaging = expectArray( + plugins.libraryStaging, + 'plugins.libraryStaging', + ).map((entry, index) => { + const at = `plugins.libraryStaging[${index}]`; + const parsed = expectObject(entry, at); + return { + plugin: expectString(parsed.plugin, `${at}.plugin`), + sourceSubdirectory: expectString( + parsed.sourceSubdirectory, + `${at}.sourceSubdirectory`, + ), + targets: expectStringArray(parsed.targets, `${at}.targets`), + features: expectStringArray(parsed.features, `${at}.features`), + layout: expectString(parsed.layout, `${at}.layout`), + }; + }); + + return { + layoutVersion, + codex: { + version: expectString(codex.version, 'codex.version'), + cliVersionPrefix: expectString( + codex.cliVersionPrefix, + 'codex.cliVersionPrefix', + ), + manifestSchema: expectString( + codex.manifestSchema, + 'codex.manifestSchema', + ), + resourceDirectory: expectString( + codex.resourceDirectory, + 'codex.resourceDirectory', + ), + manifestFileName: expectString( + codex.manifestFileName, + 'codex.manifestFileName', + ), + packageMetadataFileName: expectString( + codex.packageMetadataFileName, + 'codex.packageMetadataFileName', + ), + noticeFileName: expectString( + codex.noticeFileName, + 'codex.noticeFileName', + ), + sourceRoots: expectStringArray(codex.sourceRoots, 'codex.sourceRoots'), + sourceRelativePaths: expectStringArray( + codex.sourceRelativePaths, + 'codex.sourceRelativePaths', + ), + noticeSources, + universalGroups, + targets, + }, + plugins: { + sourceDirectory: expectString( + plugins.sourceDirectory, + 'plugins.sourceDirectory', + ), + destinationDirectory: expectString( + plugins.destinationDirectory, + 'plugins.destinationDirectory', + ), + manifestFileName: expectString( + plugins.manifestFileName, + 'plugins.manifestFileName', + ), + targetContainsAny: expectStringArray( + plugins.targetContainsAny, + 'plugins.targetContainsAny', + ), + subdirectories, + libraryStaging, + skipDirectoryNames: expectStringArray( + plugins.skipDirectoryNames, + 'plugins.skipDirectoryNames', + ), + skipDirectoryNamePrefixes: expectStringArray( + plugins.skipDirectoryNamePrefixes, + 'plugins.skipDirectoryNamePrefixes', + ), + skipFileNamePrefixes: expectStringArray( + plugins.skipFileNamePrefixes, + 'plugins.skipFileNamePrefixes', + ), + skipFileNameFragments: expectStringArray( + plugins.skipFileNameFragments, + 'plugins.skipFileNameFragments', + ), + }, + }; +} + +function renderCodexTarget(entry) { + return renderStruct( + 'CodexTarget', + [ + ['target', rustString(entry.target)], + ['platform', rustString(entry.platform)], + ['directory', rustString(entry.directory)], + ['executable', rustString(entry.executable)], + ['files', rustStrings(entry.files)], + ], + 1, + ); +} + +function renderNoticeSource(entry) { + return renderStruct( + 'NoticeSource', + [ + ['targets', rustStrings(entry.targets)], + ['source', rustString(entry.source)], + ['preserve', entry.preserve ? 'true' : 'false'], + ], + 1, + ); +} + +function renderUniversalGroup(entry) { + return renderStruct( + 'UniversalGroup', + [ + ['name', rustString(entry.name)], + ['directory', rustString(entry.directory)], + ['targets', rustStrings(entry.targets)], + ], + 1, + ); +} + +function renderSubdirectory(entry) { + return renderStruct( + 'Subdirectory', + [ + ['path', rustString(entry.path)], + ['target_contains', rustStrings(entry.targetContains)], + ['targets', rustStrings(entry.targets)], + ['features', rustStrings(entry.features)], + ], + 1, + ); +} + +function renderLibraryStaging(entry) { + return renderStruct( + 'LibraryStaging', + [ + ['plugin', rustString(entry.plugin)], + ['source_subdirectory', rustString(entry.sourceSubdirectory)], + ['targets', rustStrings(entry.targets)], + ['features', rustStrings(entry.features)], + ['layout', rustString(entry.layout)], + ], + 1, + ); +} + +function renderGenerated(declaration) { + const codex = declaration.codex; + const plugins = declaration.plugins; + const codexStruct = renderStruct('Codex', [ + ['resource_directory', rustString(codex.resourceDirectory)], + ['manifest_file_name', rustString(codex.manifestFileName)], + ['package_metadata_file_name', rustString(codex.packageMetadataFileName)], + ['notice_file_name', rustString(codex.noticeFileName)], + ['source_roots', rustStrings(codex.sourceRoots)], + ['source_relative_paths', rustStrings(codex.sourceRelativePaths)], + [ + 'notice_sources', + `&[\n${codex.noticeSources.map(renderNoticeSource).join(',\n')}\n]`, + ], + [ + 'universal_groups', + `&[\n${codex.universalGroups.map(renderUniversalGroup).join(',\n')}\n]`, + ], + ['targets', `&[\n${codex.targets.map(renderCodexTarget).join(',\n')}\n]`], + ]); + const pluginsStruct = renderStruct('Plugins', [ + ['source_directory', rustString(plugins.sourceDirectory)], + ['destination_directory', rustString(plugins.destinationDirectory)], + ['manifest_file_name', rustString(plugins.manifestFileName)], + ['target_contains_any', rustStrings(plugins.targetContainsAny)], + [ + 'subdirectories', + `&[\n${plugins.subdirectories.map(renderSubdirectory).join(',\n')}\n]`, + ], + [ + 'library_staging', + `&[\n${plugins.libraryStaging.map(renderLibraryStaging).join(',\n')}\n]`, + ], + ['skip_directory_names', rustStrings(plugins.skipDirectoryNames)], + [ + 'skip_directory_name_prefixes', + rustStrings(plugins.skipDirectoryNamePrefixes), + ], + ['skip_file_name_prefixes', rustStrings(plugins.skipFileNamePrefixes)], + ['skip_file_name_fragments', rustStrings(plugins.skipFileNameFragments)], + ]); + + return `// @generated by apps/ai-game-creator-shell/scripts/check-package-layout.mjs +// 来源:build_support/package-layout.json。不要手工编辑本文件。 +// 修改随包资源布局请编辑声明文件,然后运行 +// npm run agc:package-layout:sync(在仓库根目录) +// 门禁会校验两者一致(npm run agc:typecheck 链内含 check-package-layout.mjs)。 + +pub const DECLARATION_SCHEMA: &str = ${rustString(EXPECTED_SCHEMA)}; +pub const LAYOUT_VERSION: u64 = ${declaration.layoutVersion}; + +pub const CODEX_VERSION: &str = ${rustString(declaration.codex.version)}; +pub const CODEX_CLI_VERSION: &str = ${rustString(declaration.codex.cliVersionPrefix + declaration.codex.version)}; +pub const CODEX_MANIFEST_SCHEMA: &str = ${rustString(declaration.codex.manifestSchema)}; + +pub const CODEX: Codex = ${codexStruct}; + +pub const PLUGINS: Plugins = ${pluginsStruct}; +`; +} + +function appLockedCodexVersion() { + const appPackage = expectObject( + JSON.parse(readFileSync(path.join(APP_ROOT, 'package.json'), 'utf8')), + 'apps/ai-game-creator-shell/package.json', + ); + const declared = + appPackage.dependencies?.['@openai/codex'] ?? + appPackage.devDependencies?.['@openai/codex']; + if (typeof declared !== 'string' || declared.length === 0) { + fail( + '应用 package.json 未声明 @openai/codex,无法校验声明的 codex.version', + ); + } + return declared.replace(/^[\^~]/, ''); +} + +function main() { + const declaration = parseDeclaration(readFileSync(DECLARATION_PATH, 'utf8')); + const lockedVersion = appLockedCodexVersion(); + if (lockedVersion !== declaration.codex.version) { + fail( + `声明 codex.version(${declaration.codex.version})与应用锁定的 @openai/codex(${lockedVersion})不一致;请同步更新 build_support/package-layout.json`, + ); + } + const rendered = renderGenerated(declaration); + const write = process.argv.includes('--write'); + if (write) { + writeFileSync(GENERATED_PATH, rendered); + console.log( + `随包资源声明已生成:${path.relative(process.cwd(), GENERATED_PATH)}(layoutVersion ${declaration.layoutVersion})`, + ); + return; + } + const current = readFileSync(GENERATED_PATH, 'utf8'); + if (current !== rendered) { + console.error( + [ + `随包资源声明与 Rust 常量不一致:`, + ` 声明:${path.relative(process.cwd(), DECLARATION_PATH)}`, + ` 生成:${path.relative(process.cwd(), GENERATED_PATH)}`, + '请运行:npm run agc:package-layout:sync', + ].join('\n'), + ); + process.exit(1); + } + console.log( + `随包资源声明一致(layoutVersion ${declaration.layoutVersion},codex ${declaration.codex.version},目标 ${declaration.codex.targets.length},插件子目录 ${declaration.plugins.subdirectories.length})`, + ); +} + +try { + main(); +} catch (error) { + if (error instanceof DeclarationError) { + console.error(`随包资源声明无效:${error.message}`); + process.exit(1); + } + throw error; +} diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs b/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs index e2064d28d..d802866df 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs @@ -1,8 +1,18 @@ //! 构建与运行共用的平台布局;只允许分发锁定原生包里的明确组件。 +//! +//! 布局、组件白名单与版本常量来自唯一声明 `build_support/package-layout.json` +//! (Rust 侧经 `build_support/package-layout.generated.rs` 取得编译期常量, +//! 由 `scripts/check-package-layout.mjs` 生成并在门禁中校验一致)。 +//! 本模块只读声明,不写任何随包资源。 -pub const VERSION: &str = "0.155.1"; -pub const CLI_VERSION: &str = "codex-cli 0.155.1"; -pub const SCHEMA: &str = "genarrative-codex-sidecar.v2"; +// 共享声明模块:构建脚本、运行期与测试各自只用到其中一部分,未用到的入口不算缺陷。 +#[allow(dead_code)] +#[path = "package_layout.rs"] +pub(crate) mod package_layout; + +pub const VERSION: &str = package_layout::CODEX_VERSION; +pub const CLI_VERSION: &str = package_layout::CODEX_CLI_VERSION; +pub const SCHEMA: &str = package_layout::CODEX_MANIFEST_SCHEMA; #[derive(Clone, Copy, Debug)] pub struct Layout { @@ -12,46 +22,13 @@ pub struct Layout { pub files: &'static [&'static str], } -const WINDOWS_FILES: &[&str] = &[ - "bin/codex.exe", - "bin/codex-code-mode-host.exe", - "codex-path/rg.exe", - "codex-resources/codex-command-runner.exe", - "codex-resources/codex-windows-sandbox-setup.exe", - "codex-package.json", -]; -const MAC_FILES: &[&str] = &[ - "bin/codex", - "bin/codex-code-mode-host", - "codex-path/rg", - "codex-resources/zsh/bin/zsh", - "codex-package.json", -]; - pub fn for_target(target: &str) -> Option { - match target { - "x86_64-pc-windows-msvc" => Some(Layout { - platform: "win32-x64", - directory: "win-x64", - executable: "bin/codex.exe", - files: WINDOWS_FILES, - }), - "aarch64-apple-darwin" | "x86_64-apple-darwin" => Some(Layout { - platform: if target.starts_with("aarch64") { - "darwin-arm64" - } else { - "darwin-x64" - }, - directory: if target.starts_with("aarch64") { - "mac-native/darwin-arm64" - } else { - "mac-native/darwin-x64" - }, - executable: "bin/codex", - files: MAC_FILES, - }), - _ => None, - } + package_layout::codex_target(target).map(|declared| Layout { + platform: declared.platform, + directory: declared.directory, + executable: declared.executable, + files: declared.files, + }) } #[cfg(test)] @@ -80,4 +57,11 @@ mod tests { assert!(for_target("aarch64-pc-windows-msvc").is_none()); assert!(for_target("x86_64-unknown-linux-gnu").is_none()); } + + #[test] + fn constants_come_from_the_shared_declaration() { + assert_eq!(VERSION, "0.155.1"); + assert_eq!(CLI_VERSION, format!("codex-cli {VERSION}")); + assert_eq!(SCHEMA, "genarrative-codex-sidecar.v2"); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs new file mode 100644 index 000000000..ebfe90b06 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs @@ -0,0 +1,115 @@ +// @generated by apps/ai-game-creator-shell/scripts/check-package-layout.mjs +// 来源:build_support/package-layout.json。不要手工编辑本文件。 +// 修改随包资源布局请编辑声明文件,然后运行 +// npm run agc:package-layout:sync(在仓库根目录) +// 门禁会校验两者一致(npm run agc:typecheck 链内含 check-package-layout.mjs)。 + +pub const DECLARATION_SCHEMA: &str = "agc-package-layout.v1"; +pub const LAYOUT_VERSION: u64 = 1; + +pub const CODEX_VERSION: &str = "0.155.1"; +pub const CODEX_CLI_VERSION: &str = "codex-cli 0.155.1"; +pub const CODEX_MANIFEST_SCHEMA: &str = "genarrative-codex-sidecar.v2"; + +pub const CODEX: Codex = Codex { + resource_directory: "resources/codex", + manifest_file_name: "manifest.json", + package_metadata_file_name: "codex-package.json", + notice_file_name: "NOTICE.md", + source_roots: &["app", "repo"], + source_relative_paths: &["node_modules/@openai/codex-/vendor/", "node_modules/@openai/codex/node_modules/@openai/codex-/vendor/"], + notice_sources: &[ +NoticeSource { + targets: &["aarch64-apple-darwin", "x86_64-apple-darwin"], + source: "resources/codex/【声明】Mac内置Codex组件-2026-09-18.md", + preserve: false, + }, +NoticeSource { + targets: &["x86_64-pc-windows-msvc"], + source: "resources/codex/win-x64/NOTICE.md", + preserve: true, + } +], + universal_groups: &[ +UniversalGroup { + name: "mac-native", + directory: "mac-native", + targets: &["aarch64-apple-darwin", "x86_64-apple-darwin"], + } +], + targets: &[ +CodexTarget { + target: "x86_64-pc-windows-msvc", + platform: "win32-x64", + directory: "win-x64", + executable: "bin/codex.exe", + files: &["bin/codex.exe", "bin/codex-code-mode-host.exe", "codex-path/rg.exe", "codex-resources/codex-command-runner.exe", "codex-resources/codex-windows-sandbox-setup.exe", "codex-package.json"], + }, +CodexTarget { + target: "aarch64-apple-darwin", + platform: "darwin-arm64", + directory: "mac-native/darwin-arm64", + executable: "bin/codex", + files: &["bin/codex", "bin/codex-code-mode-host", "codex-path/rg", "codex-resources/zsh/bin/zsh", "codex-package.json"], + }, +CodexTarget { + target: "x86_64-apple-darwin", + platform: "darwin-x64", + directory: "mac-native/darwin-x64", + executable: "bin/codex", + files: &["bin/codex", "bin/codex-code-mode-host", "codex-path/rg", "codex-resources/zsh/bin/zsh", "codex-package.json"], + } +], +}; + +pub const PLUGINS: Plugins = Plugins { + source_directory: "plugins", + destination_directory: "resources/plugins", + manifest_file_name: "plugin.json", + target_contains_any: &["windows", "apple-darwin"], + subdirectories: &[ +Subdirectory { + path: "src", + target_contains: &[], + targets: &[], + features: &[], + }, +Subdirectory { + path: "panels", + target_contains: &[], + targets: &[], + features: &[], + }, +Subdirectory { + path: "skills", + target_contains: &[], + targets: &[], + features: &[], + }, +Subdirectory { + path: "native/payload", + target_contains: &["windows"], + targets: &[], + features: &[], + }, +Subdirectory { + path: "dotnet/publish/win-x64", + target_contains: &[], + targets: &["x86_64-pc-windows-msvc"], + features: &["unity-editor-execute"], + } +], + library_staging: &[ +LibraryStaging { + plugin: "agc-godot-editor", + source_subdirectory: "native/gdextension", + targets: &["x86_64-pc-windows-msvc"], + features: &["godot-editor-execute"], + layout: "godot-bundle", + } +], + skip_directory_names: &["target", "node_modules"], + skip_directory_name_prefixes: &["."], + skip_file_name_prefixes: &["."], + skip_file_name_fragments: &[".test."], +}; diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json new file mode 100644 index 000000000..21ac80bbe --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json @@ -0,0 +1,110 @@ +{ + "schema": "agc-package-layout.v1", + "layoutVersion": 1, + "description": "AGC 随包资源布局与复制规则的唯一声明。Rust 侧构建期校验与 Node 侧准备步骤共用本文件,任何一侧都不得再写第二份布局或组件白名单。含 、 占位符的字段由调用方按目标三元展开。修改布局时同步递增 layoutVersion(准备步骤的缓存 key 组成部分)。", + "codex": { + "version": "0.155.1", + "cliVersionPrefix": "codex-cli ", + "manifestSchema": "genarrative-codex-sidecar.v2", + "resourceDirectory": "resources/codex", + "manifestFileName": "manifest.json", + "packageMetadataFileName": "codex-package.json", + "noticeFileName": "NOTICE.md", + "sourceRoots": ["app", "repo"], + "sourceRelativePaths": [ + "node_modules/@openai/codex-/vendor/", + "node_modules/@openai/codex/node_modules/@openai/codex-/vendor/" + ], + "noticeSources": [ + { + "targets": ["aarch64-apple-darwin", "x86_64-apple-darwin"], + "source": "resources/codex/【声明】Mac内置Codex组件-2026-09-18.md", + "preserve": false + }, + { + "targets": ["x86_64-pc-windows-msvc"], + "source": "resources/codex/win-x64/NOTICE.md", + "preserve": true + } + ], + "universalGroups": [ + { + "name": "mac-native", + "directory": "mac-native", + "targets": ["aarch64-apple-darwin", "x86_64-apple-darwin"] + } + ], + "targets": [ + { + "target": "x86_64-pc-windows-msvc", + "platform": "win32-x64", + "directory": "win-x64", + "executable": "bin/codex.exe", + "files": [ + "bin/codex.exe", + "bin/codex-code-mode-host.exe", + "codex-path/rg.exe", + "codex-resources/codex-command-runner.exe", + "codex-resources/codex-windows-sandbox-setup.exe", + "codex-package.json" + ] + }, + { + "target": "aarch64-apple-darwin", + "platform": "darwin-arm64", + "directory": "mac-native/darwin-arm64", + "executable": "bin/codex", + "files": [ + "bin/codex", + "bin/codex-code-mode-host", + "codex-path/rg", + "codex-resources/zsh/bin/zsh", + "codex-package.json" + ] + }, + { + "target": "x86_64-apple-darwin", + "platform": "darwin-x64", + "directory": "mac-native/darwin-x64", + "executable": "bin/codex", + "files": [ + "bin/codex", + "bin/codex-code-mode-host", + "codex-path/rg", + "codex-resources/zsh/bin/zsh", + "codex-package.json" + ] + } + ] + }, + "plugins": { + "sourceDirectory": "plugins", + "destinationDirectory": "resources/plugins", + "manifestFileName": "plugin.json", + "targetContainsAny": ["windows", "apple-darwin"], + "subdirectories": [ + { "path": "src" }, + { "path": "panels" }, + { "path": "skills" }, + { "path": "native/payload", "targetContains": ["windows"] }, + { + "path": "dotnet/publish/win-x64", + "targets": ["x86_64-pc-windows-msvc"], + "features": ["unity-editor-execute"] + } + ], + "libraryStaging": [ + { + "plugin": "agc-godot-editor", + "sourceSubdirectory": "native/gdextension", + "targets": ["x86_64-pc-windows-msvc"], + "features": ["godot-editor-execute"], + "layout": "godot-bundle" + } + ], + "skipDirectoryNames": ["target", "node_modules"], + "skipDirectoryNamePrefixes": ["."], + "skipFileNamePrefixes": ["."], + "skipFileNameFragments": [".test."] + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs new file mode 100644 index 000000000..28ae352b6 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs @@ -0,0 +1,684 @@ +//! 随包资源布局与复制规则的单一声明读取层,以及随包产物的只读校验。 +//! +//! 人工声明只有一处:`build_support/package-layout.json`。Rust 侧使用由 +//! `scripts/check-package-layout.mjs` 从该声明生成的编译期常量 +//! (`package-layout.generated.rs`,门禁校验两者一致),Node 侧准备步骤直接读声明本身。 +//! 本模块**只读**:既不解析 JSON,也不写任何随包资源。 + +use std::collections::BTreeSet; +use std::io::{BufReader, Read}; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +/// 目标三元 → 平台包与组件白名单。 +#[derive(Debug)] +pub struct CodexTarget { + pub target: &'static str, + pub platform: &'static str, + pub directory: &'static str, + pub executable: &'static str, + pub files: &'static [&'static str], +} + +/// 一次构建共用的整目录(例如 macOS 的 `mac-native` 双架构)。 +#[derive(Debug)] +pub struct UniversalGroup { + pub name: &'static str, + pub directory: &'static str, + pub targets: &'static [&'static str], +} + +/// 第三方声明的来源;`preserve` 表示该文件受版本控制、只允许原地保留。 +#[derive(Debug)] +pub struct NoticeSource { + pub targets: &'static [&'static str], + pub source: &'static str, + pub preserve: bool, +} + +/// 插件工作区的随包子目录及其生效条件。 +#[derive(Debug)] +pub struct Subdirectory { + pub path: &'static str, + pub target_contains: &'static [&'static str], + pub targets: &'static [&'static str], + pub features: &'static [&'static str], +} + +/// 由外部工具链另行产出的随包库(如 Godot gdextension)的归位规则。 +#[derive(Debug)] +pub struct LibraryStaging { + pub plugin: &'static str, + pub source_subdirectory: &'static str, + pub targets: &'static [&'static str], + pub features: &'static [&'static str], + pub layout: &'static str, +} + +#[derive(Debug)] +pub struct Codex { + pub resource_directory: &'static str, + pub manifest_file_name: &'static str, + pub package_metadata_file_name: &'static str, + pub notice_file_name: &'static str, + pub source_roots: &'static [&'static str], + pub source_relative_paths: &'static [&'static str], + pub notice_sources: &'static [NoticeSource], + pub universal_groups: &'static [UniversalGroup], + pub targets: &'static [CodexTarget], +} + +#[derive(Debug)] +pub struct Plugins { + pub source_directory: &'static str, + pub destination_directory: &'static str, + pub manifest_file_name: &'static str, + pub target_contains_any: &'static [&'static str], + pub subdirectories: &'static [Subdirectory], + pub library_staging: &'static [LibraryStaging], + pub skip_directory_names: &'static [&'static str], + pub skip_directory_name_prefixes: &'static [&'static str], + pub skip_file_name_prefixes: &'static [&'static str], + pub skip_file_name_fragments: &'static [&'static str], +} + +include!("package-layout.generated.rs"); + +const PLATFORM_PLACEHOLDER: &str = ""; +const TARGET_PLACEHOLDER: &str = ""; + +pub fn codex() -> &'static Codex { + &CODEX +} + +pub fn plugins() -> &'static Plugins { + &PLUGINS +} + +/// 声明里的目标布局;未声明的目标返回 `None`。 +pub fn codex_target(target: &str) -> Option<&'static CodexTarget> { + CODEX.targets.iter().find(|entry| entry.target == target) +} + +/// 目标所属的整目录分组(macOS 双架构共用 `mac-native`)。 +pub fn codex_universal_group(target: &str) -> Option<&'static UniversalGroup> { + CODEX + .universal_groups + .iter() + .find(|group| group.targets.contains(&target)) +} + +/// 目标对应的第三方声明来源。 +pub fn codex_notice_source(target: &str) -> Option<&'static NoticeSource> { + CODEX + .notice_sources + .iter() + .find(|entry| entry.targets.contains(&target)) +} + +/// 单次构建需要校验的平台目录:属于整目录分组时是该组全部三元,否则是自身;不支持的目标为空。 +pub fn staged_targets(target: &str) -> Vec<&'static str> { + if let Some(group) = codex_universal_group(target) { + return group.targets.to_vec(); + } + codex_target(target) + .map(|declared| vec![declared.target]) + .unwrap_or_default() +} + +/// 上游平台包的候选路径,顺序与声明一致(先 app 目录后仓库根,各自按声明顺序)。 +pub fn codex_source_candidates( + app_root: &Path, + repo_root: &Path, + target: &str, +) -> Result, String> { + let layout = + codex_target(target).ok_or_else(|| format!("声明不含目标 {target} 的 Codex 布局"))?; + let mut candidates = Vec::new(); + for root in CODEX.source_roots { + let base = match *root { + "app" => app_root, + "repo" => repo_root, + other => return Err(format!("声明中的 sourceRoots 取值无效:{other}")), + }; + for relative in CODEX.source_relative_paths { + let expanded = relative + .replace(PLATFORM_PLACEHOLDER, layout.platform) + .replace(TARGET_PLACEHOLDER, target); + candidates.push(base.join(expanded)); + } + } + Ok(candidates) +} + +/// 插件随包 staging 是否适用于该目标(与声明里的平台门槛一致)。 +pub fn plugin_staging_applies(target: &str) -> bool { + PLUGINS + .target_contains_any + .iter() + .any(|needle| target.contains(needle)) +} + +/// 子目录在当前目标与已启用 feature 下是否随包。 +pub fn subdirectory_enabled( + subdirectory: &Subdirectory, + target: &str, + feature_enabled: impl Fn(&str) -> bool, +) -> bool { + (subdirectory.target_contains.is_empty() + || subdirectory + .target_contains + .iter() + .any(|needle| target.contains(needle))) + && (subdirectory.targets.is_empty() || subdirectory.targets.contains(&target)) + && subdirectory + .features + .iter() + .all(|name| feature_enabled(name)) +} + +/// 随包库归位在当前目标与已启用 feature 下是否生效。 +pub fn library_staging_enabled( + staging: &LibraryStaging, + target: &str, + feature_enabled: impl Fn(&str) -> bool, +) -> bool { + (staging.targets.is_empty() || staging.targets.contains(&target)) + && staging.features.iter().all(|name| feature_enabled(name)) +} + +/// Cargo build script 上下文里该 feature 是否启用(`CARGO_FEATURE_`)。 +pub fn cargo_feature_enabled(name: &str) -> bool { + let variable = format!("CARGO_FEATURE_{}", name.to_uppercase().replace('-', "_")); + std::env::var_os(variable).is_some() +} + +/// 目录是否被跳过(构建产物目录与隐藏目录)。 +pub fn skip_directory(name: &str) -> bool { + PLUGINS.skip_directory_names.contains(&name) + || PLUGINS + .skip_directory_name_prefixes + .iter() + .any(|prefix| name.starts_with(prefix)) +} + +/// 文件是否被跳过(测试文件与隐藏文件)。 +pub fn skip_file_name(name: &str) -> bool { + PLUGINS + .skip_file_name_prefixes + .iter() + .any(|prefix| name.starts_with(prefix)) + || PLUGINS + .skip_file_name_fragments + .iter() + .any(|fragment| name.contains(fragment)) +} + +/// 逐块 sha256;构建期校验与 staging 比对共用同一实现。 +pub fn sha256_file(path: &Path) -> Result { + let file = std::fs::File::open(path)?; + let mut reader = BufReader::new(file); + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +/// 声明里的相对路径一律使用 `/`,落盘时转换为平台分隔符。 +pub fn declared_relative_path(relative: &str) -> PathBuf { + relative.split('/').collect() +} + +/// 只读校验一份已经落盘的 Codex 随包目录(本函数不写任何文件)。 +/// +/// 校验项:目录存在且非链接、清单存在且 schema/平台/版本一致、清单文件集合与组件白名单完全一致、 +/// 每个组件存在且摘要一致、第三方声明存在、可执行组件具备可执行位、目录内不存在白名单外的文件。 +pub fn validate_staged_codex_bundle(target_dir: &Path, target: &str) -> Result<(), String> { + let layout = + codex_target(target).ok_or_else(|| format!("声明不含目标 {target} 的 Codex 布局"))?; + let metadata = std::fs::symlink_metadata(target_dir) + .map_err(|error| format!("随包目录不可读 {}:{error}", target_dir.display()))?; + if !metadata.is_dir() { + return Err(format!("随包目录不是目录:{}", target_dir.display())); + } + if metadata.file_type().is_symlink() { + return Err(format!("随包目录不允许符号链接:{}", target_dir.display())); + } + + let manifest_path = target_dir.join(CODEX.manifest_file_name); + let manifest_raw = std::fs::read_to_string(&manifest_path) + .map_err(|error| format!("随包清单不可读 {}:{error}", manifest_path.display()))?; + let manifest: serde_json::Value = serde_json::from_str(&manifest_raw) + .map_err(|error| format!("随包清单不是合法 JSON {}:{error}", manifest_path.display()))?; + let schema = manifest["schemaVersion"].as_str().unwrap_or_default(); + if schema != CODEX_MANIFEST_SCHEMA { + return Err(format!( + "随包清单 schema 不受支持:{schema}(期望 {CODEX_MANIFEST_SCHEMA})" + )); + } + let platform = manifest["platform"].as_str().unwrap_or_default(); + if platform != layout.platform { + return Err(format!( + "随包清单平台与目标不一致:{platform}(目标 {target} 期望 {})", + layout.platform + )); + } + let version = manifest["version"].as_str().unwrap_or_default(); + if version != CODEX_CLI_VERSION { + return Err(format!( + "随包清单版本与声明不一致:{version}(期望 {CODEX_CLI_VERSION})" + )); + } + let hashes = manifest["files"] + .as_object() + .ok_or_else(|| format!("随包清单缺少 files 映射:{}", manifest_path.display()))?; + let declared = layout + .files + .iter() + .map(|relative| (*relative).to_string()) + .collect::>(); + let listed = hashes.keys().cloned().collect::>(); + if declared != listed { + let missing = declared.difference(&listed).cloned().collect::>(); + let unexpected = listed.difference(&declared).cloned().collect::>(); + return Err(format!( + "随包清单文件集合与组件白名单不一致(缺少 {missing:?},多出 {unexpected:?})" + )); + } + + for relative in layout.files { + let path = target_dir.join(declared_relative_path(relative)); + let file_metadata = std::fs::symlink_metadata(&path) + .map_err(|error| format!("随包组件缺失 {relative}:{error}"))?; + if file_metadata.file_type().is_symlink() { + return Err(format!("随包组件不允许符号链接:{relative}")); + } + if !file_metadata.is_file() { + return Err(format!("随包组件不是普通文件:{relative}")); + } + if file_metadata.len() == 0 { + return Err(format!("随包组件为空:{relative}")); + } + let expected = hashes + .get(*relative) + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if expected.len() != 64 || !expected.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(format!("随包清单摘要格式无效:{relative} = {expected}")); + } + let actual = + sha256_file(&path).map_err(|error| format!("读取随包组件失败 {relative}:{error}"))?; + if !actual.eq_ignore_ascii_case(expected) { + return Err(format!( + "随包组件摘要与清单不一致:{relative}(清单 {expected},实际 {actual})" + )); + } + #[cfg(unix)] + if *relative == layout.executable { + use std::os::unix::fs::PermissionsExt; + if file_metadata.permissions().mode() & 0o111 == 0 { + return Err(format!("随包可执行组件缺少可执行位:{relative}")); + } + } + } + + let notice = target_dir.join(CODEX.notice_file_name); + if !notice.is_file() { + return Err(format!("随包第三方声明缺失:{}", notice.display())); + } + + let mut allowed = declared.clone(); + allowed.insert(CODEX.notice_file_name.to_string()); + allowed.insert(CODEX.manifest_file_name.to_string()); + let actual = collect_tree_files(target_dir)?; + let unexpected = actual.difference(&allowed).cloned().collect::>(); + if !unexpected.is_empty() { + return Err(format!( + "随包目录存在白名单外的文件:{unexpected:?}({})", + target_dir.display() + )); + } + Ok(()) +} + +/// 递归收集目录下的普通文件(相对路径,`/` 分隔);遇到符号链接即失败。 +pub fn collect_tree_files(root: &Path) -> Result, String> { + let mut files = BTreeSet::new(); + let mut stack = vec![(root.to_path_buf(), String::new())]; + while let Some((directory, prefix)) = stack.pop() { + let entries = std::fs::read_dir(&directory) + .map_err(|error| format!("随包目录不可读 {}:{error}", directory.display()))?; + for entry in entries { + let entry = entry.map_err(|error| format!("随包目录项不可读:{error}"))?; + let file_type = entry + .file_type() + .map_err(|error| format!("随包目录项类型不可读:{error}"))?; + let name = entry.file_name().to_string_lossy().to_string(); + let relative = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }; + if file_type.is_symlink() { + return Err(format!("随包资源不允许符号链接:{relative}")); + } + if file_type.is_dir() { + stack.push((entry.path(), relative)); + } else { + files.insert(relative); + } + } + } + Ok(files) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn declared_target() -> &'static CodexTarget { + codex_target("aarch64-apple-darwin").expect("mac layout") + } + + /// 在临时目录里生成一份合规的 Codex 随包目录。 + fn write_bundle(root: &Path, target: &str) -> serde_json::Value { + let layout = codex_target(target).expect("layout"); + let mut hashes = serde_json::Map::new(); + for relative in layout.files { + let path = root.join(declared_relative_path(relative)); + fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); + fs::write(&path, format!("component {relative}")).expect("write component"); + #[cfg(unix)] + if *relative == layout.executable { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod"); + } + hashes.insert( + relative.to_string(), + serde_json::Value::String(sha256_file(&path).expect("hash")), + ); + } + fs::write(root.join(CODEX.notice_file_name), "notice").expect("write notice"); + let manifest = serde_json::json!({ + "schemaVersion": CODEX_MANIFEST_SCHEMA, + "platform": layout.platform, + "version": CODEX_CLI_VERSION, + "files": hashes, + }); + fs::write( + root.join(CODEX.manifest_file_name), + serde_json::to_string_pretty(&manifest).expect("serialize"), + ) + .expect("write manifest"); + manifest + } + + #[test] + fn valid_bundle_passes() { + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin").expect("valid bundle"); + } + + #[test] + fn rejects_manifest_schema_platform_and_version_drift() { + for (key, value) in [ + ("schemaVersion", serde_json::json!("other-sidecar.v9")), + ("platform", serde_json::json!("darwin-x64")), + ("version", serde_json::json!("codex-cli 0.0.0")), + ] { + let temp = tempfile::tempdir().expect("tempdir"); + let mut manifest = write_bundle(temp.path(), "aarch64-apple-darwin"); + manifest[key] = value; + fs::write( + temp.path().join(CODEX.manifest_file_name), + serde_json::to_string_pretty(&manifest).expect("serialize"), + ) + .expect("write manifest"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject"); + assert!(error.contains("清单"), "{key}: {error}"); + } + } + + #[test] + fn rejects_missing_component_and_digest_drift() { + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + let executable = declared_target().executable; + fs::remove_file(temp.path().join(declared_relative_path(executable))).expect("remove"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject missing component"); + assert!(error.contains(executable), "{error}"); + + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + let path = temp.path().join("codex-package.json"); + fs::write(&path, "tampered").expect("tamper"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject digest drift"); + assert!(error.contains("摘要"), "{error}"); + } + + #[test] + fn rejects_undeclared_file_and_missing_notice() { + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + fs::write(temp.path().join("stray.bin"), "stray").expect("write stray"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject stray file"); + assert!(error.contains("白名单外"), "{error}"); + + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + fs::remove_file(temp.path().join(CODEX.notice_file_name)).expect("remove notice"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject missing notice"); + assert!(error.contains("第三方声明"), "{error}"); + } + + #[cfg(unix)] + #[test] + fn rejects_symlinked_component_and_missing_executable_bit() { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + let executable = temp.path().join("bin/codex"); + fs::remove_file(&executable).expect("remove"); + symlink("codex-code-mode-host", &executable).expect("symlink"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject symlink"); + assert!(error.contains("符号链接"), "{error}"); + + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + let executable = temp.path().join("bin/codex"); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o644)).expect("chmod"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject missing executable bit"); + assert!(error.contains("可执行位"), "{error}"); + } + + #[test] + fn unsupported_target_is_rejected() { + let temp = tempfile::tempdir().expect("tempdir"); + let error = validate_staged_codex_bundle(temp.path(), "x86_64-unknown-linux-gnu") + .expect_err("must reject unsupported target"); + assert!(error.contains("声明不含目标"), "{error}"); + } + + #[test] + fn declaration_pins_codex_layout() { + assert_eq!(DECLARATION_SCHEMA, "agc-package-layout.v1"); + assert_eq!(LAYOUT_VERSION, 1); + assert_eq!(CODEX_VERSION, "0.155.1"); + assert_eq!(CODEX_CLI_VERSION, "codex-cli 0.155.1"); + assert_eq!(CODEX.targets.len(), 3); + let windows = codex_target("x86_64-pc-windows-msvc").expect("windows layout"); + assert_eq!(windows.platform, "win32-x64"); + assert_eq!(windows.directory, "win-x64"); + assert_eq!(windows.executable, "bin/codex.exe"); + assert_eq!(windows.files.len(), 6); + assert_eq!(declared_target().files.len(), 5); + assert!(declared_target() + .files + .contains(&"codex-resources/zsh/bin/zsh")); + assert!(codex_target("universal-apple-darwin").is_none()); + assert!(codex_target("x86_64-unknown-linux-gnu").is_none()); + } + + #[test] + fn universal_group_covers_both_darwin_targets() { + let group = codex_universal_group("aarch64-apple-darwin").expect("darwin group"); + assert_eq!(group.directory, "mac-native"); + assert_eq!( + group.targets, + ["aarch64-apple-darwin", "x86_64-apple-darwin"] + ); + assert_eq!(staged_targets("aarch64-apple-darwin").len(), 2); + assert_eq!( + staged_targets("x86_64-pc-windows-msvc"), + ["x86_64-pc-windows-msvc"] + ); + assert!(staged_targets("x86_64-unknown-linux-gnu").is_empty()); + } + + #[test] + fn notice_sources_cover_every_declared_target() { + for target in ["x86_64-pc-windows-msvc", "aarch64-apple-darwin"] { + assert!( + codex_notice_source(target).is_some(), + "缺少声明来源:{target}" + ); + } + assert!( + codex_notice_source("x86_64-pc-windows-msvc") + .expect("windows notice") + .preserve + ); + assert!( + !codex_notice_source("x86_64-apple-darwin") + .expect("mac notice") + .preserve + ); + } + + #[test] + fn source_candidates_follow_declared_order() { + let rendered = codex_source_candidates( + Path::new("/app"), + Path::new("/repo"), + "aarch64-apple-darwin", + ) + .expect("candidates") + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect::>(); + assert_eq!( + rendered, + [ + "/app/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin", + "/app/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin", + "/repo/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin", + "/repo/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin", + ] + ); + assert!(codex_source_candidates( + Path::new("/app"), + Path::new("/repo"), + "x86_64-unknown-linux-gnu" + ) + .is_err()); + } + + #[test] + fn plugin_rules_match_declared_whitelist() { + assert_eq!(PLUGINS.subdirectories.len(), 5); + assert_eq!(PLUGINS.subdirectories[0].path, "src"); + let payload = PLUGINS + .subdirectories + .iter() + .find(|entry| entry.path == "native/payload") + .expect("native/payload"); + assert!(subdirectory_enabled( + payload, + "x86_64-pc-windows-msvc", + |_| true + )); + assert!(!subdirectory_enabled( + payload, + "aarch64-apple-darwin", + |_| true + )); + let unity = PLUGINS + .subdirectories + .iter() + .find(|entry| entry.path == "dotnet/publish/win-x64") + .expect("unity publish"); + assert!(subdirectory_enabled( + unity, + "x86_64-pc-windows-msvc", + |name| name == "unity-editor-execute" + )); + assert!(!subdirectory_enabled( + unity, + "x86_64-pc-windows-msvc", + |_| false + )); + assert!(!subdirectory_enabled(unity, "aarch64-apple-darwin", |_| { + true + })); + assert!(plugin_staging_applies("x86_64-pc-windows-msvc")); + assert!(plugin_staging_applies("aarch64-apple-darwin")); + assert!(!plugin_staging_applies("x86_64-unknown-linux-gnu")); + assert_eq!(PLUGINS.source_directory, "plugins"); + assert_eq!(PLUGINS.destination_directory, "resources/plugins"); + assert_eq!(PLUGINS.manifest_file_name, "plugin.json"); + } + + #[test] + fn library_staging_rules_are_declared() { + let staging = PLUGINS + .library_staging + .iter() + .find(|entry| entry.layout == "godot-bundle") + .expect("godot staging"); + assert_eq!(staging.plugin, "agc-godot-editor"); + assert_eq!(staging.source_subdirectory, "native/gdextension"); + assert!(library_staging_enabled( + staging, + "x86_64-pc-windows-msvc", + |name| name == "godot-editor-execute" + )); + assert!(!library_staging_enabled( + staging, + "x86_64-pc-windows-msvc", + |_| false + )); + assert!(!library_staging_enabled( + staging, + "aarch64-apple-darwin", + |_| true + )); + } + + #[test] + fn skip_rules_match_copy_semantics() { + assert!(skip_directory("target")); + assert!(skip_directory("node_modules")); + assert!(skip_directory(".git")); + assert!(!skip_directory("src")); + assert!(skip_file_name(".env")); + assert!(skip_file_name("runner.test.mjs")); + assert!(!skip_file_name("runner.mjs")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index e0ceeb6da..a53d8da4f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -4,6 +4,11 @@ #[path = "../build_support/godot_bundle.rs"] mod godot_bundle; +// 复用随包资源声明的既有单测(校验通过/拒绝用例),生产运行时只经 codex_bundle 使用布局。 +#[cfg(test)] +#[path = "../build_support/package_layout.rs"] +mod package_layout; + use std::collections::BTreeMap; use std::fs; use std::fs::{File, OpenOptions}; diff --git a/package.json b/package.json index 87d45e193..2c852e22f 100644 --- a/package.json +++ b/package.json @@ -168,6 +168,10 @@ "agc:build": "npm --prefix apps/ai-game-creator-shell run build --", "agc:skill-pack:check": "npm --prefix apps/ai-game-creator-shell run skill-pack:check", "agc:skill-pack:sync": "npm --prefix apps/ai-game-creator-shell run skill-pack:sync", + "agc:bundled-resources:check": "npm --prefix apps/ai-game-creator-shell run bundled-resources:check", + "agc:bundled-resources:sync": "npm --prefix apps/ai-game-creator-shell run bundled-resources:sync", + "agc:bundled-resources:prepare": "npm --prefix apps/ai-game-creator-shell run bundled-resources:prepare", + "agc:bundled-resources:test": "npm --prefix apps/ai-game-creator-shell run bundled-resources:test", "agc:plugins:test": "node --test plugins/agc-cocos-editor/src/entry.test.mjs plugins/agc-unity-editor/src/entry.test.mjs plugins/agc-godot-editor/src/entry.test.mjs", "agc:plugins:native-test": "cargo test --manifest-path plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml && cargo test --locked --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml && cargo test --locked --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml", "agc:plugins:check": "npm run agc:plugins:test && npm run agc:plugins:native-test",