diff --git a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs index b90209878..3d285feaa 100755 --- a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -9,7 +9,7 @@ // 设计约束:Rust 侧不解析 JSON(避免运行期解析与生命周期妥协),只使用本脚本产出的常量; // Node 侧准备步骤直接读同一份 JSON。因此本门禁是“单一声明”的机械保障。 -import { readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -487,6 +487,46 @@ function appLockedCodexVersion() { return declared.replace(/^[\^~]/, ''); } +const REPO_ROOT = path.resolve(APP_ROOT, '..', '..'); +const PLUGINS_ROOT = path.join(REPO_ROOT, 'plugins'); +const SHELL_MANIFEST_PATH = path.join(SRC_TAURI, 'Cargo.toml'); + +/// 声明里的路径必须是仓库内相对路径:绝对路径或含 `..` 会写到工作区之外。 +function assertRelativePath(value, at) { + if ( + path.isAbsolute(value) || + value.startsWith('/') || + value.split('/').includes('..') + ) { + fail(`${at} 必须是仓库内相对路径(不允许绝对路径或 ..):${value}`); + } + return value; +} + +/// 声明引用的插件必须是真实存在的插件目录(拼错插件的后果是静默不交付)。 +function assertKnownPlugin(name, at) { + const directory = path.join(PLUGINS_ROOT, name); + if (!existsSync(path.join(directory, 'plugin.json'))) { + fail(`${at} 指向的插件不存在或缺少 plugin.json:${name}`); + } + return name; +} + +/// shell crate 的 feature 表(拼错 feature 的后果同样是静默不交付)。 +function shellCrateFeatures() { + const manifest = readFileSync(SHELL_MANIFEST_PATH, 'utf8'); + const section = /\[features\]([\s\S]*?)(?:\n\[|$)/u.exec(manifest); + if (!section) { + fail('无法从 src-tauri/Cargo.toml 读取 [features] 段'); + } + return new Set( + section[1] + .split('\n') + .map((line) => /^([A-Za-z0-9_-]+)\s*=/u.exec(line.trim())?.[1]) + .filter(Boolean), + ); +} + /// 新 section(prepareSteps / nativePayloads / prepared 来源)不参与 Rust 生成物, /// 必须在门禁里单独把关,避免「声明了却没人用」或引用到不存在的准备步骤。 function validateExtendedDeclarations() { @@ -566,6 +606,80 @@ function validateExtendedDeclarations() { } } + const knownFeatures = shellCrateFeatures(); + const checkFeatures = (values, at) => { + for (const name of expectStringArray(values ?? [], at)) { + if (!knownFeatures.has(name)) { + fail(`${at} 引用了 Cargo.toml 里不存在的 feature:${name}`); + } + } + }; + for (const [index, raw] of expectArray( + plugins.subdirectories ?? [], + 'plugins.subdirectories', + ).entries()) { + const at = `plugins.subdirectories[${index}]`; + const subdirectory = expectObject(raw, at); + assertRelativePath( + expectString(subdirectory.path, `${at}.path`), + `${at}.path`, + ); + if (subdirectory.plugin) { + assertKnownPlugin(subdirectory.plugin, `${at}.plugin`); + } + checkFeatures(subdirectory.features, `${at}.features`); + } + for (const [index, raw] of expectArray( + plugins.libraryStaging ?? [], + 'plugins.libraryStaging', + ).entries()) { + const at = `plugins.libraryStaging[${index}]`; + const staging = expectObject(raw, at); + assertKnownPlugin( + expectString(staging.plugin, `${at}.plugin`), + `${at}.plugin`, + ); + assertRelativePath( + expectString(staging.sourceSubdirectory, `${at}.sourceSubdirectory`), + `${at}.sourceSubdirectory`, + ); + for (const relative of expectStringArray( + staging.files ?? [], + `${at}.files`, + )) { + assertRelativePath(relative, `${at}.files`); + } + checkFeatures(staging.features, `${at}.features`); + } + for (const payload of payloads) { + assertRelativePath( + payload.destinationSubdirectory, + `nativePayloads.${payload.plugin}.destinationSubdirectory`, + ); + checkFeatures( + payload.features, + `nativePayloads.${payload.plugin}.features`, + ); + } + for (const [index, raw] of steps.entries()) { + const at = `plugins.prepareSteps[${index}]`; + const step = expectObject(raw, at); + for (const key of ['workingDirectory', 'packageDirectory']) { + if (step[key]) { + assertRelativePath( + expectString(step[key], `${at}.${key}`), + `${at}.${key}`, + ); + } + } + for (const relative of expectStringArray( + step.requiredOutputs ?? [], + `${at}.requiredOutputs`, + )) { + assertRelativePath(relative, `${at}.requiredOutputs`); + } + } + const referenced = [ ...expectArray(plugins.subdirectories ?? [], 'plugins.subdirectories') .filter( diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs index 44a83c92e..ca1cffe52 100755 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -1312,15 +1312,25 @@ export function prepareBundledResources({ return summaries; } +function requireFlagValue(argv, index, flag) { + const value = argv[index + 1]; + if (value === undefined || String(value).startsWith('--')) { + fail( + `${flag} 缺少取值(例如 ${flag} );拒绝回退到宿主默认值,以免准备错目标`, + ); + } + return String(value); +} + 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]; + args.target = requireFlagValue(argv, index, '--target'); index += 1; } else if (value === '--destination') { - args.destinationRoot = argv[index + 1]; + args.destinationRoot = requireFlagValue(argv, index, '--destination'); index += 1; } else if (value === '--dry-run') { args.dryRun = true; 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 index 0a161a4ad..7b66a0ed1 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs @@ -140,6 +140,21 @@ function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) { fs.writeFileSync(path.join(pluginRoot, '.git/HEAD'), 'ref\n'); fs.writeFileSync(path.join(pluginRoot, '.env'), 'secret\n'); + for (const name of [ + 'agc-cocos-editor', + 'agc-unity-editor', + 'agc-godot-editor', + ]) { + const root = path.join(repoRoot, 'plugins', name); + fs.mkdirSync(path.join(root, 'src'), { + recursive: true, + }); + fs.writeFileSync(path.join(root, 'plugin.json'), `{"name":"${name}"}\n`); + fs.writeFileSync( + path.join(root, 'src/entry.mjs'), + `export const ${name} = 1;\n`, + ); + } const cocosRoot = path.join(repoRoot, 'plugins/agc-cocos-editor'); fs.mkdirSync(path.join(cocosRoot, 'src'), { recursive: true }); fs.writeFileSync( @@ -515,7 +530,7 @@ test('declaration drives source lookup and staging units', () => { source, /codex-win32-x64[\\/]vendor[\\/]x86_64-pc-windows-msvc$/u, ); - assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 2); + assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 4); } finally { fixture.cleanup(); } @@ -605,3 +620,42 @@ test('fails closed when a prepare step does not produce its declared outputs', ( fixture.cleanup(); } }); + +test('delivers editor branch artifacts declared as prepared or library staging', () => { + const fixture = buildFixture(); + try { + const features = new Set([ + 'unity-editor-execute', + 'godot-editor-execute', + 'cocos-editor-injection', + ]); + prepare(fixture, { features }); + const staged = (relative) => + path.join(fixture.destinationRoot, 'resources/plugins', relative); + + // Unity:prepared 子目录整体复制 + assert.ok( + fs.existsSync( + staged('agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe'), + ), + 'Unity helper 必须随包', + ); + // Godot:libraryStaging 声明文件逐个复制 + assert.ok( + fs.existsSync( + staged( + 'agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll', + ), + ), + 'Godot 扩展必须随包', + ); + assert.ok( + fs.existsSync( + staged('agc-godot-editor/native/gdextension/vendor/provenance.json'), + ), + 'Godot 随包清单文件必须随包', + ); + } finally { + fixture.cleanup(); + } +}); diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs b/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs index 02af4c3a5..0c0061d5b 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs @@ -99,4 +99,22 @@ mod tests { ) .unwrap(); } + + #[test] + fn validate_rejects_incomplete_bundle() { + let temp = tempfile::tempdir().expect("tempdir"); + let error = validate(temp.path()).expect_err("空目录必须被拒绝"); + assert!(!error.is_empty(), "失败原因不能为空"); + + // 只有文件名、内容不合法的「像样」目录也必须被拒绝。 + for relative in BUNDLE_FILES { + let file = temp.path().join(relative); + fs::create_dir_all(file.parent().expect("parent")).expect("create dir"); + fs::write(&file, b"not a real artifact").expect("write file"); + } + assert!( + validate(temp.path()).is_err(), + "内容不合法的随包库必须被拒绝", + ); + } } diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 8ecb6365b..d56284c31 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -88,7 +88,7 @@ Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter- AGC 随包资源(内置 Codex CLI、插件工作区)的布局与组件白名单只有一份人工声明:`apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`。Node 侧准备步骤直接读它,Rust 侧读由 `node scripts/check-package-layout.mjs --write`(仓库根 `npm run agc:bundled-resources:sync`)生成的 `build_support/package-layout.generated.rs`;门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链,两者不一致直接失败。改布局只能改声明文件再同步生成物,不要手改生成文件,也不要另写第二份白名单。准备步骤是 `node apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`:写临时目录后原子替换、命中缓存不写任何文件、只替换本工具产物、失败即退出并给出可执行提示;其用例为 `npm run agc:bundled-resources:test`。内置 Codex CLI 的上游平台包来自仓库根 `npm ci`,缺失时工具会直接提示重新安装。构建脚本对既有随包产物做只读校验(不再有写入分支,因此也没有跳过写入的开关)。 -随包资源由准备步骤在 Tauri 之前生成:`npm run agc` 在 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 里、spawn Tauri CLI 之前调用(日志以 `[ai-game-creator-shell]` 前缀给出命中缓存或重新生成);发布链在 `build-release.mjs` 的 `runTauriBuild` 内与 Node 运行时 staging 并列调用,并传 `profile: 'release'`,`tauri build --no-bundle` 不强制 staging。**构建脚本完全不生成随包资源**(源码派生内容逐文件比对、已准备产物查存在性、Godot 随包库跑既有深度校验),源码不变时 `cargo build` 稳定 fresh。需要外部工具链或同一次 cargo 构建才能产出的内容(Unity publish 目录、Godot gdextension、Cocos bridge dll)也由准备步骤按 `build_support/package-layout.json` 的 `prepareSteps` 先运行 `powershell.exe -File build.ps1` 或 `cargo build -p … --target …` 再复制;这些步骤按内容指纹跳过未变化的情况。`resources/plugins` 由准备步骤拥有:不要手工往里放东西,准备步骤会按仓库 `plugins/` 与声明重建;`.taurignore` 的 staging 条目已删除(构建期不再写该目录)。 +随包资源由准备步骤在 Tauri 之前生成:`npm run agc` 在 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 里、spawn Tauri CLI 之前调用(日志以 `[ai-game-creator-shell]` 前缀给出命中缓存或重新生成);发布链在 `build-release.mjs` 的 `runTauriBuild` 内与 Node 运行时 staging 并列调用,并传 `profile: 'release'`,`tauri build --no-bundle` 不强制 staging。**构建脚本完全不生成随包资源**(源码派生内容逐文件比对、已准备产物查存在性、Godot 随包库跑既有深度校验),源码不变时 `cargo build` 稳定 fresh。需要外部工具链或同一次 cargo 构建才能产出的内容(Unity publish 目录、Godot gdextension、Cocos bridge dll)也由准备步骤按 `build_support/package-layout.json` 的 `prepareSteps` 先运行 `powershell.exe -File build.ps1` 或 `cargo build -p … --target …` 再复制;这些步骤按内容指纹跳过未变化的情况。`resources/plugins` 由准备步骤拥有:不要手工往里放东西,准备步骤会按仓库 `plugins/` 与声明重建;dev 构建下客户端读的是 `target/debug/plugins/**`(Tauri 在 debug 配置下把随包资源拷到那里),所以改完资源要重跑准备步骤而不是手动改 `resources/`;`.taurignore` 的 staging 条目已删除(构建期不再写该目录)。 ### 本地 Rust 构建缓存与磁盘上限