diff --git a/.gitignore b/.gitignore index 88a842855..652820e39 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,12 @@ temp*build*/ /apps/ai-game-creator-shell/src-tauri/resources/node-runtime/ /apps/ai-game-creator-shell/src-tauri/resources/claude-agent/ /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/src-tauri/resources/plugins-backup-*/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/*-backup-*/ +/apps/ai-game-creator-shell/src-tauri/resources/claude-agent-staging-*/ +/apps/ai-game-creator-shell/src-tauri/resources/claude-agent-backup-*/ /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/.taurignore b/apps/ai-game-creator-shell/.taurignore deleted file mode 100644 index 8ec126556..000000000 --- a/apps/ai-game-creator-shell/.taurignore +++ /dev/null @@ -1,4 +0,0 @@ -# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。 -# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发 -# “构建 -> 监听 -> 再构建”的自触发循环。 -resources/plugins/ diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 83dbe4b56..b0cef6b19 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": { "@anthropic-ai/claude-agent-sdk": "0.3.285", diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 3ceaddaf8..bfb958806 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -11,6 +11,8 @@ import { } from './agc-global-version.mjs'; import { defaultEditorFeatures, + readCargoTarget, + resolveEditorFeatures, withDefaultCargoFeatures, } from './cargo-features.mjs'; import { @@ -19,6 +21,10 @@ import { resolveReleaseChannel, } from './channel-identity.mjs'; import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs'; +import { + prepareBundledResources, + supportedHostTarget, +} from './prepare-bundled-resources.mjs'; import { stageNodeRuntime } from './stage-node-runtime.mjs'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -40,26 +46,7 @@ function defaultTarget() { } function explicitBuildTarget(args) { - let target; - const separator = args.indexOf('--'); - const options = separator < 0 ? args : args.slice(0, separator); - for (let index = 0; index < options.length; index += 1) { - const argument = options[index]; - let value; - if (argument === '--target' || argument === '-t') { - value = options[++index]; - } else if (argument.startsWith('--target=')) { - value = argument.slice('--target='.length); - } else { - continue; - } - if (!value?.trim() || value.startsWith('-')) { - throw new Error('--target 缺少有效目标'); - } - if (target !== undefined) throw new Error('不能重复指定 --target'); - target = value.trim(); - } - return target; + return readCargoTarget(args); } function validateReleaseTarget(target) { @@ -469,10 +456,32 @@ function writeChannelConfigFile(channel, target, includeNodeRuntime = false) { return configPath; } +/// 随包资源必须在打包工具之前生成:构建脚本只做只读校验,不再生成。 +export function stageBundledResources( + target, + { prepare = prepareBundledResources, features } = {}, +) { + const summaries = prepare({ + target, + features: new Set(features ?? defaultEditorFeatures(target)), + profile: 'release', + log: (line) => console.log(`[ai-game-creator-shell] ${line}`), + }); + for (const summary of summaries) { + console.log(`[ai-game-creator-shell] ${summary}`); + } +} + export function runTauriBuild( args = [], context = resolveReleaseContext(args), - { spawn = spawnSync, stageRuntime = stageNodeRuntime } = {}, + { + spawn = spawnSync, + stageRuntime = stageNodeRuntime, + stageBundled = stageBundledResources, + platform = process.platform, + arch = process.arch, + } = {}, ) { if ( explicitBuildTarget(args) && @@ -480,14 +489,44 @@ export function runTauriBuild( ) { throw new Error('构建参数与发布上下文目标不一致'); } - const tauriArguments = buildTauriBuildArguments(args, context.target); - const { channel, target } = context; - if (!args.includes('--no-bundle')) stageRuntime(target); - const configPath = writeChannelConfigFile( - channel, - target, - !args.includes('--no-bundle'), + const tauriArguments = buildTauriBuildArguments( + args, + context.target, + platform, ); + const bundling = !args.includes('--no-bundle'); + // 无显式 target 的 no-bundle 由 Cargo 构建宿主平台,不使用默认发布目标。 + const resourceTarget = + explicitBuildTarget(args) || + (bundling ? context.target : supportedHostTarget(platform, arch)); + if ( + !bundling && + !resourceTarget && + defaultEditorFeatures(platform).length > 0 + ) { + throw new Error( + `当前宿主 ${platform}/${arch} 不在随包资源声明覆盖内;请用 --target 指定受支持的构建目标`, + ); + } + const features = resolveEditorFeatures({ + argv: tauriArguments, + target: resourceTarget ?? platform, + env: {}, + }); + const { channel } = context; + if (bundling) { + stageRuntime(resourceTarget); + } + if (resourceTarget) { + stageBundled(resourceTarget, { features }); + } else { + console.log( + '[ai-game-creator-shell] 当前宿主平台不参与客户端随包资源构建,跳过资源准备', + ); + } + // Linux smoke 不发布 updater,沿用渠道上下文;桌面宿主必须与实际构建目标一致。 + const target = resourceTarget ?? context.target; + const configPath = writeChannelConfigFile(channel, target, bundling); console.log( `[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`, ); diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 8724bcce1..4b6fc20cc 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -38,6 +38,7 @@ import { AGC_PRODUCT_NAME, resolveChannelInstallIdentity, } from './channel-identity.mjs'; +import { supportedHostTarget } from './prepare-bundled-resources.mjs'; const windowsTarget = 'x86_64-pc-windows-msvc'; const universalTarget = 'universal-apple-darwin'; @@ -504,6 +505,8 @@ test('packaged renderer receives the same channel as the updater manifest', () = // 必须 stub:真实 staging 会用宿主平台(如 macOS 的 darwin/arm64)去对默认的 // Windows 目标做一致性校验,在非 Windows 主机上直接失败——本用例只关心渠道注入。 stageRuntime: () => {}, + // 同上:随包资源准备会读取真实上游包,本用例只关心渠道环境变量。 + stageBundled: () => {}, spawn: (_binary, _args, options) => { spawnOptions = options; return { status: 0 }; @@ -594,6 +597,8 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and seenContexts.push(context); runTauriBuild(args, context, { stageRuntime: () => {}, + // 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。 + stageBundled: () => {}, spawn: (_binary, command) => { const configIndex = command.lastIndexOf('--config'); const config = JSON.parse( @@ -843,6 +848,7 @@ test('Windows remains the default and explicit Windows overrides macOS environme context, { stageRuntime: () => {}, + stageBundled: () => {}, spawn: (_binary, command) => { assert.ok( command.includes( @@ -960,6 +966,10 @@ test('release stages Node before Tauri and injects its resource mapping only for assert.equal(target, windowsTarget); events.push('stage'); }, + stageBundled(target) { + assert.equal(target, windowsTarget); + events.push('bundled'); + }, spawn(_binary, args) { events.push('build'); const config = JSON.parse( @@ -975,12 +985,24 @@ test('release stages Node before Tauri and injects its resource mapping only for return { status: 0 }; }, }); - assert.deepEqual(events, ['stage', 'build']); + assert.deepEqual(events, ['stage', 'bundled', 'build']); + events.length = 0; runTauriBuild(['--no-bundle', '--target', windowsTarget], context, { stageRuntime() { - assert.fail('no-bundle must not stage resources'); + assert.fail('no-bundle must not stage node runtime'); + }, + stageBundled(target, options) { + // app 构建本身就需要随包资源,因此 --no-bundle 也要 staging, + // 且必须拿到与 cargo 相同的 feature 集(避免「cargo 开、staging 没开」)。 + assert.equal(target, windowsTarget); + assert.ok( + options?.features?.length > 0, + 'bundled staging 必须收到 feature 集', + ); + events.push('bundled'); }, spawn(_binary, args) { + events.push('build'); const config = JSON.parse( readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'), ); @@ -993,12 +1015,16 @@ test('release stages Node before Tauri and injects its resource mapping only for return { status: 0 }; }, }); + assert.deepEqual(events, ['bundled', 'build']); assert.throws( () => runTauriBuild(['--target', windowsTarget], context, { stageRuntime() { throw new Error('missing runtime'); }, + stageBundled() { + assert.fail('invalid runtime must prevent bundled staging'); + }, spawn() { assert.fail('invalid runtime must prevent build'); }, @@ -1007,6 +1033,297 @@ test('release stages Node before Tauri and injects its resource mapping only for ); }); +const windowsEditorFeatures = [ + 'cocos-editor-execute', + 'unity-editor-execute', + 'godot-editor-execute', +]; + +function uniqueSorted(values) { + return [...new Set(values ?? [])].sort(); +} + +function splitFeatureList(value) { + return String(value ?? '') + .split(',') + .map((name) => name.trim()) + .filter(Boolean); +} + +/// 最终命令行里 Cargo 实际会收到的 feature 集:tauri 自身的 `--features` / `-f` +/// 与 `--` 之后的 runner 参数(tauri 会把它们追加到 cargo 命令)都算数, +/// 随包资源准备必须拿到同一集合。 +function cargoFeaturesFromCommand(command) { + const argv = command.slice(command.indexOf('--') + 1); + const features = []; + for (let index = 0; index < argv.length; index += 1) { + const argument = String(argv[index]); + if (argument.startsWith('--features=')) { + features.push(...splitFeatureList(argument.slice('--features='.length))); + } else if (argument === '--features' || argument === '-f') { + features.push(...splitFeatureList(argv[index + 1])); + index += 1; + } else if (/^-f.+/u.test(argument)) { + features.push(...splitFeatureList(argument.slice(2))); + } + } + return uniqueSorted(features); +} + +/** + * PR520 的 CI 回归:`tauri build --no-bundle` 不带显式 target 时编译的是**宿主**平台, + * 而发布上下文默认目标始终是 Windows。随包资源与 feature 集必须跟着宿主走, + * 否则 Linux 宿主会拿着 Windows 目标去 staging(CI smoke 直接失败), + * 还会出现「cargo 没开 feature、staging 开了」的错配。 + */ +test('no-bundle 在无随包资源声明的宿主(Linux)既不 staging 也不注入 Windows feature', () => { + const context = resolveReleaseContext(['--no-bundle'], {}); + assert.equal(context.target, windowsTarget); + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild(['--no-bundle'], context, { + platform: 'linux', + arch: 'x64', + stageRuntime() { + assert.fail('--no-bundle 不得准备 Node 运行时'); + }, + stageBundled(target) { + assert.fail(`Linux 宿主没有随包资源声明,不得 staging ${target}`); + }, + spawn(_binary, args) { + command = args; + return { status: 0 }; + }, + }); + }); + assert.ok(command, '宿主不受声明覆盖时仍必须完成本次构建'); + assert.ok(!command.includes('--target'), '宿主构建不得打显式 target'); + assert.deepEqual( + cargoFeaturesFromCommand(command), + [], + 'Linux 宿主不得注入 Windows 编辑器 feature', + ); +}); + +test('no-bundle 在 macOS / Windows 宿主按真实宿主三元组准备随包资源', () => { + const context = resolveReleaseContext(['--no-bundle'], {}); + for (const [platform, arch, hostTarget, expectedFeatures] of [ + ['darwin', 'arm64', 'aarch64-apple-darwin', []], + ['darwin', 'x64', 'x86_64-apple-darwin', []], + ['win32', 'x64', windowsTarget, windowsEditorFeatures], + ]) { + const label = `${platform}/${arch}`; + let staged; + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild(['--no-bundle'], context, { + platform, + arch, + stageRuntime() { + assert.fail('--no-bundle 不得准备 Node 运行时'); + }, + stageBundled(target, options) { + staged = { target, features: options?.features }; + }, + spawn(_binary, args) { + command = args; + return { status: 0 }; + }, + }); + }); + assert.equal( + staged?.target, + hostTarget, + `${label} 必须按宿主三元组 staging`, + ); + assert.deepEqual( + uniqueSorted(staged?.features), + uniqueSorted(expectedFeatures), + `${label} staging feature 与宿主不一致`, + ); + assert.deepEqual( + cargoFeaturesFromCommand(command), + uniqueSorted(expectedFeatures), + `${label} Cargo feature 与 staging 不一致`, + ); + assert.ok( + !command.includes('--target'), + `${label} 宿主构建不得打显式 target`, + ); + } +}); + +test('no-bundle 的显式 target 优先于宿主:跨平台 / 跨架构仍按参数准备', () => { + for (const [platform, arch, target, expectedFeatures] of [ + ['linux', 'x64', windowsTarget, windowsEditorFeatures], + ['darwin', 'arm64', 'x86_64-apple-darwin', []], + ]) { + const args = ['--no-bundle', '--target', target]; + const context = resolveReleaseContext(args, {}); + const label = `${platform}/${arch} → ${target}`; + let staged; + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild(args, context, { + platform, + arch, + stageRuntime() { + assert.fail('--no-bundle 不得准备 Node 运行时'); + }, + stageBundled(stageTarget, options) { + staged = { target: stageTarget, features: options?.features }; + }, + spawn(_binary, argv) { + command = argv; + return { status: 0 }; + }, + }); + }); + assert.equal(staged?.target, target, `${label} 必须按显式 target staging`); + assert.ok( + command.includes(target), + `${label} 必须把显式 target 传给 Tauri`, + ); + assert.deepEqual( + uniqueSorted(staged?.features), + uniqueSorted(expectedFeatures), + `${label} staging feature 与预期不一致`, + ); + assert.deepEqual( + cargoFeaturesFromCommand(command), + uniqueSorted(expectedFeatures), + `${label} Cargo feature 与 staging 不一致`, + ); + } +}); + +test('打包构建(非 no-bundle)的 staging 目标跟随发布上下文而不是宿主', () => { + const crossArch = 'x86_64-apple-darwin'; + const context = resolveReleaseContext([], { AGC_BUILD_TARGET: crossArch }); + assert.equal(context.target, crossArch); + let runtimeTarget; + let staged; + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild([], context, { + platform: 'darwin', + arch: 'arm64', + stageRuntime(target) { + runtimeTarget = target; + }, + stageBundled(target, options) { + staged = { target, features: options?.features }; + }, + spawn(_binary, args) { + command = args; + return { status: 0 }; + }, + }); + }); + assert.equal(runtimeTarget, crossArch); + assert.equal( + staged?.target, + crossArch, + '打包构建必须按发布上下文目标 staging', + ); + assert.deepEqual(uniqueSorted(staged?.features), []); + assert.ok( + command.includes(crossArch), + '打包构建必须把发布上下文目标传给 Tauri', + ); + assert.deepEqual(cargoFeaturesFromCommand(command), []); +}); + +test('staging feature 与最终 Cargo feature 一致:-f / --features / -- 之后的 runner 参数', () => { + const context = resolveReleaseContext(['--no-bundle'], {}); + const cases = [ + { + args: ['--no-bundle', '--features', 'custom-editor-feature'], + expected: ['custom-editor-feature'], + }, + { + args: ['--no-bundle', '--features=custom-a,custom-b'], + expected: ['custom-a', 'custom-b'], + }, + { + args: ['--no-bundle', '-f', 'custom-editor-feature'], + expected: ['custom-editor-feature'], + }, + { + args: ['--no-bundle', '-fcustom-editor-feature'], + expected: ['custom-editor-feature'], + }, + // `--` 之后的参数是 runner 参数:tauri 会把它们追加到 cargo 命令行 + // (`tauri build --help`:Command line arguments passed to the runner), + // 因此 Cargo 最终拿到的是「注入的默认值 ∪ runner 参数」,staging 必须一致。 + { + args: ['--no-bundle', '--', '--features=app-runner'], + expected: [...windowsEditorFeatures, 'app-runner'], + }, + // 环境变量泄漏(Jenkins Job 参数)不得让 staging 与 Cargo 分叉。 + { + args: ['--no-bundle'], + env: { AGC_DEV_CARGO_FEATURES: 'cocos-editor-execute' }, + }, + ]; + for (const entry of cases) { + const { args, expected } = entry; + const label = args.join(' '); + let staged; + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined, ...entry.env }, () => { + runTauriBuild(args, context, { + platform: 'win32', + arch: 'x64', + stageRuntime() { + assert.fail(`${label} 不得准备 Node 运行时`); + }, + stageBundled(target, options) { + staged = { target, features: options?.features }; + }, + spawn(_binary, argv) { + command = argv; + return { status: 0 }; + }, + }); + }); + assert.equal(staged?.target, windowsTarget, label); + const cargoFeatures = cargoFeaturesFromCommand(command); + assert.deepEqual( + uniqueSorted(staged?.features), + cargoFeatures, + `${label}:staging feature 必须等于 Cargo 收到的 feature`, + ); + if (expected) { + assert.deepEqual( + cargoFeatures, + uniqueSorted(expected), + `${label}:Cargo feature 与预期不一致`, + ); + } + } +}); + +test('未注入 platform / arch 时按真实 process 平台取宿主随包目标', () => { + const hostTarget = supportedHostTarget(process.platform, process.arch); + const context = resolveReleaseContext(['--no-bundle'], {}); + const events = []; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild(['--no-bundle'], context, { + stageRuntime() { + assert.fail('--no-bundle 不得准备 Node 运行时'); + }, + stageBundled(target) { + events.push(target); + }, + spawn() { + return { status: 0 }; + }, + }); + }); + assert.deepEqual(events, hostTarget ? [hostTarget] : []); +}); + test('channel manifest carries version, platform keys and signature', () => { withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => { withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => { @@ -1111,6 +1428,8 @@ for (const channel of ['release', 'beta-2']) { ); runTauriBuild([`--target=${target}`], context, { stageRuntime: () => {}, + // 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。 + stageBundled: () => {}, spawn: (_binary, command) => { const config = JSON.parse( readFileSync( diff --git a/apps/ai-game-creator-shell/scripts/cargo-features.mjs b/apps/ai-game-creator-shell/scripts/cargo-features.mjs index 515432f5b..588912ac1 100644 --- a/apps/ai-game-creator-shell/scripts/cargo-features.mjs +++ b/apps/ai-game-creator-shell/scripts/cargo-features.mjs @@ -1,24 +1,135 @@ /** 默认桌面能力;显式 feature 参数优先,不把应用参数当 Cargo 参数。 */ export function withDefaultCargoFeatures(argv, features) { - const separator = argv.indexOf('--'); - const cargoArgs = separator < 0 ? argv : argv.slice(0, separator); - if ( - !features.length || - cargoArgs.some( - (value) => - value === '--features' || - value === '-f' || - value.startsWith('--features=') || - /^-f.+/u.test(value), - ) - ) { + // 只看第一个 `--` 之前的 Tauri 选项:runner 参数区里的 `--features` 会与这里注入的 + // 默认值在 Cargo 侧合并,不能当作「用户已经指定过了」而跳过注入。 + if (!features.length || tauriOptions(argv).some(hasExplicitFeatures)) { return argv; } return [`--features=${features.join(',')}`, ...argv]; } +/// 第一个 `--` 之前是 Tauri 自己的选项,之后是 runner(Cargo)参数。 +function tauriOptions(argv) { + const separator = argv.indexOf('--'); + return separator < 0 ? argv : argv.slice(0, separator); +} + +/** + * 交给 Tauri 与 Cargo 的参数区间:第一个 `--` 之前是 Tauri 选项,之后到第二个 `--` 是 + * runner(Cargo)参数;第二个 `--` 之后由 Tauri 转交应用,不属于构建参数。 + * dev 入口(`buildTauriArguments` 会把启动器的单个 `--` 补成 runner + 应用两段)与发布 + * 入口都用这一份区间解析,两边的目标与 feature 必须来自同一个口径。 + */ +function cargoArguments(argv) { + const separator = argv.indexOf('--'); + if (separator < 0) return argv; + const applicationStart = argv.indexOf('--', separator + 1); + return applicationStart < 0 ? argv : argv.slice(0, applicationStart); +} + +/// 四种拼写(`--features x` / `--features=x` / `-f x` / `-fx`)都算显式指定。 +function hasExplicitFeatures(value) { + return ( + value === '--features' || + value === '-f' || + value.startsWith('--features=') || + /^-f.+/u.test(value) + ); +} + +function parseFeatureNames(raw) { + return String(raw) + .split(/[\s,]+/u) + .map((name) => name.trim()) + .filter(Boolean); +} + export function defaultEditorFeatures(target) { return target === 'win32' || target.includes('windows') ? ['cocos-editor-execute', 'unity-editor-execute', 'godot-editor-execute'] : []; } + +/** + * 显式 Cargo 目标解析:`--target ` / `--target=` / `-t `。 + * 应用参数区(第二个 `--` 之后)不参与解析。未显式指定返回 undefined;取值缺失、 + * 取到另一个选项或重复指定都失败关闭。 + * dev 入口与发布入口共用这一份解析:cargo 的构建目标与随包资源 staging 目标 + * 必须来自同一个显式来源,不能一方读参数、另一方回退宿主默认值。 + */ +export function readCargoTarget(argv = []) { + const options = cargoArguments(argv); + let target; + for (let index = 0; index < options.length; index += 1) { + const argument = String(options[index]); + let value; + if (argument === '--target' || argument === '-t') { + value = options[index + 1]; + index += 1; + } else if (argument.startsWith('--target=')) { + value = argument.slice('--target='.length); + } else if (argument.startsWith('-t') && argument.length > 2) { + value = argument.slice(2).replace(/^=/u, ''); + } else { + continue; + } + if (!value?.trim() || String(value).startsWith('-')) { + throw new Error('--target 缺少有效目标'); + } + if (target !== undefined) throw new Error('不能重复指定 --target'); + target = String(value).trim(); + } + return target; +} + +/** + * 单一声明式解析:本次构建实际生效的编辑器 feature 集。 + * 解析顺序:命令行 `--features`(Cargo 真正收到的参数,不能被环境覆盖) + * > 环境变量 `AGC_DEV_CARGO_FEATURES`(存在即以它为准,空串即关闭默认 feature) + * > 目标平台默认值。 + * 传入的必须是「最终交给 Tauri 的参数」:dev 入口与发布入口的 cargo 参数与随包资源 + * 准备步骤都消费这里的返回值,否则会出现「cargo 开了 feature、staging 没开(或反之)」。 + */ +export function resolveEditorFeatures({ + argv = [], + target, + env = process.env, +} = {}) { + const fromArgv = featuresFromArgv(argv); + if (fromArgv) { + return fromArgv; + } + // 变量存在本身即声明「本次 feature 集由环境决定」:空串是有意的关闭,不是未设置。 + if (env.AGC_DEV_CARGO_FEATURES !== undefined) { + return parseFeatureNames(env.AGC_DEV_CARGO_FEATURES); + } + return defaultEditorFeatures(target); +} + +/// 汇总 cargo 参数区里出现在任何位置的 `--features` / `-f`(Cargo 会把多个 flag 合并); +/// 没有任何 feature flag 时返回 null。显式但为空(如 `--features=`)返回空数组。 +export function featuresFromArgv(argv = []) { + const options = cargoArguments(argv); + const features = []; + let found = false; + for (let index = 0; index < options.length; index += 1) { + const value = String(options[index]); + if (value.startsWith('--features=')) { + found = true; + features.push(...parseFeatureNames(value.slice('--features='.length))); + } else if (value === '--features' || value === '-f') { + found = true; + while ( + index + 1 < options.length && + !String(options[index + 1]).startsWith('-') + ) { + index += 1; + features.push(...parseFeatureNames(options[index])); + } + } else if (value.startsWith('-f') && value.length > 2) { + found = true; + features.push(...parseFeatureNames(value.slice(2).replace(/^=/u, ''))); + } + } + return found ? features : null; +} diff --git a/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs b/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs index 3358524b9..baf462966 100644 --- a/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs +++ b/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs @@ -2,7 +2,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { buildTauriBuildArguments } from './build-release.mjs'; -import { withDefaultCargoFeatures } from './cargo-features.mjs'; +import { + featuresFromArgv, + withDefaultCargoFeatures, +} from './cargo-features.mjs'; test('Windows release includes the same editor feature as development', () => { assert.deepEqual( @@ -47,3 +50,16 @@ test('explicit Cargo features override defaults in every supported spelling', () ['--features=cocos-editor-execute', '--', '--features=app'], ); }); + +test('features 变参会完整消费空格与逗号分隔的值', () => { + assert.deepEqual( + featuresFromArgv([ + '-f', + 'cocos-editor-execute,unity-editor-execute', + 'godot-editor-execute', + '--target', + 'x86_64-pc-windows-msvc', + ]), + ['cocos-editor-execute', 'unity-editor-execute', 'godot-editor-execute'], + ); +}); diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index a2c349e77..ca6897814 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1358,7 +1358,6 @@ for (const channel of ['release', 'beta-2']) { const expectedBundledDesignAgentResources = { 'design-agent': 'design-agent', - 'resources/claude-agent': 'claude-agent', ...Object.fromEntries( [ 'codex-patch-parser', @@ -1387,6 +1386,7 @@ const expectedBundledWindowsResources = { 'resources/codex/win-x64/NOTICE.md': 'coding-agent/win-x64/NOTICE.md', 'resources/codex/win-x64/manifest.json': 'coding-agent/win-x64/manifest.json', 'resources/plugins': 'plugins', + 'resources/claude-agent': 'claude-agent', }; assert.deepEqual( tauriConfig.bundle?.resources, @@ -1399,6 +1399,13 @@ for (const key of Object.keys(tauriConfig.bundle?.resources ?? {})) { 'AI game creator shell base Tauri config must not require Windows-only Codex resources', ); } + // 基线配置同时服务 Linux(只有 CI 会编译壳 crate):随包资源由准备步骤按目标生成, + // 基线声明它们会让没有 node_modules 的平台在构建期就因资源缺失失败。 + if (String(key).startsWith('resources/')) { + throw new Error( + 'AI game creator shell base Tauri config must not require platform-only bundled resources', + ); + } } assert.deepEqual( windowsTauriConfig.bundle?.resources, @@ -1438,6 +1445,7 @@ assert.deepEqual( ]), ), ['resources/plugins', 'plugins'], + ['resources/claude-agent', 'claude-agent'], ]), 'macOS must bundle the complete native Codex layout and plugin workspace', ); 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..5682da895 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -0,0 +1,917 @@ +#!/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 { existsSync, 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; +} + +// 随包子目录来源:`source` 直接来自仓库源码(准备步骤复制), +// `prepared` 需要先由准备步骤运行声明的构建命令产出,再复制进随包目录。 +function expectOrigin(value, at) { + if (value !== 'source' && value !== 'prepared') { + fail(`${at} 必须是 source 或 prepared(实际 ${String(value)})`); + } + 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 parsePackageMetadata(value) { + const metadata = expectObject(value, 'codex.packageMetadata'); + return { + layoutVersion: expectInteger( + metadata.layoutVersion, + 'codex.packageMetadata.layoutVersion', + ), + resourcesDir: expectString( + metadata.resourcesDir, + 'codex.packageMetadata.resourcesDir', + ), + pathDir: expectString(metadata.pathDir, 'codex.packageMetadata.pathDir'), + }; +} + +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`), + origin: expectOrigin(parsed.origin, `${at}.origin`), + plugin: + parsed.plugin === undefined + ? '' + : expectString(parsed.plugin, `${at}.plugin`), + 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); + const files = expectStringArray(parsed.files, `${at}.files`); + if (files.length === 0) { + fail(`${at}.files 不能为空`); + } + return { + plugin: expectString(parsed.plugin, `${at}.plugin`), + sourceSubdirectory: expectString( + parsed.sourceSubdirectory, + `${at}.sourceSubdirectory`, + ), + prepare: expectString(parsed.prepare, `${at}.prepare`), + targets: expectStringArray(parsed.targets, `${at}.targets`), + features: expectStringArray(parsed.features, `${at}.features`), + layout: expectString(parsed.layout, `${at}.layout`), + files, + }; + }); + + const claudeAgent = expectObject(root.claudeAgent, 'claudeAgent'); + const claudeAgentTargets = expectArray( + claudeAgent.targets, + 'claudeAgent.targets', + ).map((entry, index) => { + const at = `claudeAgent.targets[${index}]`; + const parsed = expectObject(entry, at); + return { + target: expectString(parsed.target, `${at}.target`), + runtimePackage: expectString( + parsed.runtimePackage, + `${at}.runtimePackage`, + ), + runtimeFileName: expectString( + parsed.runtimeFileName, + `${at}.runtimeFileName`, + ), + }; + }); + const seenClaudeAgentTargets = new Set(); + for (const entry of claudeAgentTargets) { + if (seenClaudeAgentTargets.has(entry.target)) { + fail(`claudeAgent.targets 重复声明目标 ${entry.target}`); + } + seenClaudeAgentTargets.add(entry.target); + } + const sdkPackageName = expectString( + claudeAgent.sdkPackageName, + 'claudeAgent.sdkPackageName', + ); + if (!/^@[^/]+\/[^/]+$/u.test(sdkPackageName)) { + fail(`claudeAgent.sdkPackageName 必须是带 scope 的包名:${sdkPackageName}`); + } + for (const entry of claudeAgentTargets) { + // 原生运行时包与 SDK 同处一个 scope 目录下,包名必须是裸名。 + if (entry.runtimePackage.includes('/')) { + fail( + `claudeAgent.targets 的 runtimePackage 必须是裸包名:${entry.runtimePackage}`, + ); + } + } + + return { + layoutVersion, + codex: { + version: expectString(codex.version, 'codex.version'), + cliVersionPrefix: expectString( + codex.cliVersionPrefix, + 'codex.cliVersionPrefix', + ), + manifestSchema: expectString( + codex.manifestSchema, + 'codex.manifestSchema', + ), + packageMetadata: parsePackageMetadata(codex.packageMetadata), + 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, + }, + claudeAgent: { + version: expectString(claudeAgent.version, 'claudeAgent.version'), + resourceDirectory: assertRelativePath( + expectString( + claudeAgent.resourceDirectory, + 'claudeAgent.resourceDirectory', + ), + 'claudeAgent.resourceDirectory', + ), + entryRelativePath: assertRelativePath( + expectString( + claudeAgent.entryRelativePath, + 'claudeAgent.entryRelativePath', + ), + 'claudeAgent.entryRelativePath', + ), + entryFileName: expectString( + claudeAgent.entryFileName, + 'claudeAgent.entryFileName', + ), + nodeModulesDirectory: expectString( + claudeAgent.nodeModulesDirectory, + 'claudeAgent.nodeModulesDirectory', + ), + sdkPackageName, + sdkEntryFileName: expectString( + claudeAgent.sdkEntryFileName, + 'claudeAgent.sdkEntryFileName', + ), + sdkPackageMetadataFileName: expectString( + claudeAgent.sdkPackageMetadataFileName, + 'claudeAgent.sdkPackageMetadataFileName', + ), + sourceRoots: expectStringArray( + claudeAgent.sourceRoots, + 'claudeAgent.sourceRoots', + ), + skipDirectoryNames: expectStringArray( + claudeAgent.skipDirectoryNames, + 'claudeAgent.skipDirectoryNames', + ), + skipDirectoryNamePrefixes: expectStringArray( + claudeAgent.skipDirectoryNamePrefixes, + 'claudeAgent.skipDirectoryNamePrefixes', + ), + skipFileNamePrefixes: expectStringArray( + claudeAgent.skipFileNamePrefixes, + 'claudeAgent.skipFileNamePrefixes', + ), + skipFileNameFragments: expectStringArray( + claudeAgent.skipFileNameFragments, + 'claudeAgent.skipFileNameFragments', + ), + targets: claudeAgentTargets, + }, + 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)], + ['plugin', rustString(entry.plugin ?? '')], + ['origin', rustString(entry.origin)], + ['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)], + ['files', rustStrings(entry.files)], + ], + 1, + ); +} + +function renderClaudeAgentTarget(entry) { + return renderStruct( + 'ClaudeAgentTarget', + [ + ['target', rustString(entry.target)], + ['runtime_package', rustString(entry.runtimePackage)], + ['runtime_file_name', rustString(entry.runtimeFileName)], + ], + 1, + ); +} + +function renderGenerated(declaration) { + const codex = declaration.codex; + const godotStaging = declaration.plugins.libraryStaging.find( + (entry) => entry.layout === 'godot-bundle', + ); + if (!godotStaging || godotStaging.files.length === 0) { + fail('声明缺少 godot-bundle 随包文件清单,拒绝生成空清单'); + } + const godotFiles = godotStaging.files; + const plugins = declaration.plugins; + const codexStruct = renderStruct('Codex', [ + [ + 'package_metadata', + renderStruct('PackageMetadata', [ + ['layout_version', String(codex.packageMetadata.layoutVersion)], + ['resources_dir', rustString(codex.packageMetadata.resourcesDir)], + ['path_dir', rustString(codex.packageMetadata.pathDir)], + ]), + ], + ['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 claudeAgent = declaration.claudeAgent; + const claudeAgentStruct = renderStruct('ClaudeAgent', [ + ['version', rustString(claudeAgent.version)], + ['resource_directory', rustString(claudeAgent.resourceDirectory)], + ['entry_relative_path', rustString(claudeAgent.entryRelativePath)], + ['entry_file_name', rustString(claudeAgent.entryFileName)], + ['node_modules_directory', rustString(claudeAgent.nodeModulesDirectory)], + ['sdk_package_name', rustString(claudeAgent.sdkPackageName)], + ['sdk_entry_file_name', rustString(claudeAgent.sdkEntryFileName)], + [ + 'sdk_package_metadata_file_name', + rustString(claudeAgent.sdkPackageMetadataFileName), + ], + [ + 'targets', + `&[\n${claudeAgent.targets.map(renderClaudeAgentTarget).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:bundled-resources: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 CLAUDE_AGENT_SDK_VERSION: &str = ${rustString(declaration.claudeAgent.version)}; + +pub const GODOT_BUNDLE_FILES: &[&str] = ${rustStrings(godotFiles)}; + +pub const CODEX: Codex = ${codexStruct}; + +pub const CLAUDE_AGENT: ClaudeAgent = ${claudeAgentStruct}; + +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(/^[\^~]/, ''); +} + +/// Claude Agent SDK 有两个锁点:客户端依赖与 sidecar 自己的依赖。两处都必须与声明一致, +/// 否则随包的 SDK 与运行它的 sidecar 会悄悄分叉。 +function lockedClaudeAgentVersions() { + const manifests = [ + [ + 'apps/ai-game-creator-shell/package.json', + path.join(APP_ROOT, 'package.json'), + ], + [ + 'apps/ai-game-creator-shell/agent-sidecar/package.json', + path.join(APP_ROOT, 'agent-sidecar', 'package.json'), + ], + ]; + return manifests.map(([label, file]) => { + const manifest = expectObject( + JSON.parse(readFileSync(file, 'utf8')), + label, + ); + const declared = manifest.dependencies?.['@anthropic-ai/claude-agent-sdk']; + if (typeof declared !== 'string' || declared.length === 0) { + fail( + `${label} 未声明 @anthropic-ai/claude-agent-sdk,无法校验声明的 claudeAgent.version`, + ); + } + 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() { + const root = expectObject( + JSON.parse(readFileSync(DECLARATION_PATH, 'utf8')), + 'root', + ); + const plugins = expectObject(root.plugins, 'plugins'); + const steps = expectArray(plugins.prepareSteps ?? [], 'plugins.prepareSteps'); + const names = new Set(); + for (const [index, raw] of steps.entries()) { + const at = `plugins.prepareSteps[${index}]`; + const step = expectObject(raw, at); + const name = expectString(step.name, `${at}.name`); + if (names.has(name)) { + fail(`${at}.name 重复:${name}`); + } + names.add(name); + const kind = expectString(step.kind, `${at}.kind`); + if (kind !== 'powershell' && kind !== 'cargo') { + fail(`${at}.kind 只能是 powershell 或 cargo`); + } + if (kind === 'powershell') { + expectString(step.workingDirectory, `${at}.workingDirectory`); + expectString(step.scriptFileName, `${at}.scriptFileName`); + } else { + expectString(step.packageDirectory, `${at}.packageDirectory`); + } + expectStringArray(step.requiredOutputs ?? [], `${at}.requiredOutputs`); + } + const payloads = expectArray( + plugins.nativePayloads ?? [], + 'plugins.nativePayloads', + ); + for (const [index, raw] of payloads.entries()) { + const at = `plugins.nativePayloads[${index}]`; + const payload = expectObject(raw, at); + expectString(payload.plugin, `${at}.plugin`); + expectString(payload.prepare, `${at}.prepare`); + expectString(payload.sourceFileName, `${at}.sourceFileName`); + expectString(payload.destinationFileName, `${at}.destinationFileName`); + expectString( + payload.destinationSubdirectory, + `${at}.destinationSubdirectory`, + ); + if (!names.has(payload.prepare)) { + fail(`${at}.prepare 引用了未声明的准备步骤:${payload.prepare}`); + } + } + const preparedByPlugin = new Map(); + for (const entry of expectArray( + plugins.subdirectories ?? [], + 'plugins.subdirectories', + )) { + const subdirectory = expectObject(entry, 'subdirectory'); + if (subdirectory.origin !== 'prepared') { + continue; + } + const owner = expectString( + subdirectory.plugin ?? '', + 'subdirectory.plugin', + ); + if (!owner) { + fail(`origin=prepared 的子目录必须声明 plugin:${subdirectory.path}`); + } + preparedByPlugin.set(owner, [ + ...(preparedByPlugin.get(owner) ?? []), + subdirectory.path, + ]); + } + for (const payload of payloads) { + const candidates = preparedByPlugin.get(payload.plugin) ?? []; + if (!candidates.includes(payload.destinationSubdirectory)) { + fail( + `nativePayloads 的 destinationSubdirectory(${payload.destinationSubdirectory})必须是该插件 origin=prepared 的子目录之一(现有:${candidates.join('、') || '无'})`, + ); + } + } + + 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( + (entry) => expectObject(entry, 'subdirectory').origin === 'prepared', + ) + .map((entry) => [entry.path, entry.prepare]), + ...expectArray(plugins.libraryStaging ?? [], 'plugins.libraryStaging').map( + (entry) => [entry.sourceSubdirectory, entry.prepare], + ), + ...payloads.map((entry) => [ + `${entry.plugin}/${entry.destinationSubdirectory}`, + entry.prepare, + ]), + ]; + for (const [label, prepare] of referenced) { + if (!prepare) { + fail(`prepared 来源的条目缺少 prepare 声明:${label}`); + } + if (!names.has(prepare)) { + fail(`${label} 引用了未声明的准备步骤:${prepare}`); + } + } +} + +function main() { + const declaration = parseDeclaration(readFileSync(DECLARATION_PATH, 'utf8')); + validateExtendedDeclarations(); + const lockedVersion = appLockedCodexVersion(); + if (lockedVersion !== declaration.codex.version) { + fail( + `声明 codex.version(${declaration.codex.version})与应用锁定的 @openai/codex(${lockedVersion})不一致;请同步更新 build_support/package-layout.json`, + ); + } + for (const lockedClaudeVersion of lockedClaudeAgentVersions()) { + if (lockedClaudeVersion !== declaration.claudeAgent.version) { + fail( + `声明 claudeAgent.version(${declaration.claudeAgent.version})与锁定的 @anthropic-ai/claude-agent-sdk(${lockedClaudeVersion})不一致;请同步更新 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:bundled-resources: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/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs new file mode 100755 index 000000000..1b25f2585 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -0,0 +1,1773 @@ +#!/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 { spawnSync } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import { + chmodSync, + closeSync, + copyFileSync, + existsSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + readSync, + 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); +} + +/// 声明覆盖的宿主目标;不受声明覆盖的平台返回 null(入口据此跳过资源准备)。 +export function supportedHostTarget( + platform = process.platform, + arch = process.arch, +) { + return HOST_TRIPLES.get(`${platform}:${arch}`) ?? null; +} + +export function resolveHostTarget( + platform = process.platform, + arch = process.arch, +) { + const triple = supportedHostTarget(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) { + const hash = createHash('sha256'); + const buffer = Buffer.allocUnsafe(64 * 1024); + const descriptor = openSync(file, 'r'); + try { + for (;;) { + const read = readSync(descriptor, buffer, 0, buffer.length, null); + if (read === 0) { + break; + } + hash.update(buffer.subarray(0, read)); + } + } finally { + closeSync(descriptor); + } + return hash.digest('hex'); +} + +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; +} + +/// 上游原生包元数据必须与声明一致:版本、目标、入口、资源目录与 path 目录。 +export function validateUpstreamMetadata(declaration, target, source) { + const metadataFile = path.join( + source, + declaration.codex.packageMetadataFileName, + ); + const metadata = JSON.parse(readFileSync(metadataFile, 'utf8')); + const layout = codexLayout(declaration, target); + const expected = declaration.codex.packageMetadata; + const mismatch = + metadata.layoutVersion !== expected.layoutVersion || + metadata.version !== declaration.codex.version || + metadata.target !== target || + metadata.entrypoint !== layout.executable || + metadata.resourcesDir !== expected.resourcesDir || + metadata.pathDir !== expected.pathDir; + if (mismatch) { + fail( + `内置 Codex CLI 上游包元数据与声明不一致(${metadataFile}):期望 layoutVersion=${expected.layoutVersion} version=${declaration.codex.version} target=${target} entrypoint=${layout.executable};请确认上游包版本后再同步 build_support/package-layout.json`, + ); + } +} + +/// 缓存 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};请人工确认后删除该目录再重试`, + ); + } + } +} + +/// 期望产物:每个目标的组件摘要与清单文本。 +function desiredCodex(declaration, unit, roots) { + const desired = new Map(); + for (const member of unit.targets) { + const source = findCodexSource(declaration, member.target, roots); + validateUpstreamMetadata(declaration, member.target, source); + const hashes = new Map(); + for (const relative of member.layout.files) { + hashes.set(relative, 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; +} + +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 || sha256File(filePath) !== file.sha256) { + return false; + } + if ( + process.platform !== 'win32' && + 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 }; +} + +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 suffix = `${process.pid}-${randomBytes(4).toString('hex')}`; + const stagingPath = `${unitPath}-staging-${suffix}`; + const backupPath = `${unitPath}-backup-${suffix}`; + rmSync(stagingPath, { recursive: true, force: true }); + rmSync(backupPath, { recursive: true, force: true }); + mkdirSync(stagingPath, { recursive: true }); + try { + builder(stagingPath); + } catch (error) { + rmSync(stagingPath, { recursive: true, force: true }); + throw error; + } + + const hadPrevious = existsSync(unitPath); + try { + if (hadPrevious) { + renameSync(unitPath, backupPath); + } + renameSync(stagingPath, unitPath); + } catch (error) { + let restored = !hadPrevious; + if (hadPrevious && existsSync(backupPath)) { + try { + if (existsSync(unitPath)) { + rmSync(unitPath, { recursive: true, force: true }); + } + renameSync(backupPath, unitPath); + restored = true; + } catch { + restored = false; + } + } + fail( + `替换 ${unitPath} 失败(${error.code ?? error.message}):staging 保留在 ${stagingPath};旧资源${restored ? '已恢复' : '恢复失败,请检查 ' + backupPath},确认没有并发进程后重试`, + ); + } + rmSync(backupPath, { recursive: true, force: true }); +} + +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 && + unitRecordMatches(unitPath, declaration, unit, recorded) + ) { + return { summary: `${label} 命中缓存(未写入)`, record: undefined }; + } + + const desired = desiredCodex(declaration, unit, roots); + const entry = { + key, + ...recordEntryFor(unitPath, declaration, unit, desired), + }; + if (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, + }; +} + +/// Claude Agent SDK sidecar 的声明目标;未声明的目标不随包 sidecar(Linux 等平台直接跳过)。 +function claudeAgentLayout(declaration, target) { + return ( + declaration.claudeAgent.targets.find((entry) => entry.target === target) ?? + null + ); +} + +/// 上游包目录:按声明顺序在 app / 仓库根的 node_modules 下取第一个命中的候选。 +function requireClaudeAgentPackage(declaration, roots, packageName, label) { + const candidates = declaration.claudeAgent.sourceRoots.map((rootName) => { + const root = roots[rootName]; + if (!root) { + fail(`未知的声明 sourceRoots 取值:${rootName}`); + } + return path.join( + root, + declaration.claudeAgent.nodeModulesDirectory, + packageName, + ); + }); + const found = candidates.find((candidate) => existsSync(candidate)); + if (!found) { + fail( + `${label}缺失;请先在仓库根目录执行 npm ci(已检查:${candidates.join(';')})`, + ); + } + return found; +} + +/// 上游包版本必须与声明一致:静默发旧 SDK 的代价是 sidecar 与客户端说不上话。 +function validateClaudeAgentPackageVersion(packageDirectory, declared, label) { + const metadataFile = path.join( + packageDirectory, + declared.sdkPackageMetadataFileName, + ); + let metadata; + try { + metadata = JSON.parse(readFileSync(metadataFile, 'utf8')); + } catch (error) { + fail( + `${label} 元数据不可读或不是合法 JSON:${metadataFile}(${error.message})`, + ); + } + if (metadata.version !== declared.version) { + fail( + `${label} 版本与声明不一致:${String(metadata.version)}(期望 ${declared.version},${metadataFile});请同步更新 build_support/package-layout.json`, + ); + } +} + +/// 期望产物:仓库里的 sidecar 入口 + 上游 SDK 包 + 平台原生运行时包(随包相对路径 → 来源文件)。 +function claudeAgentDesiredFiles(declaration, layout, roots) { + const declared = declaration.claudeAgent; + const scope = declared.sdkPackageName.split('/')[0]; + const entrySource = path.join(roots.app, declared.entryRelativePath); + if (!existsSync(entrySource)) { + fail(`Claude Agent SDK sidecar 入口缺失:${entrySource}`); + } + const sdkSource = requireClaudeAgentPackage( + declaration, + roots, + declared.sdkPackageName, + 'Claude Agent SDK 上游包', + ); + if (!existsSync(path.join(sdkSource, declared.sdkEntryFileName))) { + fail( + `Claude Agent SDK 上游包缺少入口 ${declared.sdkEntryFileName}:${sdkSource}`, + ); + } + const runtimePackage = `${scope}/${layout.runtimePackage}`; + const runtimeSource = requireClaudeAgentPackage( + declaration, + roots, + runtimePackage, + `Claude Agent SDK 原生运行时(${layout.runtimePackage})`, + ); + if (!existsSync(path.join(runtimeSource, layout.runtimeFileName))) { + fail( + `Claude Agent SDK 原生运行时缺少可执行文件:${path.join(runtimeSource, layout.runtimeFileName)}`, + ); + } + validateClaudeAgentPackageVersion(sdkSource, declared, 'Claude Agent SDK'); + validateClaudeAgentPackageVersion( + runtimeSource, + declared, + 'Claude Agent SDK 原生运行时', + ); + + const files = new Map([ + [ + declared.entryFileName, + { + source: entrySource, + executable: false, + }, + ], + ]); + for (const relative of walkFiles( + declared, + sdkSource, + 'Claude Agent SDK 上游包', + )) { + files.set( + `${declared.nodeModulesDirectory}/${declared.sdkPackageName}/${relative}`, + { + source: path.join(sdkSource, relative), + executable: false, + }, + ); + } + for (const relative of walkFiles( + declared, + runtimeSource, + 'Claude Agent SDK 原生运行时', + )) { + files.set( + `${declared.nodeModulesDirectory}/${runtimePackage}/${relative}`, + { + source: path.join(runtimeSource, relative), + executable: relative === layout.runtimeFileName, + }, + ); + } + return files; +} + +/// 已落盘目录里的全部普通文件(相对路径);不套用复制规则,越界文件必须能被看见。 +function collectClaudeAgentFiles(root) { + const files = []; + const stack = [['', root]]; + while (stack.length > 0) { + const [prefix, directory] = stack.pop(); + 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(`Claude Agent SDK 随包资源不允许符号链接:${entryPath}`); + } + if (entry.isDirectory()) { + stack.push([relative, entryPath]); + } else if (entry.isFile()) { + files.push(relative); + } + } + } + return files.sort(); +} + +/// 随包目录与期望产物是否一致:路径集合、尺寸、可执行位,必要时再逐文件比对内容。 +function claudeAgentTreeMatches(unitPath, files, { compareContent }) { + if (!existsSync(unitPath)) { + return false; + } + const expected = [...files.keys()].sort(); + const actual = collectClaudeAgentFiles(unitPath); + if ( + actual.length !== expected.length || + actual.some((relative, index) => relative !== expected[index]) + ) { + return false; + } + for (const [relative, entry] of files) { + const staged = path.join(unitPath, ...relative.split('/')); + const sourceInfo = statSync(entry.source); + const stagedInfo = statSync(staged); + if (sourceInfo.size !== stagedInfo.size) { + return false; + } + // Windows 的 mode 位不表达可执行语义;其它平台要求声明为可执行的文件确实带可执行位。 + if ( + process.platform !== 'win32' && + entry.executable && + (stagedInfo.mode & 0o111) === 0 + ) { + return false; + } + if (compareContent && !sameFileContent(entry.source, staged)) { + return false; + } + } + return true; +} + +/// 只允许替换由本工具创建的目录:出现白名单外的顶层条目即失败关闭。 +function assertOwnedClaudeAgentRoot(unitPath, declared) { + if (!existsSync(unitPath)) { + return; + } + const allowed = new Set([ + declared.entryFileName, + declared.nodeModulesDirectory, + ]); + for (const entry of readdirSync(unitPath)) { + if (!allowed.has(entry)) { + fail( + `Claude Agent SDK sidecar 随包目录被非本工具内容占用:${path.join(unitPath, entry)};请人工确认后删除该目录再重试`, + ); + } + } +} + +/// Claude Agent SDK sidecar:入口来自仓库源码、SDK 与原生运行时来自上游 npm 包。 +/// 整目录原子替换;命中缓存时不写任何文件。缓存 key = 声明版本 + 目标 + 入口摘要, +/// 上游包按 npm 版本锁定,因此快速路径只比路径集合、尺寸与可执行位(与 codex 的缓存口径一致)。 +function prepareClaudeAgent({ + declaration, + target, + destinationRoot, + roots, + record, + dryRun, +}) { + const layout = claudeAgentLayout(declaration, target); + if (!layout) { + return { + summary: `claude-agent 跳过(目标 ${target} 不适用)`, + record: undefined, + }; + } + const declared = declaration.claudeAgent; + const label = `claude-agent ${target}`; + const unitPath = path.join(destinationRoot, declared.resourceDirectory); + const files = claudeAgentDesiredFiles(declaration, layout, roots); + const key = [ + `layoutVersion=${declaration.layoutVersion}`, + `target=${target}`, + `version=${declared.version}`, + `entry=${sha256File(path.join(roots.app, declared.entryRelativePath))}`, + ].join('|'); + assertOwnedClaudeAgentRoot(unitPath, declared); + if ( + record.claudeAgent?.[target]?.key === key && + claudeAgentTreeMatches(unitPath, files, { compareContent: false }) + ) { + return { summary: `${label} 命中缓存(未写入)`, record: undefined }; + } + if (claudeAgentTreeMatches(unitPath, files, { compareContent: true })) { + return { + summary: `${label} 命中缓存(内容一致,未写入)`, + record: { key }, + }; + } + if (dryRun) { + return { + summary: `${label} 需要重新生成(dry-run 未写入)`, + record: undefined, + }; + } + stageAtomically(unitPath, (staging) => { + for (const [relative, entry] of files) { + const destination = path.join(staging, ...relative.split('/')); + copyFilePreservingMode(entry.source, destination); + // 原生运行时必须可执行:上游 tarball 的权限位偶有丢失,这里按声明兜底补一次。 + if (entry.executable && process.platform !== 'win32') { + chmodSync(destination, 0o755); + } + } + }); + return { + summary: `${label} 重新生成(${files.size} 个文件,写入 ${declared.resourceDirectory})`, + record: { key }, + }; +} + +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 subdirectoryAppliesToPlugin(subdirectory, pluginName) { + return !subdirectory.plugin || subdirectory.plugin === pluginName; +} + +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 collectTreeFiles(root, label) { + 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(`${label}不允许符号链接:${entryPath}`); + } + if (entry.isDirectory()) { + stack.push([relative, entryPath]); + } else if (entry.isFile()) { + files.push(relative); + } + } + } + return files.sort(); +} + +/// 复制规则由各 section 自带(plugins / claudeAgent),同名语义、同序判定。 +function skipDirectory(rules, name) { + return ( + rules.skipDirectoryNames.includes(name) || + rules.skipDirectoryNamePrefixes.some((prefix) => name.startsWith(prefix)) + ); +} + +function skipFileName(rules, name) { + return ( + rules.skipFileNamePrefixes.some((prefix) => name.startsWith(prefix)) || + rules.skipFileNameFragments.some((fragment) => name.includes(fragment)) + ); +} + +function walkFiles(rules, root, label) { + 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(`${label}不允许符号链接:${entryPath}`); + } + if (entry.isDirectory()) { + if (!skipDirectory(rules, entry.name)) { + stack.push([relative, entryPath]); + } + } else if (entry.isFile() && !skipFileName(rules, 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.plugins, entry.name)) { + copyPluginSubdirectory(declaration, entrySource, entryDestination); + } + } else if ( + entry.isFile() && + !skipFileName(declaration.plugins, 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 ( + !subdirectoryAppliesToPlugin(subdirectory, plugin.name) || + !subdirectoryEnabled(subdirectory, target, features) + ) { + continue; + } + const root = path.join(plugin.root, subdirectory.path); + for (const file of walkFiles(declaration.plugins, root, '插件资源')) { + const absolute = path.join(root, file); + const info = statSync(absolute); + // prepared 产物由准备步骤无条件复制,mtime 会变;用内容哈希保证幂等判断稳定。 + const stamp = + subdirectory.origin === 'prepared' + ? `sha256:${sha256File(absolute)}` + : `${info.size}:${Math.round(info.mtimeMs)}`; + lines.push(`${plugin.name}/${subdirectory.path}/${file}:${stamp}`); + } + } + 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)}`, + ); + for (const staging of declaration.plugins.libraryStaging ?? []) { + if ( + plugin.name !== staging.plugin || + !libraryStagingEnabled(staging, target, features) + ) { + continue; + } + for (const relative of staging.files) { + const file = path.join( + plugin.root, + staging.sourceSubdirectory, + relative, + ); + lines.push( + `${plugin.name}/${staging.sourceSubdirectory}/${relative}:${ + existsSync(file) ? `sha256:${sha256File(file)}` : 'missing' + }`, + ); + } + } + for (const payload of declaration.plugins.nativePayloads ?? []) { + if ( + plugin.name !== payload.plugin || + !nativePayloadEnabled(payload, target, features) + ) { + continue; + } + const delivered = path.join( + plugin.root, + payload.destinationSubdirectory, + payload.destinationFileName ?? payload.sourceFileName, + ); + lines.push( + `${plugin.name}/${payload.destinationSubdirectory}:${ + existsSync(delivered) ? `sha256:${sha256File(delivered)}` : 'missing' + }`, + ); + } + } + return sha256Text(lines.join('\n')); +} + +function pluginTreeMatches( + declaration, + plugins, + target, + features, + destination, +) { + if (!existsSync(destination)) { + return false; + } + const allowedFiles = new Set(); + for (const plugin of plugins) { + const pluginDestination = path.join(destination, plugin.name); + const pluginPrefix = `${plugin.name}/`; + allowedFiles.add(`${pluginPrefix}${declaration.plugins.manifestFileName}`); + if ( + !existsSync( + path.join(pluginDestination, declaration.plugins.manifestFileName), + ) + ) { + return false; + } + for (const subdirectory of declaration.plugins.subdirectories) { + if (!subdirectoryAppliesToPlugin(subdirectory, plugin.name)) { + continue; + } + const stagedDirectory = path.join(pluginDestination, subdirectory.path); + if (!subdirectoryEnabled(subdirectory, target, features)) { + if (existsSync(stagedDirectory)) { + return false; + } + continue; + } + const relativePrefix = `${plugin.name}/${subdirectory.path}/`; + const sourceRoot = path.join(plugin.root, subdirectory.path); + if (subdirectory.origin !== 'source') { + if (existsSync(sourceRoot) && !existsSync(stagedDirectory)) { + return false; + } + for (const relative of walkFiles( + declaration.plugins, + sourceRoot, + '插件资源', + )) { + const staged = path.join(stagedDirectory, relative); + if (!existsSync(staged)) { + return false; + } + allowedFiles.add(`${relativePrefix}${relative}`); + } + continue; + } + for (const relative of walkFiles( + declaration.plugins, + sourceRoot, + '插件资源', + )) { + const source = path.join(sourceRoot, relative); + const staged = path.join( + pluginDestination, + subdirectory.path, + relative, + ); + allowedFiles.add(`${relativePrefix}${relative}`); + if (!existsSync(staged)) { + return false; + } + if (statSync(source).size !== statSync(staged).size) { + return false; + } + if (sha256File(source) !== sha256File(staged)) { + return false; + } + } + } + for (const staging of declaration.plugins.libraryStaging ?? []) { + if ( + plugin.name === staging.plugin && + libraryStagingEnabled(staging, target, features) + ) { + for (const relative of staging.files) { + const staged = path.join( + pluginDestination, + staging.sourceSubdirectory, + relative, + ); + if (!existsSync(staged)) { + return false; + } + allowedFiles.add( + `${plugin.name}/${staging.sourceSubdirectory}/${relative}`, + ); + } + } + } + for (const payload of declaration.plugins.nativePayloads ?? []) { + if ( + plugin.name === payload.plugin && + nativePayloadEnabled(payload, target, features) + ) { + const relative = `${payload.destinationSubdirectory}/${payload.destinationFileName ?? payload.sourceFileName}`; + if (!existsSync(path.join(pluginDestination, relative))) { + return false; + } + allowedFiles.add(`${plugin.name}/${relative}`); + } + } + } + return collectTreeFiles(destination, '插件随包目录').every((relative) => + allowedFiles.has(relative), + ); +} + +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)};请人工确认后删除该目录再重试`, + ); + } + } +} + +/// 声明的准备步骤:需要外部工具链或同一次 cargo 构建才能产出的随包内容。 +function prepareStepsReferencedBy(declaration, target, features) { + const required = new Set(); + for (const subdirectory of declaration.plugins.subdirectories) { + if ( + subdirectory.origin === 'prepared' && + subdirectoryEnabled(subdirectory, target, features) + ) { + required.add(subdirectory.prepare); + } + } + for (const staging of declaration.plugins.libraryStaging ?? []) { + if (libraryStagingEnabled(staging, target, features)) { + required.add(staging.prepare); + } + } + for (const payload of declaration.plugins.nativePayloads ?? []) { + if (nativePayloadEnabled(payload, target, features)) { + required.add(payload.prepare); + } + } + return required; +} + +function libraryStagingEnabled(staging, target, features) { + return ( + (!staging.targets?.length || staging.targets.includes(target)) && + (staging.features ?? []).every((name) => features.has(name)) + ); +} + +function nativePayloadEnabled(payload, target, features) { + return ( + (!payload.targets?.length || payload.targets.includes(target)) && + (payload.features ?? []).every((name) => features.has(name)) + ); +} + +/// 指纹覆盖声明的根目录(跳过排除目录),与构建脚本原先的规则一致:按相对路径排序后逐文件哈希。 +function fingerprintSources(repoRoot, step) { + const { roots, excludeDirectoryNames } = step.fingerprint; + const lines = []; + for (const root of roots) { + const absoluteRoot = path.join(repoRoot, step.workingDirectory, root); + for (const relative of collectFingerprintFiles( + absoluteRoot, + excludeDirectoryNames, + )) { + const file = path.join(absoluteRoot, relative); + const info = statSync(file); + lines.push(`${relative}\u0000${info.size}\u0000${sha256File(file)}`); + } + } + return sha256Text(lines.join('\n')); +} + +function collectFingerprintFiles(root, excludeDirectoryNames, prefix = '') { + const files = []; + if (!existsSync(root)) { + return files; + } + for (const entry of readdirSync(root, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isSymbolicLink()) { + fail(`准备步骤源码不允许符号链接:${path.join(root, entry.name)}`); + } + if (entry.isDirectory()) { + if (!excludeDirectoryNames.includes(entry.name)) { + files.push( + ...collectFingerprintFiles( + path.join(root, entry.name), + excludeDirectoryNames, + relative, + ), + ); + } + } else if (entry.isFile()) { + files.push(relative); + } + } + return files.sort(); +} + +function requiredOutputsPresent(repoRoot, step) { + return step.requiredOutputs.every((relative) => { + const file = path.join(repoRoot, relative); + return ( + existsSync(file) && statSync(file).isFile() && statSync(file).size > 0 + ); + }); +} + +function defaultRunCommand({ program, args, cwd, removeEnvironment }) { + const environment = { ...process.env }; + for (const name of removeEnvironment ?? []) { + delete environment[name]; + } + const result = spawnSync(program, args, { + cwd, + env: environment, + stdio: 'inherit', + }); + if (result.error) { + throw new PrepareError(`执行 ${program} 失败:${result.error.message}`); + } + if (result.status !== 0) { + throw new PrepareError(`${program} 退出码 ${result.status}`); + } +} + +/// 按声明产出「已准备」随包内容;命中指纹且必需产物齐全时零写入。 +export function ensurePreparedArtifacts({ + declaration, + repoRoot, + srcTauriRoot, + target, + features, + profile = 'debug', + runCommand = defaultRunCommand, + log = () => {}, + dryRun = false, +}) { + const required = prepareStepsReferencedBy(declaration, target, features); + const steps = new Map( + (declaration.plugins.prepareSteps ?? []).map((step) => [step.name, step]), + ); + for (const name of required) { + const step = steps.get(name); + if (!step) { + fail(`声明引用了未定义的准备步骤:${name}`); + } + const stampPath = step.fingerprint + ? path.join( + repoRoot, + step.workingDirectory, + step.fingerprint.stampRelativePath, + ) + : undefined; + if (step.fingerprint && step.requiredOutputs.length > 0) { + const expected = fingerprintSources(repoRoot, step); + const current = existsSync(stampPath) + ? readFileSync(stampPath, 'utf8').trim() + : ''; + if (current === expected && requiredOutputsPresent(repoRoot, step)) { + log(`${name} 命中缓存(指纹一致)`); + continue; + } + } + if (dryRun) { + log(`${name} 需要重新构建(dry-run 未执行)`); + continue; + } + if (step.kind === 'powershell') { + runCommand({ + program: 'powershell.exe', + args: [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-File', + path.join(repoRoot, step.workingDirectory, step.scriptFileName), + ], + cwd: path.join(repoRoot, step.workingDirectory), + removeEnvironment: step.removeEnvironment ?? [], + }); + } else if (step.kind === 'cargo') { + runCommand({ + program: 'cargo', + args: [ + 'build', + '--manifest-path', + path.join(repoRoot, step.packageDirectory, 'Cargo.toml'), + '--target-dir', + path.join(srcTauriRoot, 'target'), + '--target', + target, + ...(profile === 'release' ? ['--release'] : []), + ...(step.features?.length + ? ['--features', step.features.join(',')] + : []), + ], + cwd: repoRoot, + removeEnvironment: [], + }); + } else { + fail(`未实现的准备步骤类型:${step.kind}`); + } + if (!requiredOutputsPresent(repoRoot, step)) { + const missing = step.requiredOutputs.filter( + (relative) => !existsSync(path.join(repoRoot, relative)), + ); + fail(`${name} 未产出必需文件:${missing.join('、') || '(未知)'}`); + } + if (stampPath && step.fingerprint) { + mkdirSync(path.dirname(stampPath), { recursive: true }); + writeFileSync(stampPath, `${fingerprintSources(repoRoot, step)}\n`); + } + log(`${name} 已构建`); + } +} + +/// 复制声明为已准备的随包子目录与随包库。 +function copyPreparedPayloads({ + declaration, + repoRoot, + staging, + target, + features, +}) { + for (const plugin of pluginDirectories(declaration, repoRoot)) { + for (const subdirectory of declaration.plugins.subdirectories) { + if ( + subdirectory.origin !== 'prepared' || + !subdirectoryEnabled(subdirectory, target, features) + ) { + continue; + } + copyPluginSubdirectory( + declaration, + path.join(plugin.root, subdirectory.path), + path.join(staging, plugin.name, subdirectory.path), + ); + } + for (const stagingEntry of declaration.plugins.libraryStaging ?? []) { + if ( + plugin.name !== stagingEntry.plugin || + !libraryStagingEnabled(stagingEntry, target, features) + ) { + continue; + } + for (const relative of stagingEntry.files) { + copyFilePreservingMode( + path.join(plugin.root, stagingEntry.sourceSubdirectory, relative), + path.join( + staging, + plugin.name, + stagingEntry.sourceSubdirectory, + relative, + ), + ); + } + } + } +} + +/// 逐字节相等且尺寸一致;目标不存在即视为不同。 +function sameFileContent(source, destination) { + if (!existsSync(destination)) { + return false; + } + const sourceInfo = statSync(source); + const destinationInfo = statSync(destination); + return ( + sourceInfo.size === destinationInfo.size && + Buffer.compare(readFileSync(source), readFileSync(destination)) === 0 + ); +} + +/// 内容一致时不重写(保住时间戳与「命中缓存零写入」口径)。 +function copyFilePreservingModeIfChanged(source, destination) { + if (sameFileContent(source, destination)) { + return; + } + copyFilePreservingMode(source, destination); +} + +/// Cocos bridge 的 dll 既要进插件工作区(唯一真源),也要进随包目录。 +function copyNativePayloads({ + declaration, + repoRoot, + srcTauriRoot, + staging, + target, + features, + profile, +}) { + for (const payload of declaration.plugins.nativePayloads ?? []) { + if (!nativePayloadEnabled(payload, target, features)) { + continue; + } + const candidates = [ + path.join( + srcTauriRoot, + 'target', + target, + profile, + 'deps', + payload.sourceFileName, + ), + path.join( + srcTauriRoot, + 'target', + target, + profile, + payload.sourceFileName, + ), + path.join( + srcTauriRoot, + 'target', + profile, + 'deps', + payload.sourceFileName, + ), + path.join(srcTauriRoot, 'target', profile, payload.sourceFileName), + ]; + const source = candidates.find((candidate) => existsSync(candidate)); + if (!source) { + fail( + `Cocos bridge native payload 未构建:${candidates.map((candidate) => path.relative(repoRoot, candidate)).join(';')}`, + ); + } + const destinationName = + payload.destinationFileName ?? payload.sourceFileName; + copyFilePreservingModeIfChanged( + source, + path.join( + repoRoot, + 'plugins', + payload.plugin, + payload.destinationSubdirectory, + destinationName, + ), + ); + copyFilePreservingModeIfChanged( + source, + path.join( + staging, + payload.plugin, + payload.destinationSubdirectory, + destinationName, + ), + ); + } +} + +function preparePlugins({ + declaration, + target, + destinationRoot, + repoRoot, + features, + plugins, + record, + profile, + dryRun, +}) { + const destination = path.join( + destinationRoot, + declaration.plugins.destinationDirectory, + ); + const fingerprint = pluginSourceFingerprint( + declaration, + plugins, + target, + features, + ); + const upToDate = + record.plugins?.fingerprint === fingerprint && + pluginTreeMatches(declaration, plugins, target, features, destination); + // dry-run 必须零写入:只做只读判断就返回。 + if (dryRun) { + return { + summary: upToDate + ? `plugins 命中缓存(未写入,${plugins.length} 个插件)` + : `plugins 需要重新生成(dry-run 未写入,${plugins.length} 个插件)`, + record: undefined, + }; + } + // 所有权断言先于任何写入(含下面的预缓存交付)。 + assertOwnedPluginRoot(destination, plugins); + // payload 交付不能排在缓存短路之后:否则「先默认构建、之后开 injection」会把交付整条跳过。 + if (existsSync(destination)) { + copyNativePayloads({ + declaration, + repoRoot, + srcTauriRoot: destinationRoot, + staging: destination, + target, + features, + profile, + }); + } + if (upToDate) { + return { + summary: `plugins 命中缓存(未写入,${plugins.length} 个插件)`, + record: undefined, + }; + } + 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 (subdirectory.origin !== 'source') { + continue; + } + if (!subdirectoryEnabled(subdirectory, target, features)) { + continue; + } + copyPluginSubdirectory( + declaration, + path.join(plugin.root, subdirectory.path), + path.join(pluginDestination, subdirectory.path), + ); + } + } + copyPreparedPayloads({ + declaration, + repoRoot, + staging, + target, + features, + }); + copyNativePayloads({ + declaration, + repoRoot, + srcTauriRoot: destinationRoot, + staging, + target, + features, + profile, + }); + }); + return { + summary: `plugins 重新生成(${plugins.length} 个插件,写入 ${declaration.plugins.destinationDirectory})`, + record: { + fingerprint: pluginSourceFingerprint( + declaration, + plugins, + target, + features, + ), + }, + }; +} +/// 准备全部随包资源;返回逐条汇总,供入口日志与测试断言。 +export 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'), + profile = 'debug', + runCommand = defaultRunCommand, + log = () => {}, + dryRun = false, + repoRoot = REPO_ROOT, + appRoot = path.dirname(destinationRoot), +} = {}) { + const declaration = readDeclaration(declarationPath); + const record = readRecord(recordPath); + const summaries = []; + let changed = false; + const codex = 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), + ) + ) { + ensurePreparedArtifacts({ + declaration, + repoRoot, + srcTauriRoot: destinationRoot, + target, + features, + profile, + runCommand, + dryRun, + log, + }); + const plugins = preparePlugins({ + declaration, + target, + destinationRoot, + repoRoot, + features, + plugins: pluginDirectories(declaration, repoRoot), + record, + profile, + dryRun, + }); + summaries.push(plugins.summary); + if (plugins.record) { + record.plugins = plugins.record; + changed = true; + } + } else { + summaries.push(`plugins 跳过(目标 ${target} 不适用)`); + } + const claudeAgent = prepareClaudeAgent({ + declaration, + target, + destinationRoot, + roots: { app: appRoot, repo: repoRoot }, + record, + dryRun, + }); + summaries.push(claudeAgent.summary); + if (claudeAgent.record) { + record.claudeAgent = { + ...record.claudeAgent, + [target]: claudeAgent.record, + }; + changed = true; + } + if (changed && !dryRun) { + writeRecord(record, recordPath); + } + 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 = requireFlagValue(argv, index, '--target'); + index += 1; + } else if (value === '--destination') { + args.destinationRoot = requireFlagValue(argv, index, '--destination'); + index += 1; + } else if (value === '--dry-run') { + args.dryRun = true; + } else if (value.startsWith('--features=')) { + args.features = new Set( + value.slice('--features='.length).split(',').filter(Boolean), + ); + } else if (value === '--features') { + args.features = new Set( + String(argv[index + 1] ?? '') + .split(',') + .filter(Boolean), + ); + index += 1; + } else { + fail(`未知参数:${value}`); + } + } + return args; +} + +function main(argv) { + const args = parseArguments(argv); + const summaries = prepareBundledResources({ + target: args.target ?? resolveHostTarget(), + destinationRoot: args.destinationRoot ?? SRC_TAURI_DIR, + ...(args.features ? { features: args.features } : {}), + dryRun: args.dryRun, + log: (line) => console.log(`[agc-resources] ${line}`), + }); + for (const summary of summaries) { + console.log(`[agc-resources] ${summary}`); + } +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + try { + 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..c808ab43c --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs @@ -0,0 +1,1159 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; +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。 +/// 造出声明里所有「已准备」产物:真实构建要 Windows 工具链,用例只需要文件在位。 +function writePrepareArtifacts(root, declaration) { + const created = []; + for (const step of declaration.plugins.prepareSteps ?? []) { + for (const relative of step.requiredOutputs) { + const file = path.join(root, relative); + if (fs.existsSync(file)) { + continue; + } + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `prepared ${relative}\n`); + created.push(relative); + } + } + return created; +} + +/// 记录调用的假执行器:默认把声明的必需产物造出来。 +function fakeRunCommand({ created = [], failOn = null } = {}) { + return (invocation) => { + created.push(`${invocation.program} ${(invocation.args ?? []).join(' ')}`); + if (failOn && invocation.program === failOn) { + throw new Error('fake run failure'); + } + }; +} + +function buildFixture({ + targets = [WINDOWS_TARGET], + plugins = true, + claudeAgent = 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, + relative === declaration.codex.packageMetadataFileName + ? `${JSON.stringify( + { + layoutVersion: declaration.codex.packageMetadata.layoutVersion, + version: declaration.codex.version, + target, + entrypoint: layout.executable, + resourcesDir: declaration.codex.packageMetadata.resourcesDir, + pathDir: declaration.codex.packageMetadata.pathDir, + }, + null, + 2, + )}\n` + : `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'); + + 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( + path.join(cocosRoot, 'plugin.json'), + '{"name":"agc-cocos-editor"}\n', + ); + fs.writeFileSync( + path.join(cocosRoot, 'src/entry.mjs'), + 'export const cocos = 1;\n', + ); + } + + if (claudeAgent) { + const declared = declaration.claudeAgent; + const entry = path.join(appRoot, declared.entryRelativePath); + fs.mkdirSync(path.dirname(entry), { recursive: true }); + fs.writeFileSync(entry, 'console.log("sidecar");\n'); + const scope = declared.sdkPackageName.split('/')[0]; + const sdkRoot = path.join( + appRoot, + declared.nodeModulesDirectory, + declared.sdkPackageName, + ); + fs.mkdirSync(path.join(sdkRoot, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(sdkRoot, declared.sdkEntryFileName), + 'export const sdk = 1;\n', + ); + fs.writeFileSync( + path.join(sdkRoot, 'src/runner.mjs'), + 'export const run = 1;\n', + ); + // 上游包里不该随包的内容:跳过规则必须把它们挡在 staging 之外。 + fs.writeFileSync(path.join(sdkRoot, '.env'), 'secret\n'); + fs.writeFileSync(path.join(sdkRoot, 'src/runner.test.mjs'), 'test\n'); + fs.writeFileSync( + path.join(sdkRoot, declared.sdkPackageMetadataFileName), + `${JSON.stringify({ version: declared.version })}\n`, + ); + for (const target of targets) { + const layout = declared.targets.find((item) => item.target === target); + assert.ok(layout, `声明缺少目标 ${target} 的 Claude Agent SDK 布局`); + const runtimeRoot = path.join( + appRoot, + declared.nodeModulesDirectory, + scope, + layout.runtimePackage, + ); + fs.mkdirSync(runtimeRoot, { recursive: true }); + const runtimeFile = path.join(runtimeRoot, layout.runtimeFileName); + fs.writeFileSync(runtimeFile, `runtime ${layout.runtimePackage}\n`); + fs.chmodSync(runtimeFile, 0o755); + fs.writeFileSync( + path.join(runtimeRoot, declared.sdkPackageMetadataFileName), + `${JSON.stringify({ version: declared.version })}\n`, + ); + } + } + + writePrepareArtifacts(repoRoot, declaration); + const cocosDll = path.join( + destinationRoot, + 'target', + WINDOWS_TARGET, + 'debug', + 'deps', + 'cocos_editor_bridge.dll', + ); + fs.mkdirSync(path.dirname(cocosDll), { recursive: true }); + fs.writeFileSync(cocosDll, 'cocos bridge payload\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 = {}) { + const runner = overrides.runCommand ?? fakeRunCommand(); + return prepareBundledResources({ + target: WINDOWS_TARGET, + destinationRoot: fixture.destinationRoot, + declarationPath: DECLARATION_PATH, + recordPath: fixture.recordPath, + lockfilePath: fixture.lockfilePath, + repoRoot: fixture.repoRoot, + appRoot: fixture.appRoot, + runCommand: runner, + ...overrides, + }); +} + +/// 替换失败注入:让「staging → 目标目录」的那一次 rename 抛错。 +/// 走 node:fs 的 ESM 活绑定(syncBuiltinESMExports 同步),实现里不得为测试开后门; +/// body 是同步的,注入窗口内不会有别的调用跑进来。 +function withRenameFailure(predicate, body) { + const original = fs.renameSync; + let injected = false; + fs.renameSync = (from, to) => { + if ( + !injected && + predicate(path.resolve(String(from)), path.resolve(String(to))) + ) { + injected = true; + throw Object.assign(new Error('注入的替换失败'), { code: 'EPERM' }); + } + return original.call(fs, from, to); + }; + syncBuiltinESMExports(); + try { + return body(); + } finally { + fs.renameSync = original; + syncBuiltinESMExports(); + } +} + +/// 把「必须失败」的调用收敛成断言:返回错误对象,没抛错即用例失败。 +function expectThrow(body) { + try { + body(); + } catch (error) { + return error; + } + throw new Error('预期抛错但调用成功了'); +} + +test('stages declared codex components with manifest and preserved notice', () => { + const fixture = buildFixture(); + try { + const summaries = 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', () => { + const fixture = buildFixture(); + try { + prepare(fixture); + const unit = path.join( + fixture.destinationRoot, + 'resources/codex', + 'win-x64', + ); + const plugins = path.join(fixture.destinationRoot, 'resources/plugins'); + const claudeAgent = path.join( + fixture.destinationRoot, + fixture.declaration.claudeAgent.resourceDirectory, + ); + const before = { + codex: snapshot(unit), + plugins: snapshot(plugins), + claudeAgent: snapshot(claudeAgent), + }; + const summaries = prepare(fixture); + assert.match(summaries[0], /命中缓存/); + assert.match(summaries[1], /命中缓存/); + assert.match(summaries[2], /命中缓存/); + assert.deepEqual( + snapshot(unit), + before.codex, + 'codex 产物内容与时间戳必须不变', + ); + assert.deepEqual( + snapshot(plugins), + before.plugins, + '插件产物内容与时间戳必须不变', + ); + assert.deepEqual( + snapshot(claudeAgent), + before.claudeAgent, + 'Claude Agent SDK sidecar 产物内容与时间戳必须不变', + ); + } finally { + fixture.cleanup(); + } +}); + +test('stages the macOS universal group with both architectures', () => { + const fixture = buildFixture({ + targets: [MAC_TARGET, 'x86_64-apple-darwin'], + }); + try { + const summaries = 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', () => { + const fixture = buildFixture(); + try { + 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 metadata drifts from the declaration', () => { + const fixture = buildFixture(); + try { + const declaration = fixture.declaration; + const layout = declaration.codex.targets.find( + (entry) => entry.target === WINDOWS_TARGET, + ); + const metadataFile = path.join( + fixture.appRoot, + `node_modules/@openai/codex-${layout.platform}/vendor/${WINDOWS_TARGET}/codex-package.json`, + ); + const metadata = JSON.parse(fs.readFileSync(metadataFile, 'utf8')); + assert.equal(metadata.version, declaration.codex.version); + for (const [key, value] of [ + ['version', '0.0.0'], + ['layoutVersion', 2], + ['entrypoint', 'bin/other.exe'], + ['resourcesDir', '../private'], + ]) { + fs.writeFileSync( + metadataFile, + `${JSON.stringify({ ...metadata, [key]: value }, null, 2)}\n`, + ); + assert.throws( + () => prepare(fixture), + /上游包元数据与声明不一致/, + `${key} 漂移必须被拒绝`, + ); + } + assert.ok( + !fs.existsSync( + path.join( + fixture.destinationRoot, + 'resources/codex/win-x64/manifest.json', + ), + ), + '拒绝时不得留下产物', + ); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed when the upstream package is missing', () => { + const fixture = buildFixture(); + try { + fs.rmSync(path.join(fixture.appRoot, 'node_modules'), { + recursive: true, + force: true, + }); + assert.throws(() => 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', () => { + const fixture = buildFixture(); + try { + assert.throws( + () => 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', () => { + const fixture = buildFixture(); + try { + const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64'); + fs.writeFileSync(path.join(unit, 'foreign.bin'), 'foreign\n'); + assert.throws(() => 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, + }); + assert.throws(() => prepare(fixture), /插件随包目录被非本工具内容占用/); + } finally { + fixture.cleanup(); + } +}); + +test('stages the Claude Agent SDK sidecar with the platform runtime', () => { + const fixture = buildFixture(); + try { + const summaries = prepare(fixture); + const declared = fixture.declaration.claudeAgent; + assert.match(summaries[2], /claude-agent x86_64-pc-windows-msvc 重新生成/); + const unit = path.join(fixture.destinationRoot, declared.resourceDirectory); + const layout = declared.targets.find( + (entry) => entry.target === WINDOWS_TARGET, + ); + const scope = declared.sdkPackageName.split('/')[0]; + const stagedSdk = path.join( + unit, + declared.nodeModulesDirectory, + declared.sdkPackageName, + ); + for (const file of [ + path.join(unit, declared.entryFileName), + path.join(stagedSdk, declared.sdkEntryFileName), + path.join(stagedSdk, 'src/runner.mjs'), + path.join( + unit, + declared.nodeModulesDirectory, + scope, + layout.runtimePackage, + layout.runtimeFileName, + ), + ]) { + assert.ok(fs.existsSync(file), `缺少 ${file}`); + } + // 跳过规则:上游包里的私密文件与测试文件不得随包。 + assert.ok(!fs.existsSync(path.join(stagedSdk, '.env'))); + assert.ok(!fs.existsSync(path.join(stagedSdk, 'src/runner.test.mjs'))); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed when the Claude Agent SDK upstream package is missing', () => { + const fixture = buildFixture(); + try { + fs.rmSync(path.join(fixture.appRoot, 'node_modules/@anthropic-ai'), { + recursive: true, + force: true, + }); + assert.throws(() => prepare(fixture), /Claude Agent SDK 上游包缺失/); + assert.ok( + !fs.existsSync( + path.join( + fixture.destinationRoot, + fixture.declaration.claudeAgent.resourceDirectory, + ), + ), + '失败关闭时不得留下半成品', + ); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed when the upstream Claude Agent SDK version drifts', () => { + const fixture = buildFixture(); + try { + const declared = fixture.declaration.claudeAgent; + const metadataFile = path.join( + fixture.appRoot, + declared.nodeModulesDirectory, + declared.sdkPackageName, + declared.sdkPackageMetadataFileName, + ); + fs.writeFileSync(metadataFile, `${JSON.stringify({ version: '0.0.0' })}\n`); + assert.throws(() => prepare(fixture), /版本与声明不一致/); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed when the Claude Agent SDK destination is owned by something else', () => { + const fixture = buildFixture(); + try { + const unit = path.join( + fixture.destinationRoot, + fixture.declaration.claudeAgent.resourceDirectory, + ); + fs.mkdirSync(unit, { recursive: true }); + fs.writeFileSync(path.join(unit, 'foreign.bin'), 'foreign\n'); + assert.throws(() => prepare(fixture), /sidecar 随包目录被非本工具内容占用/); + } finally { + fixture.cleanup(); + } +}); + +test('dry run writes nothing', () => { + const fixture = buildFixture(); + try { + const summaries = prepare(fixture, { + dryRun: true, + features: new Set([ + 'unity-editor-execute', + 'godot-editor-execute', + 'cocos-editor-injection', + ]), + }); + 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( + path.join( + fixture.destinationRoot, + fixture.declaration.claudeAgent.resourceDirectory, + ), + ), + ); + 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$/u, + ); + assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 4); + } finally { + fixture.cleanup(); + } +}); + +test('runs declared prepare steps once and copies their artifacts into the staged tree', () => { + const fixture = buildFixture(); + try { + const created = []; + const summaries = prepare(fixture, { + runCommand: fakeRunCommand({ created }), + features: new Set([ + 'unity-editor-execute', + 'godot-editor-execute', + 'cocos-editor-injection', + ]), + }); + assert.match(summaries[0], /命中缓存|重新生成/); + assert.ok( + created.some( + (entry) => + entry.startsWith('powershell.exe') && entry.includes('build.ps1'), + ), + '必须执行声明的 powershell 准备步骤', + ); + assert.ok( + created.some( + (entry) => + entry.startsWith('cargo build') && entry.includes('--target'), + ), + '必须执行声明的 cargo 准备步骤', + ); + const stagedPayload = path.join( + fixture.destinationRoot, + 'resources/plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll', + ); + assert.ok(fs.existsSync(stagedPayload), 'native payload 必须进随包目录'); + assert.ok( + fs.existsSync( + path.join( + fixture.repoRoot, + 'plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll', + ), + ), + 'native payload 必须写回插件工作区(唯一真源)', + ); + } finally { + fixture.cleanup(); + } +}); + +test('skips prepare steps whose fingerprint is unchanged', () => { + const fixture = buildFixture(); + try { + const first = []; + prepare(fixture, { runCommand: fakeRunCommand({ created: first }) }); + assert.ok(first.length > 0, '首次必须执行准备步骤'); + const second = []; + prepare(fixture, { runCommand: fakeRunCommand({ created: second }) }); + const unityRuns = second.filter( + (entry) => + entry.startsWith('powershell.exe') && + entry.includes('agc-unity-editor'), + ); + assert.deepEqual( + unityRuns, + [], + '指纹一致时不应再跑声明了指纹的 Unity 准备步骤', + ); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed when a prepare step does not produce its declared outputs', () => { + const fixture = buildFixture(); + try { + fs.rmSync(path.join(fixture.repoRoot, 'plugins/agc-unity-editor'), { + recursive: true, + force: true, + }); + assert.throws( + () => prepare(fixture, { runCommand: fakeRunCommand() }), + /未产出必需文件/, + ); + } finally { + 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(); + } +}); + +/// 全量 Windows 编辑器 feature:prepared / libraryStaging / nativePayload 三条交付路径全开。 +const ALL_WINDOWS_FEATURES = new Set([ + 'unity-editor-execute', + 'godot-editor-execute', + 'cocos-editor-injection', +]); + +function pluginStaged(fixture, relative) { + return path.join( + fixture.destinationRoot, + fixture.declaration.plugins.destinationDirectory, + relative, + ); +} + +test('keeps the previous codex resources when the replacement fails', () => { + const fixture = buildFixture(); + try { + prepare(fixture); + const declaration = fixture.declaration; + const layout = declaration.codex.targets.find( + (entry) => entry.target === WINDOWS_TARGET, + ); + const unit = path.join( + fixture.destinationRoot, + declaration.codex.resourceDirectory, + layout.directory, + ); + const before = snapshot(unit); + const component = layout.files.find((relative) => + relative.includes('code-mode-host'), + ); + const vendor = path.join( + fixture.appRoot, + `node_modules/@openai/codex-${layout.platform}/vendor/${WINDOWS_TARGET}`, + ); + // 上游组件变了:这一次必然走「staging → 目标」的替换,注入的失败正好落在替换上。 + fs.writeFileSync(path.join(vendor, component), 'changed component\n'); + // 锁定包未变化时 Codex 走缓存;移除记录强制重新读取上游内容。 + fs.rmSync(fixture.recordPath); + + const error = expectThrow(() => + withRenameFailure( + (from, to) => to === path.resolve(unit), + () => prepare(fixture), + ), + ); + assert.match(error.message, /替换 .*win-x64.* 失败/u); + assert.match(error.message, /旧资源已恢复/u); + assert.deepEqual( + snapshot(unit), + before, + '替换失败必须把旧资源原样恢复:内容、尺寸、时间戳与可执行位都不许变', + ); + assert.equal( + fs.readFileSync( + path.join(unit, declaration.codex.noticeFileName), + 'utf8', + ), + 'windows codex notice\n', + '受版本控制的第三方声明必须还在原位', + ); + const stagingPath = /staging 保留在 ([^;]+);/u.exec(error.message)?.[1]; + assert.ok(stagingPath, `报错必须给出 staging 位置:${error.message}`); + // 目标目录旁边只允许剩「旧资源」和「留给人工检查的新 staging」;backup 必须已经归位。 + const siblings = fs.readdirSync(path.dirname(unit)); + assert.ok(siblings.includes(path.basename(unit))); + assert.ok(siblings.includes(path.basename(stagingPath))); + assert.ok( + siblings.every((entry) => !entry.includes('-backup-')), + '恢复成功后不得留下 backup 目录', + ); + assert.equal( + fs.readFileSync(path.join(stagingPath, component), 'utf8'), + 'changed component\n', + '没有落盘的新产物必须留在 staging 里供人工检查', + ); + + // 注入消失后重试必须成功:旧资源完整 → 替换重新做一遍 → 落盘的是上游新内容。 + const summaries = prepare(fixture); + assert.match(summaries[0], /重新生成/u); + assert.equal( + fs.readFileSync(path.join(unit, component), 'utf8'), + 'changed component\n', + ); + } finally { + fixture.cleanup(); + } +}); + +test('keeps the previous plugin resources when the replacement fails', () => { + const fixture = buildFixture(); + try { + prepare(fixture); + const destination = path.join( + fixture.destinationRoot, + fixture.declaration.plugins.destinationDirectory, + ); + // 未声明的额外文件让缓存判定必然漂移:这次一定会走完整替换。 + fs.writeFileSync( + path.join(destination, 'agc-demo-editor/src/rogue.mjs'), + 'rogue\n', + ); + const before = snapshot(destination); + + const error = expectThrow(() => + withRenameFailure( + (from, to) => to === path.resolve(destination), + () => prepare(fixture), + ), + ); + assert.match(error.message, /替换 .*plugins.* 失败/u); + assert.match(error.message, /旧资源已恢复/u); + assert.deepEqual( + snapshot(destination), + before, + '替换失败时旧插件资源(含尚未清理的额外文件)必须逐字节保留', + ); + + prepare(fixture); + assert.ok( + !fs.existsSync(path.join(destination, 'agc-demo-editor/src/rogue.mjs')), + '注入消失后重试必须成功并清掉额外文件', + ); + assert.ok( + fs.existsSync( + path.join(destination, 'agc-demo-editor/panels/panel.html'), + ), + '重试后声明的随包内容必须齐全', + ); + } finally { + fixture.cleanup(); + } +}); + +test('clears undeclared and hidden entries left in the staged plugin tree', () => { + const fixture = buildFixture(); + try { + prepare(fixture); + // 三种残留:额外目录、隐藏目录、声明的源码目录里的隐藏文件。 + for (const relative of [ + 'agc-demo-editor/extra/rogue.txt', + 'agc-demo-editor/.cache/tmp.bin', + 'agc-demo-editor/src/.env', + ]) { + const file = pluginStaged(fixture, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, 'residue\n'); + } + + const summaries = prepare(fixture); + assert.match(summaries[1], /plugins 重新生成/u); + for (const relative of [ + 'agc-demo-editor/extra', + 'agc-demo-editor/.cache', + 'agc-demo-editor/src/.env', + ]) { + assert.ok( + !fs.existsSync(pluginStaged(fixture, relative)), + `未声明的残留 ${relative} 必须被清掉`, + ); + } + for (const relative of [ + 'agc-demo-editor/plugin.json', + 'agc-demo-editor/src/entry.mjs', + 'agc-demo-editor/panels/panel.html', + ]) { + assert.ok( + fs.existsSync(pluginStaged(fixture, relative)), + `声明的随包内容 ${relative} 必须还在`, + ); + } + assert.ok( + !fs.existsSync( + pluginStaged(fixture, 'agc-demo-editor/panels/panel.test.mjs'), + ), + '跳过规则在重建后依然生效', + ); + } finally { + fixture.cleanup(); + } +}); + +test('removes staged plugin files whose source has been deleted', () => { + const fixture = buildFixture(); + try { + prepare(fixture); + assert.ok( + fs.existsSync(pluginStaged(fixture, 'agc-demo-editor/panels/panel.html')), + '前置条件:源码里的文件已经随包', + ); + fs.rmSync( + path.join(fixture.repoRoot, 'plugins/agc-demo-editor/panels/panel.html'), + ); + + const summaries = prepare(fixture); + assert.match(summaries[1], /plugins 重新生成/u); + assert.ok( + !fs.existsSync( + pluginStaged(fixture, 'agc-demo-editor/panels/panel.html'), + ), + '源码里已删除的文件不得留在随包目录', + ); + assert.ok( + fs.existsSync(pluginStaged(fixture, 'agc-demo-editor/src/entry.mjs')), + '同一插件的其他声明内容必须还在', + ); + // 重建后的目录必须自洽:紧接着一次准备应命中缓存而不是反复重建。 + assert.match(prepare(fixture)[1], /命中缓存/u); + } finally { + fixture.cleanup(); + } +}); + +test('clears staged prepared payloads when their feature is disabled', () => { + const fixture = buildFixture(); + try { + prepare(fixture, { features: ALL_WINDOWS_FEATURES }); + for (const relative of [ + 'agc-cocos-editor/native/payload/cocos-editor-bridge.dll', + 'agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe', + 'agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll', + 'agc-godot-editor/native/gdextension/vendor/provenance.json', + ]) { + assert.ok( + fs.existsSync(pluginStaged(fixture, relative)), + `前置条件:feature 全开时 ${relative} 必须随包`, + ); + } + + prepare(fixture, { features: new Set() }); + for (const relative of [ + 'agc-cocos-editor/native/payload', + 'agc-unity-editor/dotnet', + 'agc-godot-editor/native', + ]) { + assert.ok( + !fs.existsSync(pluginStaged(fixture, relative)), + `feature 关闭后 ${relative} 不得作为残留继续随包`, + ); + } + for (const plugin of [ + 'agc-cocos-editor', + 'agc-unity-editor', + 'agc-godot-editor', + ]) { + assert.ok( + fs.existsSync(pluginStaged(fixture, `${plugin}/plugin.json`)), + `feature 关闭不得动到 ${plugin} 的声明内容`, + ); + assert.ok( + fs.existsSync(pluginStaged(fixture, `${plugin}/src/entry.mjs`)), + `feature 关闭不得动到 ${plugin} 的源码内容`, + ); + } + } finally { + fixture.cleanup(); + } +}); + +test('treats declared prepared and library-staging artifacts as owned on a second run', () => { + const fixture = buildFixture(); + try { + prepare(fixture, { features: ALL_WINDOWS_FEATURES }); + const destination = path.join( + fixture.destinationRoot, + fixture.declaration.plugins.destinationDirectory, + ); + const before = snapshot(destination); + + const summaries = prepare(fixture, { features: ALL_WINDOWS_FEATURES }); + assert.match( + summaries[1], + /plugins 命中缓存(未写入/u, + 'prepared 子目录与 libraryStaging 产物属于本工具,不得被误判成外来内容而反复重建', + ); + assert.deepEqual( + snapshot(destination), + before, + '命中缓存时一个字节、一个时间戳都不许动', + ); + for (const relative of [ + 'agc-cocos-editor/native/payload/cocos-editor-bridge.dll', + 'agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe', + 'agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll', + 'agc-godot-editor/native/gdextension/vendor/provenance.json', + ]) { + assert.ok( + fs.existsSync(pluginStaged(fixture, relative)), + `已声明产物 ${relative} 不得因为缓存判定被删掉`, + ); + } + } finally { + fixture.cleanup(); + } +}); diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index e619c7123..7cc06d82a 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -1,8 +1,10 @@ import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { buildLocalRustProcessEnv } from '../../../scripts/dev.mjs'; import { - defaultEditorFeatures, + readCargoTarget, + resolveEditorFeatures, withDefaultCargoFeatures, } from './cargo-features.mjs'; import { @@ -10,6 +12,10 @@ import { resolveAgcDevEndpoint, withAgcDevEndpointEnv, } from './dev-port.mjs'; +import { + prepareBundledResources, + supportedHostTarget, +} from './prepare-bundled-resources.mjs'; import { isAiGameCreatorServer, preflightExistingVite, @@ -51,22 +57,51 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) { } // 开发和发行构建使用同一平台编辑器 feature 集合。 -// 可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。 -function readDevCargoFeatures(env = process.env) { - const override = env.AGC_DEV_CARGO_FEATURES; - if (override !== undefined) { - return override - .split(',') - .map((value) => value.trim()) - .filter(Boolean); - } - return defaultEditorFeatures(process.platform); +// 可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭; +// 命令行显式 `--features` / `-f` 优先于环境变量(cargo 实际收到的是命令行参数)。 +// 入参是「最终交给 Tauri 的参数」:默认平台由其中的显式 `--target` 决定,未指定才是宿主平台。 +function readDevCargoFeatures(argv = []) { + return resolveEditorFeatures({ + argv, + target: readCargoTarget(argv) ?? process.platform, + }); } -function withDevCargoFeatures(argv, features = readDevCargoFeatures()) { +function withDevCargoFeatures(argv, features = readDevCargoFeatures(argv)) { return withDefaultCargoFeatures(argv, features); } +/// 随包资源必须在 Tauri 之前生成:构建脚本只做只读校验,不再生成资源。 +/// 命中缓存的重复调用不写任何文件,因此每次 dev 启动都会先跑一次。 +/// `argv` 是「最终交给 Tauri 的参数」;staging 目标与本次 cargo 构建目标同源: +/// 显式 `--target` 优先,未指定时回落宿主目标;宿主平台不受声明覆盖(如 Linux)时保持跳过, +/// 不为其新增随包资源准备。`features` 省略时按同一份参数解析,不允许「cargo 一套、staging 另一套」。 +function prepareBundledResourcesBeforeTauri( + features, + argv = [], + { + prepare = prepareBundledResources, + log = console.log, + hostTarget = supportedHostTarget(), + } = {}, +) { + const target = readCargoTarget(argv) ?? hostTarget; + if (!target) { + log( + '[ai-game-creator-shell] 当前平台不受随包资源声明覆盖,跳过随包资源准备', + ); + return; + } + const summaries = prepare({ + target, + features: new Set(features ?? readDevCargoFeatures(argv)), + log: (line) => log(`[ai-game-creator-shell] ${line}`), + }); + for (const summary of summaries) { + log(`[ai-game-creator-shell] ${summary}`); + } +} + function spawnTauriCli(argv, { env = process.env } = {}) { return spawnChild(process.execPath, [tauriCliPath, ...argv], { cwd: appRoot, @@ -75,6 +110,17 @@ function spawnTauriCli(argv, { env = process.env } = {}) { }); } +/// Tauri dev 的 Cargo 直接继承启动器环境,用户级 / 仓库级 Cargo 配置里的 +/// `rustc-wrapper`(本地常见为 sccache)会在这里生效。本地 sccache daemon 状态 +/// 一旦损坏,`cargo` 的首次 rustc 探测就会失败并阻断整个 AGC 启动;因此这里复用 +/// `npm run dev` 的本地 Rust 环境规则,由脚本而不是本机 Cargo 配置决定 wrapper。 +function buildTauriDevProcessEnv(endpoint, env = process.env) { + return buildLocalRustProcessEnv({ + ...withAgcDevEndpointEnv(endpoint, env), + [AGC_DESIGN_DEBUG_ENV]: designDebugEnabled, + }); +} + async function runTauriDev( argv = process.argv.slice(2), { @@ -84,6 +130,7 @@ async function runTauriDev( spawnCli = spawnTauriCli, waitForCli = waitForChildTermination, terminateTree = terminateChildTree, + prepareResources = prepareBundledResourcesBeforeTauri, } = {}, ) { const endpoint = await resolveDevEndpoint(); @@ -119,6 +166,12 @@ async function runTauriDev( } try { + // 目标与 feature 都从「最终交给 Tauri 的参数」解析:启动器自己的 `--` 之后是应用参数, + // 不能参与解析;注入默认 feature 也不改变这份解析结果。 + const devTauriArguments = buildTauriArguments(argv, endpoint.url); + const devFeatures = readDevCargoFeatures(devTauriArguments); + prepareResources(devFeatures, devTauriArguments); + // Windows 下 Vite 会监听资源目录;必须在启动前完成目录替换,避免 rename 被占用。 const preparation = prepareFrontend(endpoint, { signal: preparationAbort.signal, onChild(frontend) { @@ -131,14 +184,11 @@ async function runTauriDev( ]); if (!prepared || shutdownSignal) return 1; const tauriArguments = buildTauriArguments( - withDevCargoFeatures(argv), + withDevCargoFeatures(argv, devFeatures), endpoint.url, ); child = spawnCli(tauriArguments, { - env: { - ...withAgcDevEndpointEnv(endpoint), - [AGC_DESIGN_DEBUG_ENV]: designDebugEnabled, - }, + env: buildTauriDevProcessEnv(endpoint), }); const childResult = waitForCli(child); const outcome = await Promise.race([ @@ -225,7 +275,9 @@ function isDirectModuleExecution() { export { buildTauriArguments, + buildTauriDevProcessEnv, isDirectModuleExecution, + prepareBundledResourcesBeforeTauri, runTauriDev, spawnTauriCli, withDevCargoFeatures, diff --git a/apps/ai-game-creator-shell/src-tauri/.taurignore b/apps/ai-game-creator-shell/src-tauri/.taurignore deleted file mode 100644 index 8ec126556..000000000 --- a/apps/ai-game-creator-shell/src-tauri/.taurignore +++ /dev/null @@ -1,4 +0,0 @@ -# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。 -# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发 -# “构建 -> 监听 -> 再构建”的自触发循环。 -resources/plugins/ diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index 9238cb34c..d84e818a7 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -1,7 +1,8 @@ +// 构建脚本只用布局里的目录与校验入口(写入分支已移交准备步骤), +// 其余字段与常量供运行期使用,因此这里不报构建上下文里的 dead_code。 +#[allow(dead_code)] #[path = "build_support/codex_bundle.rs"] mod codex_bundle; -#[path = "build_support/codex_package_metadata.rs"] -mod codex_package_metadata; #[path = "build_support/frontend_dist_guard.rs"] mod frontend_dist_guard; #[path = "build_support/godot_bundle.rs"] @@ -9,287 +10,12 @@ mod godot_bundle; #[path = "build_support/runtime_prompt_bundle.rs"] mod runtime_prompt_bundle; -use sha2::{Digest, Sha256}; use std::collections::BTreeSet; use std::env; use std::fs; use std::path::PathBuf; -use std::io::{BufReader, Read}; - -fn sha256_file(path: &std::path::Path) -> Result { - let file = 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())) -} - -fn claude_agent_platform(target: &str) -> Option<&'static str> { - match target { - "x86_64-pc-windows-msvc" => Some("win32-x64"), - "aarch64-pc-windows-msvc" => Some("win32-arm64"), - "x86_64-apple-darwin" => Some("darwin-x64"), - "aarch64-apple-darwin" => Some("darwin-arm64"), - "x86_64-unknown-linux-gnu" => Some("linux-x64"), - "aarch64-unknown-linux-gnu" => Some("linux-arm64"), - "x86_64-unknown-linux-musl" => Some("linux-x64-musl"), - "aarch64-unknown-linux-musl" => Some("linux-arm64-musl"), - _ => None, - } -} - -fn stage_bundled_claude_agent(manifest_dir: &std::path::Path) { - let target = env::var("TARGET").expect("Cargo TARGET"); - let Some(platform) = claude_agent_platform(&target) else { - println!("cargo:warning=Claude Agent SDK sidecar 暂不支持目标:{target}"); - return; - }; - let app_root = manifest_dir.parent().expect("AGC Tauri manifest parent"); - let repo_root = app_root - .parent() - .and_then(|apps_dir| apps_dir.parent()) - .expect("AGC 应用必须位于仓库 apps 目录下"); - let roots = [app_root, repo_root]; - let sdk_source = roots - .iter() - .map(|root| root.join("node_modules/@anthropic-ai/claude-agent-sdk")) - .find(|path| path.join("sdk.mjs").is_file()) - .unwrap_or_else(|| panic!("Claude Agent SDK 缺失;请先执行 npm ci")); - let binary_package = format!("claude-agent-sdk-{platform}"); - let binary_source = roots - .iter() - .map(|root| { - root.join("node_modules/@anthropic-ai") - .join(&binary_package) - }) - .find(|path| path.is_dir()) - .unwrap_or_else(|| panic!("Claude Agent SDK 原生运行时缺失:{binary_package}")); - let entry_source = app_root.join("agent-sidecar/src/index.mjs"); - assert!(entry_source.is_file(), "Claude Agent SDK sidecar 入口缺失"); - let target_root = manifest_dir.join("resources/claude-agent"); - if target_root.exists() { - fs::remove_dir_all(&target_root).expect("清理 Claude Agent SDK staging 失败"); - } - fs::create_dir_all(target_root.join("node_modules/@anthropic-ai")) - .expect("创建 Claude Agent SDK staging 目录失败"); - stage_plugin_file(&entry_source, &target_root.join("index.mjs")); - copy_plugin_tree( - &sdk_source, - &target_root.join("node_modules/@anthropic-ai/claude-agent-sdk"), - ); - copy_plugin_tree( - &binary_source, - &target_root - .join("node_modules/@anthropic-ai") - .join(&binary_package), - ); - let binary_name = if target.contains("windows") { - "claude.exe" - } else { - "claude" - }; - let staged_binary = target_root - .join("node_modules/@anthropic-ai") - .join(&binary_package) - .join(binary_name); - if let Ok(metadata) = fs::metadata(binary_source.join(binary_name)) { - fs::set_permissions(&staged_binary, metadata.permissions()) - .expect("保留 Claude Agent SDK 原生运行时权限失败"); - } - println!("cargo:rerun-if-changed={}", entry_source.display()); - println!("cargo:rerun-if-changed={}", sdk_source.display()); - println!("cargo:rerun-if-changed={}", binary_source.display()); -} - -fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { - let target = env::var("TARGET").expect("Cargo TARGET"); - println!("cargo:rustc-env=AGC_BUILD_TARGET={target}"); - if target.contains("apple-darwin") { - // Tauri 的 universal 两次 Cargo 编译共用 resource staging, - // 每次都生成完整双架构目录,最终 bundle 不取决于最后编译的切片。 - let staging = manifest_dir.join("resources/codex/mac-native"); - if staging.exists() { - fs::remove_dir_all(&staging).expect("清理 macOS Codex staging 失败"); - } - for target in ["aarch64-apple-darwin", "x86_64-apple-darwin"] { - stage_codex_target(manifest_dir, target); - } - } else { - stage_codex_target(manifest_dir, &target); - } -} - -fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) { - let Some(layout) = codex_bundle::for_target(target) else { - assert!( - !target.contains("windows") && !target.contains("apple-darwin"), - "不支持的 Codex 随包目标:{target}" - ); - return; - }; - { - let app_root = manifest_dir - .parent() - .expect("AI 游戏创作 Tauri manifest 必须位于应用目录下"); - let repo_root = app_root - .parent() - .and_then(|apps_dir| apps_dir.parent()) - .expect("AI 游戏创作应用必须位于仓库 apps 目录下"); - let package = format!("codex-{}", layout.platform); - let source_candidates = [app_root, repo_root] - .into_iter() - .flat_map(|root| { - [ - root.join(format!("node_modules/@openai/{package}/vendor/{target}")), - root.join(format!( - "node_modules/@openai/codex/node_modules/@openai/{package}/vendor/{target}" - )), - ] - }) - .collect::>(); - let source = source_candidates - .iter() - .find(|path| { - layout - .files - .iter() - .all(|relative| path.join(relative).is_file()) - }) - .cloned() - .unwrap_or_else(|| { - panic!( - "内置 Codex CLI 缺失;请先在仓库根目录执行 npm ci(已检查:{})", - source_candidates - .iter() - .map(|path| path.display().to_string()) - .collect::>() - .join(";") - ) - }); - let metadata: serde_json::Value = serde_json::from_slice( - &fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"), - ) - .expect("Codex 原生包元数据无效"); - codex_package_metadata::validate_package_metadata(&metadata, target, layout) - .unwrap_or_else(|error| panic!("{error}")); - let target_dir = manifest_dir.join("resources/codex").join(layout.directory); - let notice = target_dir.join("NOTICE.md"); - if target.contains("apple-darwin") { - let source_notice = - manifest_dir.join("resources/codex/【声明】Mac内置Codex组件-2026-09-18.md"); - stage_plugin_file(&source_notice, ¬ice); - println!("cargo:rerun-if-changed={}", source_notice.display()); - } - if !notice.is_file() { - panic!("内置 Codex CLI 第三方声明缺失:{}", notice.display()); - } - fs::create_dir_all(&target_dir).expect("创建内置 Codex CLI 资源目录失败"); - // 这份目录是随包资源(Windows:`resources/codex/win-x64/**` → `coding-agent/win-x64/**`), - // 只能包含本轮布局声明的组件。上一版布局留下的旧二进制(例如包根目录那份 0.147.0 - // `codex.exe`)会长期留在原地:既误导本地核对与夹具,也让「随包内容」与清单不一致。 - prune_stale_codex_components(&target_dir, layout.files); - let mut file_hashes = serde_json::Map::new(); - for relative in layout.files { - let source_path = source.join(relative); - let target_path = target_dir.join(relative); - if let Some(parent) = target_path.parent() { - fs::create_dir_all(parent).expect("创建内置 Codex CLI 资源子目录失败"); - } - let source_sha256 = sha256_file(&source_path).expect("读取内置 Codex CLI 资源失败"); - let target_matches_source = target_path.is_file() - && sha256_file(&target_path) - .map(|target_sha256| target_sha256 == source_sha256) - .unwrap_or(false); - let source_permissions = fs::metadata(&source_path) - .expect("读取组件权限失败") - .permissions(); - if !target_matches_source { - fs::copy(&source_path, &target_path).expect("复制内置 Codex CLI 资源失败"); - fs::set_permissions(&target_path, source_permissions.clone()) - .expect("保留内置 Codex CLI 组件权限失败"); - } else if fs::metadata(&target_path) - .expect("读取内置 Codex CLI 资源失败") - .permissions() - != source_permissions - { - // 内容相同但曾被错误 chmod 的 staging 文件也必须恢复执行权限。 - // 权限已一致时不再写元数据:Windows 上这次写入会更新 change time, - // 让 tauri dev 的文件监听把每次构建都当成 staging 变更而无限重建。 - fs::set_permissions(&target_path, source_permissions) - .expect("保留内置 Codex CLI 组件权限失败"); - } - file_hashes.insert( - relative.to_string(), - serde_json::Value::String(source_sha256), - ); - } - let manifest = serde_json::json!({ - "schemaVersion": codex_bundle::SCHEMA, - "platform": layout.platform, - "version": codex_bundle::CLI_VERSION, - "files": file_hashes, - }); - let manifest_path = target_dir.join("manifest.json"); - let manifest_payload = format!( - "{}\n", - serde_json::to_string_pretty(&manifest).expect("序列化内置 Codex CLI 清单失败") - ); - if fs::read_to_string(&manifest_path) - .map(|current| current != manifest_payload) - .unwrap_or(true) - { - fs::write(&manifest_path, manifest_payload).expect("写入内置 Codex CLI 清单失败"); - } - for relative in layout.files { - println!("cargo:rerun-if-changed={}", source.join(relative).display()); - } - println!("cargo:rerun-if-changed={}", notice.display()); - } -} - -/// 删除 `target_dir` 下不在本轮布局内的残留文件;空目录一并收掉。 -fn prune_stale_codex_components(target_dir: &std::path::Path, files: &[&str]) { - const ALWAYS_KEEP: &[&str] = &["manifest.json", "NOTICE.md"]; - fn walk(root: &std::path::Path, directory: &std::path::Path, files: &[&str], keep: &[&str]) { - let Ok(entries) = fs::read_dir(directory) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - let Ok(kind) = entry.file_type() else { - continue; - }; - if kind.is_dir() { - walk(root, &path, files, keep); - if fs::read_dir(&path) - .map(|mut remaining| remaining.next().is_none()) - .unwrap_or(false) - { - let _ = fs::remove_dir(&path); - } - continue; - } - let Ok(relative) = path.strip_prefix(root) else { - continue; - }; - let relative = relative.to_string_lossy().replace('\\', "/"); - if files.contains(&relative.as_str()) || keep.contains(&relative.as_str()) { - continue; - } - eprintln!("cargo:warning=清理内置 Codex 组件残留:{relative}"); - let _ = fs::remove_file(&path); - } - } - walk(target_dir, target_dir, files, ALWAYS_KEEP); -} +use codex_bundle::package_layout; fn seed_task_group_id( group: &shared_contracts::game_creation_app::GameCreationAppAgentGroup, @@ -334,17 +60,128 @@ fn validate_seed_task_catalog(compiled: &runtime_prompt_bundle::CompiledPromptBu } } +/// 只读校验:确认已经落盘的随包产物与声明一致。本函数不写任何文件。 +fn validate_staged_resources(manifest_dir: &std::path::Path) { + let target = env::var("TARGET").expect("Cargo TARGET"); + for staged_target in package_layout::staged_targets(&target) { + let Some(layout) = codex_bundle::for_target(staged_target) else { + continue; + }; + let target_dir = manifest_dir + .join(package_layout::codex().resource_directory) + .join(layout.directory); + package_layout::validate_staged_codex_bundle(&target_dir, staged_target).unwrap_or_else( + |error| panic!("内置 Codex CLI 随包资源校验失败({staged_target}):{error}"), + ); + } + validate_staged_claude_agent(manifest_dir, &target); + validate_staged_plugin_workspace(manifest_dir, &target); + validate_prepared_payloads(manifest_dir, &target); +} + +/// 只读校验 Claude Agent SDK sidecar:入口、SDK 与平台原生运行时必须在位。 +/// 未声明该目标时不随包 sidecar,直接放行(Linux 等平台不参与客户端打包)。 +fn validate_staged_claude_agent(manifest_dir: &std::path::Path, target: &str) { + if package_layout::claude_agent_target(target).is_none() { + return; + } + let app_root = manifest_dir + .parent() + .expect("AI 游戏创作 Tauri manifest 必须位于应用目录下"); + let target_dir = manifest_dir.join(package_layout::claude_agent().resource_directory); + package_layout::validate_staged_claude_agent(&target_dir, app_root, target).unwrap_or_else( + |error| panic!("Claude Agent SDK sidecar 随包资源校验失败({target}):{error}"), + ); +} + +/// 只读校验插件随包工作区:声明的源码派生内容必须与仓库源码逐文件一致,整树无符号链接。 +/// 已准备产物与随包库在本机无法重建,构建期至少要确认它们已经就位。 +fn validate_prepared_payloads(manifest_dir: &std::path::Path, target: &str) { + if !package_layout::plugin_staging_applies(target) { + return; + } + let declared = package_layout::plugins(); + let repo_root = manifest_dir + .parent() + .and_then(|app_root| app_root.parent()) + .and_then(|apps_dir| apps_dir.parent()) + .expect("AGC 应用必须位于仓库 apps 目录下"); + let destination_root = manifest_dir.join(declared.destination_directory); + for plugin in package_layout::plugin_directories( + &repo_root.join(declared.source_directory), + declared.manifest_file_name, + ) + .unwrap_or_else(|error| panic!("{error}")) + { + for subdirectory in declared.subdirectories { + if !package_layout::subdirectory_is_prepared(subdirectory) + || !package_layout::subdirectory_applies_to_plugin(subdirectory, &plugin.name) + || !package_layout::subdirectory_enabled( + subdirectory, + target, + package_layout::cargo_feature_enabled, + ) + { + continue; + } + let relative = package_layout::declared_relative_path(subdirectory.path); + let staged = destination_root.join(&plugin.name).join(&relative); + if !staged.is_dir() { + panic!( + "随包已准备产物缺失:{}(请先执行随包资源准备步骤)", + staged.display() + ); + } + } + for staging in declared.library_staging { + if plugin.name != staging.plugin + || !package_layout::library_staging_enabled( + staging, + target, + package_layout::cargo_feature_enabled, + ) + { + continue; + } + let relative = package_layout::declared_relative_path(staging.source_subdirectory); + let staged = destination_root.join(&plugin.name).join(&relative); + godot_bundle::validate(&staged) + .unwrap_or_else(|error| panic!("Godot 随包资源校验失败:{error}")); + } + } +} + +fn validate_staged_plugin_workspace(manifest_dir: &std::path::Path, target: &str) { + let declared = package_layout::plugins(); + let repo_root = manifest_dir + .parent() + .and_then(|app_root| app_root.parent()) + .and_then(|apps_dir| apps_dir.parent()) + .expect("AGC 应用必须位于仓库 apps 目录下"); + package_layout::validate_staged_plugins( + &repo_root.join(declared.source_directory), + &manifest_dir.join(declared.destination_directory), + target, + package_layout::cargo_feature_enabled, + ) + .unwrap_or_else(|error| panic!("插件随包资源校验失败:{error}")); +} fn main() { let manifest_dir = PathBuf::from( env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be available"), ); let manifest_path = manifest_dir.join("prompts/runtime/manifest.json"); - stage_bundled_claude_agent(&manifest_dir); - stage_bundled_codex_cli(&manifest_dir); - prepare_unity_editor_helper(&manifest_dir); - prepare_godot_editor_extension(&manifest_dir); - stage_plugin_workspace(&manifest_dir); - stage_cocos_editor_payload(&manifest_dir); + // 运行期定位随包目录依赖该编译期常量,与是否跳过 staging 无关(见技术方案 §4.4)。 + println!( + "cargo:rustc-env=AGC_BUILD_TARGET={}", + env::var("TARGET").expect("Cargo TARGET") + ); + // Claude Agent SDK 的版本只声明一处,运行期的 sidecar 身份串由此取值。 + println!( + "cargo:rustc-env=AGC_CLAUDE_AGENT_SDK_VERSION={}", + package_layout::claude_agent().version + ); + validate_staged_resources(&manifest_dir); let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path) .unwrap_or_else(|error| panic!("Prompt Bundle 编译失败:{error}")); validate_seed_task_catalog(&compiled); @@ -366,315 +203,3 @@ fn main() { } tauri_build::build() } - -#[cfg(windows)] -fn stage_cocos_editor_payload(manifest_dir: &std::path::Path) { - if std::env::var_os("CARGO_FEATURE_COCOS_EDITOR_INJECTION").is_none() { - return; - } - let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR")); - let profile_dir = out_dir - .ancestors() - .find(|path| path.file_name().is_some_and(|name| name == "build")) - .and_then(|build_dir| build_dir.parent()) - .expect("AGC Cargo profile directory not found"); - let candidates = [ - profile_dir.join("deps/cocos_editor_bridge.dll"), - profile_dir.join("cocos_editor_bridge.dll"), - ]; - let source = candidates - .iter() - .find(|path| path.is_file()) - .unwrap_or_else(|| { - panic!( - "Cocos bridge native payload 未构建:{}", - candidates - .iter() - .map(|p| p.display().to_string()) - .collect::>() - .join(";") - ) - }); - for destination in [ - // 插件工作区里的 payload 是开发态与打包态的唯一真源。 - manifest_dir - .join("../../../plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"), - // 随包资源目录与 tauri.windows.conf.json 的 `resources/plugins` 映射保持一致。 - manifest_dir - .join("resources/plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"), - ] { - std::fs::create_dir_all(destination.parent().expect("payload resource parent")) - .expect("创建 Cocos bridge payload 目录失败"); - std::fs::copy(source, &destination).expect("复制 Cocos bridge native payload 失败"); - } - println!("cargo:rerun-if-changed={}", source.display()); -} - -#[cfg(not(windows))] -fn stage_cocos_editor_payload(_manifest_dir: &std::path::Path) {} - -/// Unity helper 是插件的随包运行文件。内容指纹避免每次 Cargo 检查都重新发布 .NET。 -fn prepare_unity_editor_helper(manifest_dir: &std::path::Path) { - println!("cargo:rerun-if-env-changed=CARGO_FEATURE_UNITY_EDITOR_EXECUTE"); - let target = env::var("TARGET").expect("Cargo TARGET"); - if env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none() - || target != "x86_64-pc-windows-msvc" - { - return; - } - let root = manifest_dir.join("../../../plugins/agc-unity-editor/dotnet"); - let mut sources = Vec::new(); - collect_unity_helper_sources(&root, &mut sources); - sources.sort(); - let mut fingerprint = Sha256::new(); - for source in &sources { - println!("cargo:rerun-if-changed={}", source.display()); - fingerprint.update( - source - .strip_prefix(&root) - .expect("helper source") - .to_string_lossy() - .as_bytes(), - ); - fingerprint.update([0]); - fingerprint.update(fs::read(source).expect("读取 Unity helper 源文件失败")); - } - let fingerprint = format!("{:x}", fingerprint.finalize()); - let publish = root.join("publish/win-x64"); - let executable = publish.join("Agc.Unity.Attach.exe"); - let stamp = publish.join(".agc-source.sha256"); - println!("cargo:rerun-if-changed={}", executable.display()); - if unity_helper_publish_complete(&publish) - && fs::read_to_string(&stamp).ok().as_deref() == Some(&fingerprint) - { - return; - } - assert!( - cfg!(windows), - "构建 Unity 插件 helper 需要 Windows .NET 10 与 x64 C++ 工具链" - ); - let status = std::process::Command::new("powershell.exe") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-File", - ]) - .arg(root.join("build.ps1")) - .current_dir(&root) - .status() - .expect("无法启动 Unity helper 构建脚本"); - assert!( - status.success() && unity_helper_publish_complete(&publish), - "Unity helper 构建失败或缺少运行文件/许可" - ); - fs::write(stamp, fingerprint).expect("写入 Unity helper 构建指纹失败"); -} - -fn unity_helper_publish_complete(publish: &std::path::Path) -> bool { - [ - "Agc.Unity.Attach.exe", - "NOTICE", - "THIRD-PARTY-NOTICES.txt", - "licenses/DotCraft-Apache-2.0.txt", - "licenses/Roslyn-MIT.txt", - "licenses/upstream.json", - "licenses/dotnet-LICENSE.TXT", - "licenses/dotnet-THIRD-PARTY-NOTICES.TXT", - "licenses/microsoft.codeanalysis.common-ThirdPartyNotices.rtf", - "licenses/microsoft.codeanalysis.csharp-ThirdPartyNotices.rtf", - ] - .iter() - .all(|name| { - fs::symlink_metadata(publish.join(name)).is_ok_and(|metadata| { - metadata.is_file() && !metadata.file_type().is_symlink() && metadata.len() > 0 - }) - }) -} - -fn collect_unity_helper_sources(root: &std::path::Path, sources: &mut Vec) { - for entry in fs::read_dir(root) - .expect("Unity helper 源码目录缺失") - .flatten() - { - let kind = entry.file_type().expect("读取 Unity helper 源文件类型失败"); - assert!(!kind.is_symlink(), "Unity helper 源码不允许符号链接"); - let name = entry.file_name(); - if kind.is_dir() { - if !matches!( - name.to_str(), - Some("bin" | "obj" | "publish" | "native-build") - ) { - collect_unity_helper_sources(&entry.path(), sources); - } - } else if kind.is_file() { - sources.push(entry.path()); - } - } -} - -fn prepare_godot_editor_extension(manifest_dir: &std::path::Path) { - println!("cargo:rerun-if-env-changed=CARGO_FEATURE_GODOT_EDITOR_EXECUTE"); - if env::var_os("CARGO_FEATURE_GODOT_EDITOR_EXECUTE").is_none() - || env::var("TARGET").expect("Cargo TARGET") != "x86_64-pc-windows-msvc" - { - return; - } - let root = manifest_dir.join("../../../plugins/agc-godot-editor/native/gdextension"); - for source in godot_bundle::source_files(&root).unwrap_or_else(|error| panic!("{error}")) { - println!("cargo:rerun-if-changed={}", source.display()); - } - assert!( - cfg!(windows), - "构建 Godot 原生扩展需要 Windows x64 C 编译器" - ); - let status = std::process::Command::new("powershell.exe") - // Cargo 可能从 PowerShell 7 启动,Windows PowerShell 应使用自身模块目录。 - .env_remove("PSModulePath") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-File", - ]) - .arg(root.join("build.ps1")) - .current_dir(&root) - .status() - .expect("无法启动 Godot 原生扩展构建脚本"); - assert!(status.success(), "Godot 原生扩展构建失败"); - godot_bundle::validate(&root).unwrap_or_else(|error| panic!("{error}")); -} - -/// 把 `plugins/` 工作区里的插件包随包映射到应用资源目录。 -/// -/// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、 -/// Cargo target 目录或 node_modules。 -fn stage_plugin_workspace(manifest_dir: &std::path::Path) { - let target = env::var("TARGET").expect("Cargo TARGET"); - if !target.contains("windows") && !target.contains("apple-darwin") { - return; - } - let repo_root = manifest_dir - .parent() - .and_then(|app_root| app_root.parent()) - .and_then(|apps_dir| apps_dir.parent()) - .expect("AGC 应用必须位于仓库 apps 目录下") - .to_path_buf(); - let workspace = repo_root.join("plugins"); - let destination_root = manifest_dir.join("resources/plugins"); - // staging 是专用生成目录;重建清除跨目标 payload 与已删除插件的残留。 - if destination_root.exists() { - std::fs::remove_dir_all(&destination_root).expect("清理插件 staging 失败"); - } - std::fs::create_dir_all(&destination_root).expect("创建插件资源目录失败"); - let entries = match std::fs::read_dir(&workspace) { - Ok(entries) => entries, - Err(_) => return, - }; - for entry in entries.flatten() { - let plugin_root = entry.path(); - assert!( - !entry - .file_type() - .expect("读取插件目录类型失败") - .is_symlink(), - "插件工作区不允许符号链接" - ); - if !plugin_root.is_dir() || !plugin_root.join("plugin.json").is_file() { - continue; - } - let name = entry.file_name(); - let destination = destination_root.join(&name); - copy_plugin_file( - &plugin_root.join("plugin.json"), - &destination.join("plugin.json"), - ); - for relative in [ - std::path::PathBuf::from("src"), - std::path::PathBuf::from("panels"), - std::path::PathBuf::from("skills"), - std::path::PathBuf::from("native/payload"), - std::path::PathBuf::from("dotnet/publish/win-x64"), - ] { - if (relative == std::path::Path::new("native/payload") && !target.contains("windows")) - || (relative == std::path::Path::new("dotnet/publish/win-x64") - && (target != "x86_64-pc-windows-msvc" - || env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none())) - { - continue; - } - copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative)); - } - if name == "agc-godot-editor" { - godot_bundle::stage( - &plugin_root.join("native/gdextension"), - &destination.join("native/gdextension"), - &target, - env::var_os("CARGO_FEATURE_GODOT_EDITOR_EXECUTE").is_some(), - ) - .unwrap_or_else(|error| panic!("{error}")); - } - println!("cargo:rerun-if-changed={}", plugin_root.display()); - } -} - -fn stage_plugin_file(source: &std::path::Path, destination: &std::path::Path) { - let bytes = std::fs::read(source) - .unwrap_or_else(|error| panic!("读取随包资源失败 {}:{error}", source.display())); - if std::fs::read(destination).is_ok_and(|existing| existing == bytes) { - return; - } - if let Some(parent) = destination.parent() { - std::fs::create_dir_all(parent).expect("创建插件资源目录失败"); - } - std::fs::write(destination, bytes).expect("复制插件资源失败"); -} - -fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) { - let entries = match std::fs::read_dir(source) { - Ok(entries) => entries, - Err(_) => return, - }; - for entry in entries.flatten() { - let target = destination.join(entry.file_name()); - let path = entry.path(); - assert!( - !entry - .file_type() - .expect("读取插件文件类型失败") - .is_symlink(), - "插件资源不允许符号链接" - ); - if path.is_dir() { - let name = entry.file_name(); - let name = name.to_string_lossy(); - if name.starts_with('.') || matches!(name.as_ref(), "target" | "node_modules") { - continue; - } - std::fs::create_dir_all(&target).expect("创建插件资源目录失败"); - copy_plugin_tree(&path, &target); - } else { - // 测试文件不随包分发。 - let name = entry.file_name(); - let name = name.to_string_lossy(); - if name.contains(".test.") { - continue; - } - if name.starts_with('.') { - continue; - } - stage_plugin_file(&path, &target); - } - } -} - -fn copy_plugin_file(source: &std::path::Path, destination: &std::path::Path) { - if !source.is_file() { - return; - } - std::fs::create_dir_all(destination.parent().expect("插件资源父目录")) - .expect("创建插件资源目录失败"); - std::fs::copy(source, destination).expect("复制插件资源失败"); -} 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/codex_package_metadata.rs b/apps/ai-game-creator-shell/src-tauri/build_support/codex_package_metadata.rs deleted file mode 100644 index 04b9b6a41..000000000 --- a/apps/ai-game-creator-shell/src-tauri/build_support/codex_package_metadata.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! 随包阶段的原生包元数据校验,不进入运行时生产模块。 - -use super::codex_bundle::{Layout, VERSION}; - -pub fn validate_package_metadata( - metadata: &serde_json::Value, - target: &str, - layout: Layout, -) -> Result<(), String> { - if metadata["layoutVersion"] == 1 - && metadata["version"] == VERSION - && metadata["target"] == target - && metadata["entrypoint"] == layout.executable - && metadata["resourcesDir"] == "codex-resources" - && metadata["pathDir"] == "codex-path" - { - Ok(()) - } else { - Err(format!("Codex 原生包版本、布局或架构不匹配目标 {target}")) - } -} - -#[cfg(test)] -mod tests { - use super::super::codex_bundle::for_target; - use super::*; - - #[test] - fn metadata_rejects_version_architecture_and_layout_drift() { - let target = "aarch64-apple-darwin"; - let layout = for_target(target).unwrap(); - let valid = serde_json::json!({ - "layoutVersion": 1, - "version": VERSION, - "target": target, - "entrypoint": "bin/codex", - "resourcesDir": "codex-resources", - "pathDir": "codex-path", - }); - assert!(validate_package_metadata(&valid, target, layout).is_ok()); - for (key, value) in [ - ("layoutVersion", serde_json::json!(2)), - ("version", serde_json::json!("0.0.0")), - ("target", serde_json::json!("x86_64-apple-darwin")), - ("entrypoint", serde_json::json!("bin/codex.exe")), - ("resourcesDir", serde_json::json!("../private")), - ("pathDir", serde_json::json!(null)), - ] { - let mut invalid = valid.clone(); - invalid[key] = value; - assert!( - validate_package_metadata(&invalid, target, layout).is_err(), - "{key}" - ); - } - } -} 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 8a326ac98..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 @@ -1,13 +1,13 @@ use sha2::{Digest, Sha256}; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; -pub const BUNDLE_FILES: [&str; 4] = [ - "bin/win-x64/agc_godot_editor.dll", - "bin/win-x64/metadata.json", - "vendor/LICENSE.txt", - "vendor/provenance.json", -]; +// 共享随包资源声明:Godot 随包文件清单与构建期校验共用同一份来源。 +#[allow(dead_code)] +#[path = "package_layout.rs"] +mod package_layout; + +pub const BUNDLE_FILES: &[&str] = package_layout::GODOT_BUNDLE_FILES; fn plain_metadata(path: &Path) -> Result { let metadata = fs::symlink_metadata(path) @@ -74,39 +74,6 @@ pub fn validate(root: &Path) -> Result)>, String> { Ok(files) } -pub fn stage(root: &Path, destination: &Path, target: &str, enabled: bool) -> Result<(), String> { - if target != "x86_64-pc-windows-msvc" || !enabled { - return Ok(()); - } - for (relative, bytes) in validate(root)? { - let path = destination.join(relative); - fs::create_dir_all(path.parent().expect("Godot resource parent")) - .map_err(|error| format!("创建 Godot 资源目录失败:{error}"))?; - fs::write(&path, bytes).map_err(|error| format!("写入 Godot 资源失败:{error}"))?; - } - Ok(()) -} - -pub fn source_files(root: &Path) -> Result, String> { - plain_metadata(root)?; - let mut sources = Vec::new(); - for entry in fs::read_dir(root).map_err(|error| format!("读取 Godot 源码失败:{error}"))? - { - let entry = entry.map_err(|error| format!("读取 Godot 源码目录项失败:{error}"))?; - if matches!(entry.file_name().to_str(), Some("bin" | ".build")) { - continue; - } - let metadata = plain_metadata(&entry.path())?; - if metadata.is_dir() { - sources.extend(source_files(&entry.path())?); - } else if metadata.is_file() { - sources.push(entry.path()); - } - } - sources.sort(); - Ok(sources) -} - #[cfg(test)] mod tests { use super::*; @@ -134,82 +101,20 @@ mod tests { } #[test] - fn stage_only_verified_windows_runtime_and_not_build_inputs() { - let source = tempfile::tempdir().unwrap(); - let destination = tempfile::tempdir().unwrap(); - fixture(source.path()); - fs::write(source.path().join("bridge.gd"), "source").unwrap(); - fs::write(source.path().join("bin/win-x64/extra.dll"), "excluded").unwrap(); - stage( - source.path(), - destination.path(), - "x86_64-pc-windows-msvc", - true, - ) - .unwrap(); + 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 { - assert_eq!( - fs::read(source.path().join(relative)).unwrap(), - fs::read(destination.path().join(relative)).unwrap() - ); + 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!(!destination.path().join("bridge.gd").exists()); - assert!(!destination.path().join("bin/win-x64/extra.dll").exists()); - } - - #[test] - fn unsupported_or_disabled_targets_need_no_native_artifacts() { - let destination = tempfile::tempdir().unwrap(); - for (target, enabled) in [ - ("aarch64-apple-darwin", true), - ("x86_64-apple-darwin", true), - ("x86_64-unknown-linux-gnu", true), - ("aarch64-pc-windows-msvc", true), - ("x86_64-pc-windows-msvc", false), - ] { - stage( - Path::new("missing-godot-native"), - destination.path(), - target, - enabled, - ) - .unwrap(); - assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0); - } - } - - #[test] - fn incomplete_or_tampered_bundle_fails_before_copying() { - let source = tempfile::tempdir().unwrap(); - let destination = tempfile::tempdir().unwrap(); - fixture(source.path()); - fs::write(source.path().join(BUNDLE_FILES[0]), b"tampered").unwrap(); - assert!(stage( - source.path(), - destination.path(), - "x86_64-pc-windows-msvc", - true - ) - .unwrap_err() - .contains("SHA256")); - assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0); - fixture(source.path()); - fs::remove_file(source.path().join("vendor/LICENSE.txt")).unwrap(); - assert!(validate(source.path()).is_err()); - } - - #[test] - fn source_watch_list_excludes_build_outputs() { - let source = tempfile::tempdir().unwrap(); - fixture(source.path()); - fs::create_dir(source.path().join(".build")).unwrap(); - fs::write(source.path().join(".build/bridge.obj"), "generated").unwrap(); - fs::write(source.path().join("bridge.gd"), "source").unwrap(); - let sources = source_files(source.path()).unwrap(); - assert_eq!(sources.len(), 3); - assert!(sources.contains(&source.path().join("bridge.gd"))); - assert!(!sources.iter().any(|path| path - .components() - .any(|component| component.as_os_str() == "bin" || component.as_os_str() == ".build"))); + assert!( + validate(temp.path()).is_err(), + "内容不合法的随包库必须被拒绝", + ); } } 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..931290eb7 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs @@ -0,0 +1,163 @@ +// @generated by apps/ai-game-creator-shell/scripts/check-package-layout.mjs +// 来源:build_support/package-layout.json。不要手工编辑本文件。 +// 修改随包资源布局请编辑声明文件,然后运行 +// npm run agc:bundled-resources:sync(在仓库根目录) +// 门禁会校验两者一致(npm run agc:typecheck 链内含 check-package-layout.mjs)。 + +pub const DECLARATION_SCHEMA: &str = "agc-package-layout.v1"; +pub const LAYOUT_VERSION: u64 = 2; + +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 CLAUDE_AGENT_SDK_VERSION: &str = "0.3.285"; + +pub const GODOT_BUNDLE_FILES: &[&str] = &["bin/win-x64/agc_godot_editor.dll", "bin/win-x64/metadata.json", "vendor/LICENSE.txt", "vendor/provenance.json"]; + +pub const CODEX: Codex = Codex { + package_metadata: PackageMetadata { + layout_version: 1, + resources_dir: "codex-resources", + path_dir: "codex-path", +}, + 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 CLAUDE_AGENT: ClaudeAgent = ClaudeAgent { + version: "0.3.285", + resource_directory: "resources/claude-agent", + entry_relative_path: "agent-sidecar/src/index.mjs", + entry_file_name: "index.mjs", + node_modules_directory: "node_modules", + sdk_package_name: "@anthropic-ai/claude-agent-sdk", + sdk_entry_file_name: "sdk.mjs", + sdk_package_metadata_file_name: "package.json", + targets: &[ +ClaudeAgentTarget { + target: "x86_64-pc-windows-msvc", + runtime_package: "claude-agent-sdk-win32-x64", + runtime_file_name: "claude.exe", + }, +ClaudeAgentTarget { + target: "aarch64-apple-darwin", + runtime_package: "claude-agent-sdk-darwin-arm64", + runtime_file_name: "claude", + }, +ClaudeAgentTarget { + target: "x86_64-apple-darwin", + runtime_package: "claude-agent-sdk-darwin-x64", + runtime_file_name: "claude", + } +], +}; + +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", + plugin: "", + origin: "source", + target_contains: &[], + targets: &[], + features: &[], + }, +Subdirectory { + path: "panels", + plugin: "", + origin: "source", + target_contains: &[], + targets: &[], + features: &[], + }, +Subdirectory { + path: "skills", + plugin: "", + origin: "source", + target_contains: &[], + targets: &[], + features: &[], + }, +Subdirectory { + path: "native/payload", + plugin: "agc-cocos-editor", + origin: "prepared", + target_contains: &["windows"], + targets: &[], + features: &["cocos-editor-injection"], + }, +Subdirectory { + path: "dotnet/publish/win-x64", + plugin: "agc-unity-editor", + origin: "prepared", + 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", + files: &["bin/win-x64/agc_godot_editor.dll", "bin/win-x64/metadata.json", "vendor/LICENSE.txt", "vendor/provenance.json"], + } +], + 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..1729262db --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json @@ -0,0 +1,234 @@ +{ + "schema": "agc-package-layout.v1", + "description": "AGC 随包资源布局与复制规则的唯一声明。Rust 侧构建期校验与 Node 侧准备步骤共用本文件,任何一侧都不得再写第二份布局或组件白名单。含 、 占位符的字段由调用方按目标三元展开。修改布局时同步递增 layoutVersion(准备步骤的缓存 key 组成部分)。", + "layoutVersion": 2, + "codex": { + "version": "0.155.1", + "cliVersionPrefix": "codex-cli ", + "manifestSchema": "genarrative-codex-sidecar.v2", + "packageMetadata": { + "layoutVersion": 1, + "resourcesDir": "codex-resources", + "pathDir": "codex-path" + }, + "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" + ] + } + ] + }, + "claudeAgent": { + "version": "0.3.285", + "resourceDirectory": "resources/claude-agent", + "entryRelativePath": "agent-sidecar/src/index.mjs", + "entryFileName": "index.mjs", + "nodeModulesDirectory": "node_modules", + "sdkPackageName": "@anthropic-ai/claude-agent-sdk", + "sdkEntryFileName": "sdk.mjs", + "sdkPackageMetadataFileName": "package.json", + "sourceRoots": ["app", "repo"], + "skipDirectoryNames": ["target", "node_modules"], + "skipDirectoryNamePrefixes": ["."], + "skipFileNamePrefixes": ["."], + "skipFileNameFragments": [".test."], + "targets": [ + { + "target": "x86_64-pc-windows-msvc", + "runtimePackage": "claude-agent-sdk-win32-x64", + "runtimeFileName": "claude.exe" + }, + { + "target": "aarch64-apple-darwin", + "runtimePackage": "claude-agent-sdk-darwin-arm64", + "runtimeFileName": "claude" + }, + { + "target": "x86_64-apple-darwin", + "runtimePackage": "claude-agent-sdk-darwin-x64", + "runtimeFileName": "claude" + } + ] + }, + "plugins": { + "sourceDirectory": "plugins", + "destinationDirectory": "resources/plugins", + "manifestFileName": "plugin.json", + "targetContainsAny": ["windows", "apple-darwin"], + "subdirectories": [ + { + "path": "src", + "origin": "source" + }, + { + "path": "panels", + "origin": "source" + }, + { + "path": "skills", + "origin": "source" + }, + { + "path": "native/payload", + "origin": "prepared", + "targetContains": ["windows"], + "plugin": "agc-cocos-editor", + "prepare": "cocos-bridge-build", + "features": ["cocos-editor-injection"] + }, + { + "path": "dotnet/publish/win-x64", + "origin": "prepared", + "prepare": "unity-helper-publish", + "targets": ["x86_64-pc-windows-msvc"], + "features": ["unity-editor-execute"], + "plugin": "agc-unity-editor" + } + ], + "libraryStaging": [ + { + "plugin": "agc-godot-editor", + "sourceSubdirectory": "native/gdextension", + "prepare": "godot-extension-build", + "targets": ["x86_64-pc-windows-msvc"], + "features": ["godot-editor-execute"], + "layout": "godot-bundle", + "files": [ + "bin/win-x64/agc_godot_editor.dll", + "bin/win-x64/metadata.json", + "vendor/LICENSE.txt", + "vendor/provenance.json" + ] + } + ], + "nativePayloads": [ + { + "plugin": "agc-cocos-editor", + "prepare": "cocos-bridge-build", + "sourceFileName": "cocos_editor_bridge.dll", + "destinationSubdirectory": "native/payload", + "targets": ["x86_64-pc-windows-msvc"], + "features": ["cocos-editor-injection"], + "destinationFileName": "cocos-editor-bridge.dll" + } + ], + "prepareSteps": [ + { + "name": "unity-helper-publish", + "kind": "powershell", + "workingDirectory": "plugins/agc-unity-editor/dotnet", + "scriptFileName": "build.ps1", + "fingerprint": { + "roots": ["."], + "excludeDirectoryNames": ["bin", "obj", "publish", "native-build"], + "stampRelativePath": "publish/win-x64/.agc-source.sha256" + }, + "requiredOutputs": [ + "plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe", + "plugins/agc-unity-editor/dotnet/publish/win-x64/NOTICE", + "plugins/agc-unity-editor/dotnet/publish/win-x64/THIRD-PARTY-NOTICES.txt", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/DotCraft-Apache-2.0.txt", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/Roslyn-MIT.txt", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/upstream.json", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/dotnet-LICENSE.TXT", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/dotnet-THIRD-PARTY-NOTICES.TXT", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/microsoft.codeanalysis.common-ThirdPartyNotices.rtf", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/microsoft.codeanalysis.csharp-ThirdPartyNotices.rtf" + ] + }, + { + "name": "godot-extension-build", + "kind": "powershell", + "workingDirectory": "plugins/agc-godot-editor/native/gdextension", + "scriptFileName": "build.ps1", + "removeEnvironment": ["PSModulePath"], + "requiredOutputs": [ + "plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll", + "plugins/agc-godot-editor/native/gdextension/bin/win-x64/metadata.json", + "plugins/agc-godot-editor/native/gdextension/vendor/LICENSE.txt", + "plugins/agc-godot-editor/native/gdextension/vendor/provenance.json" + ], + "fingerprint": { + "roots": ["."], + "excludeDirectoryNames": ["bin", ".build", "native-build"], + "stampRelativePath": "bin/win-x64/.agc-source.sha256" + } + }, + { + "name": "cocos-bridge-build", + "kind": "cargo", + "packageDirectory": "plugins/agc-cocos-editor/native/cocos-editor-bridge", + "features": ["windows-injection"], + "requiredOutputs": [] + } + ], + "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..ddd54ee55 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs @@ -0,0 +1,1641 @@ +//! 随包资源布局与复制规则的单一声明读取层,以及随包产物的只读校验。 +//! +//! 人工声明只有一处:`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 PackageMetadata { + pub layout_version: u64, + pub resources_dir: &'static str, + pub path_dir: &'static str, +} + +/// 插件工作区的随包子目录及其生效条件。 +#[derive(Debug)] +pub struct Subdirectory { + pub path: &'static str, + /// 该子目录归属的插件名;空串表示适用于所有插件。 + pub plugin: &'static str, + /// `source`:由声明与仓库源码就能生成(准备步骤负责);`build`:构建期工具链产出(构建脚本负责)。 + pub origin: &'static str, + pub target_contains: &'static [&'static str], + pub targets: &'static [&'static str], + pub features: &'static [&'static str], +} + +/// 由外部工具链另行产出的随包库(如 Godot gdextension)的归位规则。 +/// +/// `files` 是相对 `source_subdirectory` 的随包文件清单:源码工作区同位目录里还有构建输入与 +/// 测试,只有这些文件会归位到随包目录,校验也以这份清单为准。 +#[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, + pub files: &'static [&'static str], +} + +#[derive(Debug)] +pub struct Codex { + pub package_metadata: PackageMetadata, + 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], +} + +/// Claude Agent SDK sidecar 在某个目标上的原生运行时包。 +#[derive(Debug)] +pub struct ClaudeAgentTarget { + pub target: &'static str, + pub runtime_package: &'static str, + pub runtime_file_name: &'static str, +} + +/// Claude Agent SDK sidecar 的随包布局:应用仓库入口 + 两个上游 npm 包。 +#[derive(Debug)] +pub struct ClaudeAgent { + pub version: &'static str, + pub resource_directory: &'static str, + pub entry_relative_path: &'static str, + pub entry_file_name: &'static str, + pub node_modules_directory: &'static str, + pub sdk_package_name: &'static str, + pub sdk_entry_file_name: &'static str, + pub sdk_package_metadata_file_name: &'static str, + pub targets: &'static [ClaudeAgentTarget], +} + +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 +} + +pub fn claude_agent() -> &'static ClaudeAgent { + &CLAUDE_AGENT +} + +/// 声明里的 Claude Agent SDK sidecar 目标;未声明的目标返回 `None`(该目标不随包 sidecar)。 +pub fn claude_agent_target(target: &str) -> Option<&'static ClaudeAgentTarget> { + CLAUDE_AGENT + .targets + .iter() + .find(|entry| entry.target == target) +} + +/// 声明里的目标布局;未声明的目标返回 `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)) +} + +/// 子目录是否归属该插件(空串表示通用)。 +pub fn subdirectory_applies_to_plugin(subdirectory: &Subdirectory, plugin_name: &str) -> bool { + subdirectory.plugin.is_empty() || subdirectory.plugin == plugin_name +} + +/// 子目录在当前目标与已启用 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() +} + +/// 只读校验插件随包工作区。 +/// +/// 校验分两层:先按当前目标与已启用 feature 构造允许文件集合(插件清单、生效的随包子目录、 +/// 生效的随包库),再要求随包目录由这些文件恰好组成——清单与源码派生的子目录逐文件 sha256 +/// 一致,构建期派生的子目录与源码工作区同位目录逐文件对齐(内容由产出它的构建步骤负责), +/// 随包库的声明文件齐备;集合之外的任何文件一律拒绝(未声明的根条目、源码子目录里的残留文件、 +/// 未启用 feature 的产物)。整树不得出现符号链接,本函数不做任何写入。 +pub fn validate_staged_plugins( + source_root: &Path, + destination_root: &Path, + target: &str, + feature_enabled: impl Fn(&str) -> bool, +) -> Result<(), String> { + if !plugin_staging_applies(target) { + return Ok(()); + } + let metadata = std::fs::symlink_metadata(destination_root).map_err(|error| { + format!( + "插件随包资源目录不可读 {}:{error}", + destination_root.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!( + "插件随包资源目录缺失或不允许符号链接:{}", + destination_root.display() + )); + } + + let declared_plugins = plugin_directories(source_root, plugins().manifest_file_name)?; + let declared_names = declared_plugins + .iter() + .map(|plugin| plugin.name.as_str()) + .collect::>(); + for entry in std::fs::read_dir(destination_root).map_err(|error| { + format!( + "插件随包资源目录不可读 {}:{error}", + destination_root.display() + ) + })? { + let entry = entry.map_err(|error| format!("插件随包目录项不可读:{error}"))?; + let file_type = entry + .file_type() + .map_err(|error| format!("插件随包目录项类型不可读:{error}"))?; + if file_type.is_symlink() { + return Err(format!( + "插件随包目录不允许符号链接:{}", + entry.path().display() + )); + } + let name = entry.file_name().to_string_lossy().to_string(); + if !declared_names.contains(name.as_str()) { + return Err(format!( + "插件随包目录存在未声明条目:{}", + entry.path().display() + )); + } + } + for plugin in &declared_plugins { + let staged = destination_root.join(&plugin.name); + let source_manifest = plugin.path.join(plugins().manifest_file_name); + let staged_manifest = staged.join(plugins().manifest_file_name); + if !staged_manifest.is_file() { + return Err(format!("随包插件缺少清单:{}", staged_manifest.display())); + } + let source_manifest_digest = sha256_file(&source_manifest) + .map_err(|error| format!("读取插件清单失败 {}:{error}", source_manifest.display()))?; + let staged_manifest_digest = sha256_file(&staged_manifest).map_err(|error| { + format!( + "读取随包插件清单失败 {}:{error}", + staged_manifest.display() + ) + })?; + if source_manifest_digest != staged_manifest_digest { + return Err(format!( + "随包插件清单与源码不一致:{}", + staged_manifest.display() + )); + } + let mut allowed = BTreeSet::new(); + allowed.insert(plugins().manifest_file_name.to_string()); + for subdirectory in plugins().subdirectories { + if !subdirectory_applies_to_plugin(subdirectory, &plugin.name) + || !subdirectory_enabled(subdirectory, target, &feature_enabled) + { + continue; + } + let relative = declared_relative_path(subdirectory.path); + let source_directory = plugin.path.join(&relative); + let staged_directory = staged.join(&relative); + for file in source_subdirectory_files(&source_directory)? { + allowed.insert(format!("{}/{}", subdirectory.path, file)); + let staged_file = staged_directory.join(declared_relative_path(&file)); + if !staged_file.is_file() { + return Err(if subdirectory_is_prepared(subdirectory) { + format!( + "随包已准备产物缺少文件:{}(请先执行随包资源准备步骤)", + staged_file.display() + ) + } else { + format!("随包插件缺少文件:{}", staged_file.display()) + }); + } + if !subdirectory_is_source_derived(subdirectory) { + continue; + } + let source_file = source_directory.join(declared_relative_path(&file)); + let source_digest = sha256_file(&source_file).map_err(|error| { + format!("读取插件文件失败 {}:{error}", source_file.display()) + })?; + let staged_digest = sha256_file(&staged_file).map_err(|error| { + format!("读取随包插件文件失败 {}:{error}", staged_file.display()) + })?; + if source_digest != staged_digest { + return Err(format!( + "随包插件文件与源码不一致:{}", + staged_file.display() + )); + } + } + } + for staging in plugins().library_staging { + if staging.plugin != plugin.name.as_str() + || !library_staging_enabled(staging, target, &feature_enabled) + { + continue; + } + for file in staging.files { + let relative = format!("{}/{}", staging.source_subdirectory, file); + let staged_file = staged.join(declared_relative_path(&relative)); + if !staged_file.is_file() { + return Err(format!( + "随包库缺少声明文件:{}(请先执行随包资源准备步骤)", + staged_file.display() + )); + } + allowed.insert(relative); + } + } + for relative in collect_tree_files(&staged)? { + if !allowed.contains(&relative) { + return Err(format!( + "随包插件存在未声明文件:{}(目标 {target} 与当前 feature 组合不允许;请先执行随包资源准备步骤)", + staged.join(declared_relative_path(&relative)).display() + )); + } + } + } + collect_tree_files(destination_root)?; + Ok(()) +} + +/// 源码工作区同位子目录里的文件集合;该目录不存在时为空集。 +fn source_subdirectory_files(source_directory: &Path) -> Result, String> { + if !source_directory.is_dir() { + return Ok(BTreeSet::new()); + } + collect_sources(source_directory) +} + +/// 声明为「由准备步骤按源码派生」的子目录。 +pub fn subdirectory_is_source_derived(subdirectory: &Subdirectory) -> bool { + subdirectory.origin == "source" +} + +/// 声明为「需要先由准备步骤运行构建命令产出」的子目录。 +pub fn subdirectory_is_prepared(subdirectory: &Subdirectory) -> bool { + subdirectory.origin == "prepared" +} + +/// 仓库插件目录:含清单文件的普通目录,按名字排序。 +pub fn plugin_directories( + source_root: &Path, + manifest_file_name: &str, +) -> Result, String> { + let mut plugins = Vec::new(); + let entries = std::fs::read_dir(source_root) + .map_err(|error| format!("插件工作区不可读 {}:{error}", source_root.display()))?; + for entry in entries { + let entry = entry.map_err(|error| format!("插件目录项不可读:{error}"))?; + let name = entry.file_name().to_string_lossy().to_string(); + let path = entry.path(); + let file_type = entry + .file_type() + .map_err(|error| format!("插件目录项类型不可读:{error}"))?; + if file_type.is_symlink() { + continue; + } + if file_type.is_dir() && path.join(manifest_file_name).is_file() { + plugins.push(PluginDirectory { name, path }); + } + } + plugins.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(plugins) +} + +pub struct PluginDirectory { + pub name: String, + pub path: PathBuf, +} + +/// 按声明的跳过规则收集目录下的文件(相对路径,`/` 分隔);遇到符号链接即失败。 +pub fn collect_sources(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() { + if !skip_directory(&name) { + stack.push((entry.path(), relative)); + } + } else if !skip_file_name(&name) { + files.insert(relative); + } + } + } + Ok(files) +} + +/// 只读校验一份已经落盘的 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(()) +} + +/// 只读校验 Claude Agent SDK sidecar 的随包产物(由准备步骤写入)。 +/// +/// 上游 npm 包由应用依赖与 lockfile 锁定,准备步骤整目录替换,因此构建期校验的是: +/// 入口与仓库源码逐字节一致、SDK 版本与声明一致、平台原生运行时在位、目录里没有白名单外的内容。 +pub fn validate_staged_claude_agent( + target_dir: &Path, + app_root: &Path, + target: &str, +) -> Result<(), String> { + let declared = claude_agent_target(target) + .ok_or_else(|| format!("声明不含目标 {target} 的 Claude Agent SDK sidecar 布局"))?; + 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 entry = target_dir.join(CLAUDE_AGENT.entry_file_name); + let entry_source = app_root.join(declared_relative_path(CLAUDE_AGENT.entry_relative_path)); + let staged_entry = std::fs::read(&entry).map_err(|error| { + format!( + "Claude Agent SDK sidecar 入口不可读 {}:{error}", + entry.display() + ) + })?; + if staged_entry.is_empty() { + return Err(format!( + "Claude Agent SDK sidecar 入口为空:{}", + entry.display() + )); + } + let repository_entry = std::fs::read(&entry_source).map_err(|error| { + format!( + "Claude Agent SDK sidecar 源码入口不可读 {}:{error}", + entry_source.display() + ) + })?; + if staged_entry != repository_entry { + return Err(format!( + "Claude Agent SDK sidecar 入口与仓库源码不一致:{}(源码 {})", + entry.display(), + entry_source.display() + )); + } + + let scope = package_scope(CLAUDE_AGENT.sdk_package_name); + let sdk_package = claude_agent_node_modules(target_dir) + .join(declared_relative_path(CLAUDE_AGENT.sdk_package_name)); + validate_upstream_package_version( + &sdk_package, + CLAUDE_AGENT.sdk_package_metadata_file_name, + CLAUDE_AGENT.version, + "Claude Agent SDK", + )?; + let sdk_entry = sdk_package.join(CLAUDE_AGENT.sdk_entry_file_name); + if !is_staged_file(&sdk_entry) { + return Err(format!( + "Claude Agent SDK sidecar 缺少 SDK 入口:{}", + sdk_entry.display() + )); + } + + let runtime_package = claude_agent_node_modules(target_dir) + .join(declared_relative_path(scope)) + .join(declared_relative_path(declared.runtime_package)); + validate_upstream_package_version( + &runtime_package, + CLAUDE_AGENT.sdk_package_metadata_file_name, + CLAUDE_AGENT.version, + "Claude Agent SDK 原生运行时", + )?; + let runtime_file = runtime_package.join(declared_relative_path(declared.runtime_file_name)); + if !is_staged_file(&runtime_file) { + return Err(format!( + "Claude Agent SDK native runtime 缺失:{}", + runtime_file.display() + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if std::fs::metadata(&runtime_file) + .map(|metadata| metadata.permissions().mode() & 0o111 == 0) + .unwrap_or(true) + { + return Err(format!( + "Claude Agent SDK native runtime 缺少可执行位:{}", + runtime_file.display() + )); + } + } + + let allowed_prefixes = [ + format!( + "{}/{}", + CLAUDE_AGENT.node_modules_directory, CLAUDE_AGENT.sdk_package_name + ), + format!( + "{}/{}/{}", + CLAUDE_AGENT.node_modules_directory, scope, declared.runtime_package + ), + ]; + for relative in collect_tree_files(target_dir)? { + if relative == CLAUDE_AGENT.entry_file_name + || allowed_prefixes + .iter() + .any(|prefix| relative.starts_with(&format!("{prefix}/"))) + { + continue; + } + return Err(format!( + "Claude Agent SDK sidecar 随包目录存在白名单外的文件:{relative}({})", + target_dir.display() + )); + } + Ok(()) +} + +/// 随包 sidecar 的 `node_modules` 目录。 +fn claude_agent_node_modules(target_dir: &Path) -> PathBuf { + target_dir.join(declared_relative_path(CLAUDE_AGENT.node_modules_directory)) +} + +/// `@scope/pkg` 的 scope 段;声明门禁保证 SDK 包名带 scope。 +fn package_scope(package_name: &str) -> &str { + package_name.split('/').next().unwrap_or(package_name) +} + +/// 普通文件(非符号链接)且非空。 +fn is_staged_file(path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok_and(|metadata| { + metadata.is_file() && !metadata.file_type().is_symlink() && metadata.len() > 0 + }) +} + +/// 上游包元数据里的版本必须与声明一致:上游换版本而声明未同步时,构建期立即失败。 +fn validate_upstream_package_version( + package_dir: &Path, + metadata_file_name: &str, + expected: &str, + label: &str, +) -> Result<(), String> { + let metadata_path = package_dir.join(metadata_file_name); + let raw = std::fs::read_to_string(&metadata_path) + .map_err(|error| format!("{label} 元数据不可读 {}:{error}", metadata_path.display()))?; + let metadata: serde_json::Value = serde_json::from_str(&raw).map_err(|error| { + format!( + "{label} 元数据不是合法 JSON {}:{error}", + metadata_path.display() + ) + })?; + let version = metadata["version"].as_str().unwrap_or_default(); + if version != expected { + return Err(format!( + "{label} 版本与声明不一致:{version}(期望 {expected},{});请同步更新 build_support/package-layout.json", + metadata_path.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, 2); + 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::>(); + // 期望值与实现同形:实现是「根目录 join 整条声明的相对路径」,路径内部的 `/` 不会被 + // Windows 改写;按组件逐段 join 会在 Windows 上产出纯反斜杠,让该用例必红。 + let expected = |root: &str, nested: bool| { + let relative = if nested { + "node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin" + } else { + "node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin" + }; + Path::new(root).join(relative).to_string_lossy().to_string() + }; + assert_eq!( + rendered, + [ + expected("/app", false), + expected("/app", true), + expected("/repo", false), + expected("/repo", true), + ] + ); + 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")); + } + + #[test] + fn plugin_subdirectories_declare_their_origin() { + let source = PLUGINS + .subdirectories + .iter() + .filter(|entry| subdirectory_is_source_derived(entry)) + .map(|entry| entry.path) + .collect::>(); + let prepared = PLUGINS + .subdirectories + .iter() + .filter(|entry| subdirectory_is_prepared(entry)) + .map(|entry| entry.path) + .collect::>(); + assert_eq!(source, ["src", "panels", "skills"]); + assert_eq!(prepared, ["native/payload", "dotnet/publish/win-x64"]); + } + + #[test] + fn package_metadata_expectations_come_from_the_declaration() { + assert_eq!(CODEX.package_metadata.layout_version, 1); + assert_eq!(CODEX.package_metadata.resources_dir, "codex-resources"); + assert_eq!(CODEX.package_metadata.path_dir, "codex-path"); + } + + /// 源码与随包目录各写一份插件夹具:随包侧缺测试文件、带一个构建期产物目录。 + fn write_plugin_fixture(source_root: &Path, destination_root: &Path) { + let plugin_source = source_root.join("agc-demo-editor"); + fs::create_dir_all(plugin_source.join("src")).expect("create src"); + fs::write( + plugin_source.join("plugin.json"), + "{\"name\":\"agc-demo-editor\"}\n", + ) + .expect("write manifest"); + fs::write(plugin_source.join("src/entry.mjs"), "export const a = 1;\n") + .expect("write entry"); + fs::write(plugin_source.join("src/entry.test.mjs"), "test\n").expect("write test file"); + fs::create_dir_all(plugin_source.join("native/gdextension")).expect("create gdextension"); + + let staged = destination_root.join("agc-demo-editor"); + fs::create_dir_all(staged.join("src")).expect("create staged src"); + fs::write( + staged.join("plugin.json"), + "{\"name\":\"agc-demo-editor\"}\n", + ) + .expect("write staged manifest"); + fs::write(staged.join("src/entry.mjs"), "export const a = 1;\n") + .expect("write staged entry"); + } + + /// 在源码与随包目录各写一份同名同内容的插件目录(随包侧即准备步骤的复制结果)。 + fn write_plugin_copy( + source_root: &Path, + destination_root: &Path, + plugin_name: &str, + relative_files: &[&str], + ) { + for root in [source_root, destination_root] { + let plugin = root.join(plugin_name); + fs::create_dir_all(&plugin).expect("create plugin"); + fs::write( + plugin.join("plugin.json"), + format!("{{\"name\":\"{plugin_name}\"}}\n"), + ) + .expect("write manifest"); + for relative in relative_files { + let path = plugin.join(declared_relative_path(relative)); + fs::create_dir_all(path.parent().expect("path parent")).expect("create parent"); + fs::write(&path, format!("{relative}\n")).expect("write file"); + } + } + } + + #[test] + fn staged_plugins_are_checked_against_repository_sources() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + write_plugin_fixture(&source_root, &destination_root); + validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect("valid plugin workspace"); + + let entry = destination_root.join("agc-demo-editor/src/entry.mjs"); + fs::write(&entry, "tampered\n").expect("tamper"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("must reject digest drift"); + assert!(error.contains("不一致"), "{error}"); + + fs::write(&entry, "export const a = 1;\n").expect("restore"); + fs::remove_file(&entry).expect("remove"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("must reject missing file"); + assert!(error.contains("缺少文件"), "{error}"); + + fs::write(&entry, "export const a = 1;\n").expect("restore"); + fs::remove_file(destination_root.join("agc-demo-editor/plugin.json")) + .expect("remove manifest"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("must reject missing manifest"); + assert!(error.contains("缺少清单"), "{error}"); + + validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-unknown-linux-gnu", + |_| true, + ) + .expect("不支持的目标直接放行"); + } + + #[cfg(unix)] + #[test] + fn staged_plugins_reject_symlinks() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + write_plugin_fixture(&source_root, &destination_root); + symlink( + "entry.mjs", + destination_root.join("agc-demo-editor/src/link.mjs"), + ) + .expect("symlink"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("must reject symlink"); + assert!(error.contains("符号链接"), "{error}"); + } + + /// 随包目录里出现源码侧不存在的文件(源码子目录残留、插件根目录未知文件、未声明的根条目)必须被拒绝。 + #[test] + fn staged_plugins_reject_undeclared_files() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + write_plugin_fixture(&source_root, &destination_root); + + let leftover = destination_root.join("agc-demo-editor/src/leftover.mjs"); + fs::write(&leftover, "stale\n").expect("write leftover"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("必须拒绝源码子目录里的残留文件"); + assert!(error.contains("未声明文件"), "{error}"); + assert!(error.contains("leftover.mjs"), "{error}"); + fs::remove_file(&leftover).expect("remove leftover"); + + let root_file = destination_root.join("agc-demo-editor/README.md"); + fs::write(&root_file, "stale\n").expect("write plugin root file"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("必须拒绝插件根目录的未知文件"); + assert!(error.contains("未声明文件"), "{error}"); + fs::remove_file(&root_file).expect("remove plugin root file"); + + let stray = destination_root.join("stray.txt"); + fs::write(&stray, "stale\n").expect("write stray entry"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("必须拒绝未声明的根条目"); + assert!(error.contains("未声明条目"), "{error}"); + } + + /// 源码侧删掉文件后,随包目录里的旧副本属于未声明文件(随包目录不接受比源码多出的内容)。 + #[test] + fn staged_plugins_reject_files_removed_from_sources() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + write_plugin_fixture(&source_root, &destination_root); + fs::remove_file(source_root.join("agc-demo-editor/src/entry.mjs")).expect("remove source"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("源码删除后的随包副本必须被拒绝"); + assert!(error.contains("未声明文件"), "{error}"); + assert!(error.contains("entry.mjs"), "{error}"); + } + + /// Cocos 的 `native/payload` 由构建产出:只在 windows 且 feature 开启时随包,其余组合下其产物必须被拒绝。 + #[test] + fn staged_plugins_reject_prepared_artifacts_of_disabled_features() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + write_plugin_copy( + &source_root, + &destination_root, + "agc-cocos-editor", + &["src/entry.mjs", "native/payload/cocos-editor-bridge.dll"], + ); + validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |name| name == "cocos-editor-injection", + ) + .expect("feature 开启时 prepared 产物合法"); + + let error = validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |_| false, + ) + .expect_err("feature 关闭后必须拒绝 prepared 产物"); + assert!(error.contains("未声明文件"), "{error}"); + assert!(error.contains("cocos-editor-bridge.dll"), "{error}"); + + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("目标不含 windows 时必须拒绝 prepared 产物"); + assert!(error.contains("未声明文件"), "{error}"); + } + + /// 随包库只归位声明里的文件:源码工作区的构建输入不进随包目录,随包目录多出或缺少文件都必须被拒绝。 + #[test] + fn staged_plugins_follow_declared_library_staging_files() { + let staging = PLUGINS + .library_staging + .iter() + .find(|entry| entry.layout == "godot-bundle") + .expect("godot staging"); + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + let mut relative_files = vec!["src/entry.mjs".to_string()]; + relative_files.extend( + staging + .files + .iter() + .map(|file| format!("{}/{}", staging.source_subdirectory, file)), + ); + let relative_files = relative_files + .iter() + .map(String::as_str) + .collect::>(); + write_plugin_copy( + &source_root, + &destination_root, + "agc-godot-editor", + &relative_files, + ); + let source_only = source_root.join("agc-godot-editor/native/gdextension/src/native.cpp"); + fs::create_dir_all(source_only.parent().expect("path parent")).expect("create source dir"); + fs::write(&source_only, "void build_input() {}\n").expect("write source-only file"); + + validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |name| name == "godot-editor-execute", + ) + .expect("声明文件齐备时随包库合法"); + + let stale = + destination_root.join("agc-godot-editor/native/gdextension/bin/win-x64/stale.dll"); + fs::write(&stale, "stale\n").expect("write stale library file"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |name| name == "godot-editor-execute", + ) + .expect_err("必须拒绝随包库里未声明的文件"); + assert!(error.contains("未声明文件"), "{error}"); + fs::remove_file(&stale).expect("remove stale library file"); + + let declared = destination_root + .join("agc-godot-editor") + .join(declared_relative_path(&format!( + "{}/{}", + staging.source_subdirectory, staging.files[0] + ))); + fs::remove_file(&declared).expect("remove declared library file"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |name| name == "godot-editor-execute", + ) + .expect_err("必须拒绝缺少声明文件的随包库"); + assert!(error.contains("缺少声明文件"), "{error}"); + + let error = validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |_| false, + ) + .expect_err("feature 关闭后必须拒绝随包库"); + assert!(error.contains("未声明文件"), "{error}"); + } + + #[test] + fn collect_sources_applies_declared_skip_rules() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path(); + fs::create_dir_all(root.join("target")).expect("create target"); + fs::create_dir_all(root.join("node_modules")).expect("create node_modules"); + fs::create_dir_all(root.join("src")).expect("create src"); + fs::write(root.join("target/junk.rs"), "junk\n").expect("write junk"); + fs::write(root.join("node_modules/pkg.js"), "pkg\n").expect("write pkg"); + fs::write(root.join("src/entry.mjs"), "entry\n").expect("write entry"); + fs::write(root.join("src/entry.test.mjs"), "test\n").expect("write test"); + fs::write(root.join(".env"), "secret\n").expect("write env"); + let files = collect_sources(root).expect("collect"); + assert_eq!(files.into_iter().collect::>(), ["src/entry.mjs"]); + } + + #[test] + fn declaration_pins_claude_agent_layout() { + assert_eq!(CLAUDE_AGENT_SDK_VERSION, "0.3.285"); + assert_eq!(CLAUDE_AGENT.version, "0.3.285"); + assert_eq!(CLAUDE_AGENT.resource_directory, "resources/claude-agent"); + assert_eq!(CLAUDE_AGENT.entry_file_name, "index.mjs"); + assert_eq!( + CLAUDE_AGENT.entry_relative_path, + "agent-sidecar/src/index.mjs" + ); + assert_eq!( + CLAUDE_AGENT.sdk_package_name, + "@anthropic-ai/claude-agent-sdk" + ); + assert_eq!(CLAUDE_AGENT.sdk_entry_file_name, "sdk.mjs"); + assert_eq!(CLAUDE_AGENT.targets.len(), 3); + let windows = claude_agent_target("x86_64-pc-windows-msvc").expect("windows sidecar"); + assert_eq!(windows.runtime_package, "claude-agent-sdk-win32-x64"); + assert_eq!(windows.runtime_file_name, "claude.exe"); + let mac = claude_agent_target("aarch64-apple-darwin").expect("mac sidecar"); + assert_eq!(mac.runtime_package, "claude-agent-sdk-darwin-arm64"); + assert_eq!(mac.runtime_file_name, "claude"); + assert!(claude_agent_target("x86_64-unknown-linux-gnu").is_none()); + } + + fn write_package_version(directory: &Path, metadata_file_name: &str, version: &str) { + fs::write( + directory.join(metadata_file_name), + serde_json::json!({ "version": version }).to_string(), + ) + .expect("write package metadata"); + } + + /// 写一份最小的 Claude Agent SDK sidecar 随包产物(入口 + SDK + 平台原生运行时)。 + fn write_claude_agent_bundle(target_dir: &Path, app_root: &Path, target: &str) { + let declared = claude_agent_target(target).expect("claude agent layout"); + let entry_source = app_root.join(declared_relative_path(CLAUDE_AGENT.entry_relative_path)); + fs::create_dir_all(entry_source.parent().expect("entry parent")).expect("create entry dir"); + fs::write(&entry_source, "console.log('sidecar');\n").expect("write entry source"); + fs::create_dir_all(target_dir).expect("create target dir"); + fs::write( + target_dir.join(CLAUDE_AGENT.entry_file_name), + "console.log('sidecar');\n", + ) + .expect("write staged entry"); + + let sdk = target_dir + .join(declared_relative_path(CLAUDE_AGENT.node_modules_directory)) + .join(declared_relative_path(CLAUDE_AGENT.sdk_package_name)); + fs::create_dir_all(&sdk).expect("create sdk package"); + fs::write( + sdk.join(CLAUDE_AGENT.sdk_entry_file_name), + "export const sdk = 1;\n", + ) + .expect("write sdk entry"); + write_package_version( + &sdk, + CLAUDE_AGENT.sdk_package_metadata_file_name, + CLAUDE_AGENT.version, + ); + + let runtime = target_dir + .join(declared_relative_path(CLAUDE_AGENT.node_modules_directory)) + .join(declared_relative_path(package_scope( + CLAUDE_AGENT.sdk_package_name, + ))) + .join(declared_relative_path(declared.runtime_package)); + fs::create_dir_all(&runtime).expect("create runtime package"); + let runtime_file = runtime.join(declared_relative_path(declared.runtime_file_name)); + fs::write(&runtime_file, "runtime\n").expect("write runtime"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&runtime_file, fs::Permissions::from_mode(0o755)) + .expect("chmod runtime"); + } + write_package_version( + &runtime, + CLAUDE_AGENT.sdk_package_metadata_file_name, + CLAUDE_AGENT.version, + ); + } + + #[test] + fn staged_claude_agent_bundle_is_checked_against_repository_sources() { + let temp = tempfile::tempdir().expect("tempdir"); + let target_dir = temp.path().join("resources/claude-agent"); + let app_root = temp.path().join("app"); + write_claude_agent_bundle(&target_dir, &app_root, "aarch64-apple-darwin"); + validate_staged_claude_agent(&target_dir, &app_root, "aarch64-apple-darwin") + .expect("合规目录通过"); + + fs::write( + target_dir.join(CLAUDE_AGENT.entry_file_name), + "console.log('drift');\n", + ) + .expect("write drifted entry"); + let error = validate_staged_claude_agent(&target_dir, &app_root, "aarch64-apple-darwin") + .expect_err("入口漂移必须被拒绝"); + assert!(error.contains("与仓库源码不一致"), "{error}"); + + write_claude_agent_bundle(&target_dir, &app_root, "aarch64-apple-darwin"); + fs::write(target_dir.join("stray.mjs"), "stray\n").expect("write stray"); + let error = validate_staged_claude_agent(&target_dir, &app_root, "aarch64-apple-darwin") + .expect_err("白名单外的文件必须被拒绝"); + assert!(error.contains("白名单外"), "{error}"); + } + + #[test] + fn staged_claude_agent_rejects_missing_sdk_entry_and_version_drift() { + let temp = tempfile::tempdir().expect("tempdir"); + let target_dir = temp.path().join("resources/claude-agent"); + let app_root = temp.path().join("app"); + write_claude_agent_bundle(&target_dir, &app_root, "x86_64-pc-windows-msvc"); + let sdk = target_dir + .join(declared_relative_path(CLAUDE_AGENT.node_modules_directory)) + .join(declared_relative_path(CLAUDE_AGENT.sdk_package_name)); + fs::remove_file(sdk.join(CLAUDE_AGENT.sdk_entry_file_name)).expect("remove sdk entry"); + let error = validate_staged_claude_agent(&target_dir, &app_root, "x86_64-pc-windows-msvc") + .expect_err("缺 SDK 入口必须被拒绝"); + assert!(error.contains("缺少 SDK 入口"), "{error}"); + + write_claude_agent_bundle(&target_dir, &app_root, "x86_64-pc-windows-msvc"); + write_package_version(&sdk, CLAUDE_AGENT.sdk_package_metadata_file_name, "0.0.0"); + let error = validate_staged_claude_agent(&target_dir, &app_root, "x86_64-pc-windows-msvc") + .expect_err("版本漂移必须被拒绝"); + assert!(error.contains("版本与声明不一致"), "{error}"); + } + + #[test] + fn claude_agent_layout_rejects_undeclared_target() { + let error = validate_staged_claude_agent( + Path::new("missing"), + Path::new("."), + "x86_64-unknown-linux-gnu", + ) + .expect_err("未声明目标必须被拒绝"); + assert!(error.contains("声明不含目标"), "{error}"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitignore b/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitignore deleted file mode 100644 index 6a7461313..000000000 --- a/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.dll diff --git a/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitkeep b/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index a6ad51e1f..4dee3f105 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -304,7 +304,8 @@ pub(crate) fn game_creator_claude_code_cli_version_identity() -> Result testEndpoint; const runTauriDev = ( argv: string[], options: Parameters[1], -) => runTauriDevImpl(argv, { prepareFrontend: async () => {}, ...options }); +) => + runTauriDevImpl(argv, { + prepareFrontend: async () => {}, + // 随包资源准备会读取真实上游包与仓库插件工作区;需要断言的用例自行注入。 + prepareResources: () => {}, + ...options, + }); async function waitForFile(path: string, timeoutMs = 5000) { const deadline = Date.now() + timeoutMs; @@ -152,6 +164,10 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { prepareFrontend: async () => { order.push('frontend-ready'); }, + prepareResources: (features) => { + expect(Array.isArray(features)).toBe(true); + order.push('resources'); + }, spawnCli: () => { order.push('spawn'); return child; @@ -170,6 +186,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { expect(result).toBe(1); expect(order).toEqual([ 'preflight', + 'resources', 'frontend-ready', 'spawn', 'exit', @@ -300,3 +317,311 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { } }); }); + +describe('AI 游戏创作 Tauri dev 进程环境', () => { + const posixTest = process.platform === 'win32' ? test.skip : test; + + // 本机 `~/.cargo/config.toml` 或仓库级 Cargo 配置里的 sccache wrapper 只有在环境变量 + // 非空时才会被覆盖;这里必须显式写入要交给 Tauri Cargo 的 wrapper 决策结果。 + posixTest('本地 dev 不把 sccache 交给 Tauri Cargo', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const env = buildTauriDevProcessEnv(testEndpoint, { + RUSTC_WRAPPER: 'sccache', + CARGO_BUILD_RUSTC_WRAPPER: 'sccache', + }); + + expect(env.RUSTC_WRAPPER).toBe('/usr/bin/env'); + expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('/usr/bin/env'); + } finally { + warn.mockRestore(); + } + }); + + posixTest('未显式配置 wrapper 时清空两个变量', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const env = buildTauriDevProcessEnv(testEndpoint, { + CARGO_TERM_COLOR: 'never', + }); + + expect(env.RUSTC_WRAPPER).toBe(''); + expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(''); + expect(env.CARGO_TERM_COLOR).toBe('never'); + } finally { + warn.mockRestore(); + } + }); + + posixTest('保留显式自定义 wrapper 且不改写调用方 env', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const input = { RUSTC_WRAPPER: '/opt/custom/rustc-wrapper' }; + const env = buildTauriDevProcessEnv(testEndpoint, input); + + expect(env.RUSTC_WRAPPER).toBe('/opt/custom/rustc-wrapper'); + expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('/opt/custom/rustc-wrapper'); + expect(env.GENARRATIVE_AGC_VITE_PORT).toBe(String(testEndpoint.port)); + expect(input).toEqual({ RUSTC_WRAPPER: '/opt/custom/rustc-wrapper' }); + } finally { + warn.mockRestore(); + } + }); +}); + +describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => { + const windowsFeatures = [ + 'cocos-editor-execute', + 'unity-editor-execute', + 'godot-editor-execute', + ]; + // buildTauriArguments 注入的动态 devUrl 配置:最终交给 Tauri 的参数里长这样。 + const devConfig = + '{"build":{"devUrl":"http://127.0.0.1:10005/","beforeDevCommand":""}}'; + + test('显式 --target 决定 staging 目标,未指定或属于应用参数时回落宿主目标', () => { + const calls: [string, string[]][] = []; + const hostTarget = 'aarch64-apple-darwin'; + const prepare = (options: { + target: string; + features: Iterable; + }) => { + calls.push([options.target, [...options.features]]); + return []; + }; + + for (const argv of [ + ['dev', '--config', devConfig], + ['dev', '--target', 'x86_64-apple-darwin', '--config', devConfig], + ['dev', '--target=x86_64-pc-windows-msvc', '--config', devConfig], + // 启动器把 `--` 之后的参数交给应用(补成第二段分隔符):不能拿它改随包资源目标。 + [ + 'dev', + '--config', + devConfig, + '--', + '--', + '--target=x86_64-pc-windows-msvc', + ], + ]) { + prepareBundledResourcesBeforeTauri(windowsFeatures, argv, { + hostTarget, + prepare, + log: () => {}, + }); + } + + expect(calls.map(([target]) => target)).toEqual([ + hostTarget, + 'x86_64-apple-darwin', + 'x86_64-pc-windows-msvc', + hostTarget, + ]); + expect(calls[0][1]).toEqual(windowsFeatures); + }); + + test('宿主平台不受声明覆盖时跳过准备,且不为其新增随包支持', () => { + const prepare = vi.fn(); + const log = vi.fn(); + + prepareBundledResourcesBeforeTauri( + windowsFeatures, + ['dev', '--config', devConfig], + { hostTarget: null, prepare, log }, + ); + + expect(prepare).not.toHaveBeenCalled(); + expect(log.mock.calls.flat().join('\n')).toContain('跳过随包资源准备'); + }); + + async function runCapturingDev(argv: string[]) { + const prepared: unknown[][] = []; + const spawned: string[][] = []; + const child = Object.assign(new EventEmitter(), { + pid: 1234, + exitCode: 0, + signalCode: null, + kill: vi.fn(), + }); + const result = await runTauriDev(argv, { + resolveDevEndpoint: resolveTestEndpoint, + preflight: async () => {}, + prepareResources: (...args: unknown[]) => { + prepared.push(args); + }, + spawnCli: (args: string[]) => { + spawned.push(args); + return child; + }, + waitForCli: async () => ({ type: 'exit', code: 0, signal: null }), + terminateTree: async () => ({ stopped: true, forced: false }), + }); + return { result, prepared, spawned }; + } + + test('显式 -f 优先于环境变量,并同时决定随包资源 feature 与 cargo 参数', async () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', 'template-library-fixtures'); + try { + const { result, prepared, spawned } = await runCapturingDev([ + '-f', + 'custom-feature', + ]); + + expect(result).toBe(0); + expect(prepared.map(([features]) => features)).toEqual([ + ['custom-feature'], + ]); + expect(spawned[0].slice(0, 3)).toEqual(['dev', '-f', 'custom-feature']); + expect(spawned[0]).not.toContain( + `--features=${windowsFeatures.join(',')}`, + ); + // 随包资源准备拿到的是最终交给 Tauri 的参数,不是启动器收到的原始参数。 + expect(prepared[0][1]).toEqual(spawned[0]); + } finally { + vi.unstubAllEnvs(); + } + }); + + test('显式 --target 同时进入随包资源准备与 Tauri 参数', async () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', ''); + try { + const { result, prepared, spawned } = await runCapturingDev([ + '--target', + 'x86_64-apple-darwin', + ]); + + expect(result).toBe(0); + expect(prepared.map(([features]) => features)).toEqual([[]]); + expect(prepared[0][1]).toEqual([ + 'dev', + '--target', + 'x86_64-apple-darwin', + '--config', + devConfig, + ]); + expect(spawned[0]).toEqual(prepared[0][1]); + } finally { + vi.unstubAllEnvs(); + } + }); + + test('启动器 `--` 之后的应用参数不参与 staging feature 解析', async () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', ''); + try { + const { result, prepared, spawned } = await runCapturingDev([ + '--', + '--features=app-tool', + ]); + + expect(result).toBe(0); + expect(prepared.map(([features]) => features)).toEqual([[]]); + expect(spawned[0]).toEqual([ + 'dev', + '--config', + devConfig, + '--', + '--', + '--features=app-tool', + ]); + expect(prepared[0][1]).toEqual(spawned[0]); + } finally { + vi.unstubAllEnvs(); + } + }); + + test('空串 AGC_DEV_CARGO_FEATURES 关闭默认 feature,staging 与 cargo 都不带 feature', async () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', ''); + try { + const { result, prepared, spawned } = await runCapturingDev([]); + + expect(result).toBe(0); + expect(prepared.map(([features]) => features)).toEqual([[]]); + expect( + spawned[0].some((argument) => argument.startsWith('--features')), + ).toBe(false); + } finally { + vi.unstubAllEnvs(); + } + }); + + test('环境变量只在不与显式 CLI 冲突时生效', () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', 'template-library-fixtures'); + try { + expect( + resolveEditorFeatures({ + argv: ['dev', '--config', devConfig], + target: 'win32', + }), + ).toEqual(['template-library-fixtures']); + expect( + resolveEditorFeatures({ + argv: ['dev', '-f', 'cli-feature', '--config', devConfig], + target: 'win32', + }), + ).toEqual(['cli-feature']); + } finally { + vi.unstubAllEnvs(); + } + }); +}); + +describe('AI 游戏创作 Tauri dev 目标与 feature 解析', () => { + test('目标只从构建参数区解析,取值缺失、取到选项或重复都失败关闭', () => { + expect(readCargoTarget([])).toBeUndefined(); + expect(readCargoTarget(['--no-watch'])).toBeUndefined(); + expect(readCargoTarget(['-t', 'x86_64-apple-darwin'])).toBe( + 'x86_64-apple-darwin', + ); + expect(readCargoTarget(['--target=x86_64-apple-darwin'])).toBe( + 'x86_64-apple-darwin', + ); + // runner 参数由 Tauri 追加给 cargo:仍然算构建目标。 + expect( + readCargoTarget(['build', '--', '--target=aarch64-apple-darwin']), + ).toBe('aarch64-apple-darwin'); + // 第二个 `--` 之后是应用参数。 + expect( + readCargoTarget(['dev', '--', '--', '--target=aarch64-apple-darwin']), + ).toBeUndefined(); + expect(() => readCargoTarget(['--target'])).toThrow('缺少有效目标'); + expect(() => readCargoTarget(['--target', '--no-watch'])).toThrow( + '缺少有效目标', + ); + expect(() => readCargoTarget(['--target', 'a', '-t', 'b'])).toThrow( + '不能重复指定', + ); + }); + + test('feature 解析覆盖 -f 拼写、合并多个 flag 并止步于应用参数', () => { + const cases: [string[], string[]][] = [ + [ + ['-f', 'a,b'], + ['a', 'b'], + ], + [['-fa'], ['a']], + [['--features=explicit'], ['explicit']], + // Cargo 会合并多个 feature flag:staging 必须拿到同一集合。 + [ + ['--features', 'a', '-f', 'b'], + ['a', 'b'], + ], + // 显式给空集合即关闭默认,而不是回落默认值。 + [['--features='], []], + // 第二个 `--` 之后是应用参数。 + [ + ['dev', '--config', '{}', '--', '--', '-f', 'app-feature'], + [ + 'cocos-editor-execute', + 'unity-editor-execute', + 'godot-editor-execute', + ], + ], + ]; + + for (const [argv, expected] of cases) { + expect(resolveEditorFeatures({ argv, target: 'win32', env: {} })).toEqual( + expected, + ); + } + }); +}); diff --git a/docs/README.md b/docs/README.md index cc3979acf..cca80fe09 100644 --- a/docs/README.md +++ b/docs/README.md @@ -38,6 +38,7 @@ - [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。 - [AGC 后端框架整理与演进路线](./technical/【技术方案】AGC后端框架整理与演进路线-2026-09-18.md):共享 Runtime、本地执行宿主、云端控制面、领域/平台适配器及分阶段收口边界。 +- [AGC 随包资源 staging 归位](./technical/【技术方案】AGC随包资源staging归位-2026-09-26.md):随包资源改由准备步骤在 `tauri dev|build` 之前一次性生成、`build.rs` 退化为校验者;含缓存与原子性合同、入口接线、验收判据与里程碑拆分。 - [AGC 异步操作可恢复闭环](./【技术方案】AGC异步操作可恢复闭环-2026-09-14.md):认证响应体、最近项目检查和首页自动创建的超时、逐项恢复与跨页防重合同。 - [AGC 客户端稳定版生命周期大切换](./【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md):统一 operation、认证/Runner、项目入口、本地恢复和 dev-stack 身份边界。 - [策划会话 Runtime V2 接入与旧链路退役方案](./technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md):历史方案,仅用于追溯 V2 的实现与退役过程,不作为当前实现依据。 diff --git a/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md b/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md new file mode 100644 index 000000000..a200761ec --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md @@ -0,0 +1,64 @@ +# 【实施计划】AGC 随包资源改由校验器读入 + +| 字段 | 值 | +| --------- | --------------------------------------------------------------- | +| Milestone | `docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md` | +| Status | ready(待里程碑规范评审通过后开工) | +| Owner | suzmii / Agent | + +## 修改边界 + +允许修改: + +- `apps/ai-game-creator-shell/src-tauri/build.rs`:新增只读校验调用点;本里程碑内保持现有写入分支不变(不改变既有构建行为)。 +- `apps/ai-game-creator-shell/src-tauri/build_support/**`:把平台布局、组件白名单、摘要校验整理为可被构建脚本之外的独立工具复用的一处声明。 +- 新增随包资源准备工具及其测试(位置见「待确认决策」)。 +- 需要时扩展 `apps/ai-game-creator-shell/scripts/check-config.mjs` 的断言。 +- 文档:主规范未决问题收口、开发运维文档对应段落。 + +明确不修改: + +- 三份 tauri 配置的 `resources` 映射、包内路径与安装包形态。 +- 运行时资源解析与完整性校验(`codex_cli.rs`、`plugin_host.rs`、`editor_adapters.rs`、`environment_check.rs`)。 +- 发布脚本流程、版本号机制、签名与上传。 +- dev 启动器与发布入口的接线(下一里程碑)。 +- 编辑器分支产物(Unity/Godot/Cocos)的生成方式(最后一个里程碑)。 + +## 实现顺序 + +1. **共用能力可复用**:确认 `build_support` 内的平台布局与组件白名单能被独立工具引用(现状先例:`src/agent/codex_cli.rs` 与 `main.rs` 已通过 `#[path]` 复用同一模块),把「布局 + 白名单 + 摘要校验」收敛为单一入口,避免准备工具另写一份清单。 +2. **准备工具骨架**:目标目录与清单写出、缓存 key(上游 lockfile 的 `resolved` + `integrity` + 布局版本 + 目标三元)、临时目录 + 原子替换、所有权与符号链接校验、并发串行化、单行汇总日志。先实现纯复制两条路径(随包组件、插件工作区),编辑器分支产物本轮仍由构建脚本生成。 +3. **幂等与失败关闭**:重复执行不改变内容与时间戳;上游缺失、摘要不匹配、目录被非本工具占用、目标平台不支持四类场景各自失败并给出可定位原因。 +4. **校验路径上线**:构建脚本在既有产物上执行只读校验(默认不影响现有写入行为),校验失败以明确原因中止。 +5. **测试与证据**:按里程碑「证据要求」补齐用例与运行记录。 + +## 验证命令 + +1. 声明唯一性与门禁:`npm run agc:bundled-resources:check`(已进 `agc:typecheck` 链),不一致时用 `npm run agc:bundled-resources:sync` 重新生成。 +2. 准备工具用例(含幂等与失败关闭):`npm run agc:bundled-resources:test`。 +3. 校验路径独立运行(跳过写入分支):`AGC_SKIP_RESOURCE_STAGING=1 cargo check --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`。 +4. Rust 用例:`cargo test --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml package_layout` 与 `cargo test --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml codex_bundle`。 +5. 幂等(真实工作区):连续两次 `node apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`,第二次必须全部「命中缓存」,且两次之后的目录快照(相对路径、大小、mtime、sha256)完全一致。 +6. 并存一致:准备步骤产物与构建脚本产物逐文件比对(相对路径、大小、sha256)一致。 +7. 行为不回归:`cargo build --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-default-features`(本里程碑不承诺构建变快,仅确认行为与改造前一致,并记录当前构建耗时作为后续里程碑基线)。 +8. 门禁:`node apps/ai-game-creator-shell/scripts/check-config.mjs`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`,以及改动范围内相关 vitest/Rust 测试。 + +## 风险与回滚点 + +| 风险 | 影响 | 处理 | +| --- | --- | --- | +| 校验器误判把构建卡死 | 影响所有本机构建 | 只读校验先以「不影响写入行为」的方式接入;出现误判可先关闭校验调用点回滚 | +| 准备工具与构建脚本并存产生双写 | 两处结果漂移、时间戳变化 | 并存期以「准备工具产物 == 构建脚本产物」逐文件比对作为过渡判据;不一致视为失败 | +| 缓存 key 漏掉上游变化 | 静默用旧组件 | key 含 lockfile `resolved` + `integrity` + 布局版本 + 三元;清单校验作为第二道闸 | +| 准备工具实现形态选错 | 返工 | 见「待确认决策」,评审时一次定清 | + +回滚点:本里程碑不改变既有构建行为,回滚只需移除校验调用点与准备工具,不影响产物与发布流程。 + +## 已定决策 + +准备工具的实现形态(主规范未决问题 1)**已定为混合**(2026-09-27,机制见主规范 §4.8): + +- 上游获取、`integrity` 校验、归档安全与原子替换复用 Node 侧既有范式(`scripts/stage-node-runtime.mjs`、`scripts/prepare-macos-codex.mjs`); +- 平台布局、组件白名单与逐文件摘要校验复用 Rust 侧既有声明(`build_support/codex_bundle.rs`、`build_support/godot_bundle.rs`),由准备工具与校验路径共用同一份声明文件承载,不再各写一份清单。(上游原生包元数据的期望值后来并入同一份声明;`build_support/codex_package_metadata.rs` 已在 M2 因失去调用方删除。) + +理由:避免出现第二份组件白名单,同时不必重写 registry 下载、`integrity` 与 tar 安全校验;缺点是声明需要经过一次生成步骤才能在 Rust 侧使用,由 `check-package-layout.mjs` 门禁保证两者一致。 diff --git a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md new file mode 100644 index 000000000..9048bfd04 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md @@ -0,0 +1,115 @@ +# 【里程碑】AGC 编辑器分支产物归位与症状层补丁清理 + +| 字段 | 值 | +| ----------- | --------------------------------------------------------------- | +| Version | 1.0 | +| Status | in-progress(2026-09-28 已过 Windows 真机核心评审;2026-09-29 完成打包一致性验收,客户端加载待有编辑器环境的机器;Cocos payload feature 集差异待裁决) | +| Date | 2026-09-26 | +| Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | + +## 已实现(2026-09-27) + +- 声明新增三类「准备步骤」产物:`subdirectories[origin=prepared]`(Unity publish 目录)、`libraryStaging[].prepare + files`(Godot gdextension)、`nativePayloads[]`(Cocos bridge dll);`plugins.prepareSteps` 描述每个准备步骤的程序、工作目录、指纹与必需产物。 +- 准备步骤(`scripts/prepare-bundled-resources.mjs`)按声明执行 `powershell.exe -File build.ps1` 与 `cargo build -p … --target …`,用内容指纹跳过未变化的步骤,校验必需产物齐全后才复制;命中指纹且产物齐全时零写入。 +- 构建脚本删除了三处产物生成与整棵树复制(`prepare_unity_editor_helper`、`prepare_godot_editor_extension`、`stage_cocos_editor_payload`、`stage_build_generated_plugin_payloads` 及其辅助函数,共减少约 220 行),只保留只读校验:源码派生内容逐文件比对、已准备产物存在性、Godot 随包库沿用既有深度校验(`godot_bundle::validate`)。Godot 随包文件清单改由声明提供(单一来源)。 +- `.taurignore` 的两份 staging 条目已删除(构建期不再写 `resources/plugins`,无需忽略)。 +- 准备步骤的 Windows 侧命令路径已随系统性审计在真机复跑(powershell/cargo 两条路径、产物归位与幂等均已验证)。 + +## 目标 + +编辑器分支(Unity/Godot/Cocos)的随包产物也由准备步骤生成,构建脚本不再调用外部工具链产出随包资源;此前为绕开自触发问题而加入的症状层补丁与说明全部删除,实现形态与主规范一致。 + +## 范围 + +- 三个编辑器分支产物的生成职责迁出构建脚本,包括需要外部工具链的两条路径。 +- 构建脚本内与资源生成相关的规避手段删除:残留清理逻辑、为幂等而设的辅助常量与判断、开发监听忽略条目中与随包资源相关的部分。 +- 平台与特性开关(哪些平台、哪些特性才需要这些产物)在新形态下保持既有语义。 +- 与发布打包、包内资源门禁、运行时解析的一致性核对。 + +## 不在范围内 + +- 编辑器分支本身的接入协议、宿主能力与运行时行为。 +- 外部工具链版本管理与安装流程(沿用现状)。 +- 随包组件与插件工作区的生成形态(上一里程碑已完成)。 + +## 依赖与前置条件 + +- 前两个里程碑验收通过。 +- Windows 环境具备 Unity/Godot 分支所需的工具链(.NET 与 CMake 等),以便验证产物生成与打包。 + +## 验收标准 + +- [ ] 构建脚本中不再存在向随包资源目录写入的分支,也不再调用产出随包资源的外部工具链。 +- [ ] 三个编辑器分支的随包产物路径、内容摘要、可执行位与迁移前逐项一致,且由准备步骤稳定产出。(2026-09-29 验收:路径集合与源码派生内容、Unity/Godot 产物逐字节一致;Cocos payload 因声明只构建 `windows-injection` 而与迁移前不同,且 MSVC 链接产物本身不可字节复现,见下方验收记录,口径待裁决) +- [ ] 此前为规避自触发而加入的补丁(残留清理、幂等辅助、监听忽略条目)在代码与文档中全部移除,不再有「为了绕开构建问题」的说明。 +- [ ] 平台与特性开关语义不变:不支持的平台不产出这些资源,且不因此失败。 +- [ ] 全量门禁通过,且客户端在具备条件与不具备条件两种环境下都能给出明确结论(可用 / 缺组件及原因)。 + +## 验收记录(2026-09-29,Windows 本机) + +### 打包一致性(验收标准第 2 条) + +准备步骤按发布入口同样的参数复跑(`prepareBundledResources({ target: 'x86_64-pc-windows-msvc', features: Set('unity-editor-execute','godot-editor-execute','cocos-editor-injection'), profile: 'release' })`):`cocos-bridge-build 已构建`、`unity-helper-publish` / `godot-extension-build 命中缓存`、`plugins 重新生成`;随后连续三次复跑,`resources/plugins` 的 32 个文件内容与 mtime 均不再变化(稳定产出)。 + +出包走同一发布入口(`runTauriBuild` + `--bundles nsis`),并临时把 `bundle.createUpdaterArtifacts` 置 false——本机没有 updater 签名私钥,只出安装包;产物 `target/x86_64-pc-windows-msvc/release/bundle/nsis/陶泥儿开发版_0.1.67_x64-setup.exe`(166452206 B,sha256 `04a0be8ab8f54d8f032ce86d3e5c7a99c1dd6e81f71d49b63486827b11a01940`)。`tauri.windows.conf.json` 里 `resources/plugins → plugins` 是目录级映射。 + +包内核对:7z 解包得 2108 个文件,包内 `plugins/` 的 32 个文件与准备步骤产出的 `resources/plugins` **逐文件 sha256 完全一致**;`editor_adapters.rs` 的三个运行时相对路径(`UNITY_ATTACH_HELPER_RELATIVE` / `GODOT_BRIDGE_PAYLOAD_RELATIVE` / `COCOS_BRIDGE_PAYLOAD_RELATIVE`)加上声明里的必需产物共 17 项在包内全部命中。 + +迁移前对照取两份:本机已安装的 2026-09-24 包(`%LOCALAPPDATA%\陶泥儿开发版\plugins`,迁移前产品产物),以及把 `src-tauri` 整体切回合并基线 `8f59c034f` 后在同一个工作树里跑 `cargo build --release --target x86_64-pc-windows-msvc --features=unity-editor-execute,godot-editor-execute,cocos-editor-injection` 得到的 `resources/plugins`。三份对照路径集合一致,源码派生内容(JS/HTML/JSON/license/notice)逐字节一致,Unity helper、Godot 扩展逐字节一致。 + +两处已定性的差异: + +1. **Cocos payload 的构建 feature 集不同(需裁决口径)**:迁移前由同一次应用构建产出(`windows-bootstrap` + `windows-injection`,345088 B);准备步骤按声明只构建 `windows-injection`(26112 B,见 2026-09-27 决策日志条)。两者导出面完全相同(`DllMain`、`cocos_editor_bridge_bootstrap_source`),差掉的是宿主侧 bootstrap 传输(`reqwest`/`tungstenite`/`inspector`),注入进程不使用;但按「内容摘要与迁移前逐项一致」的字面判据不成立。 +2. **MSVC 链接产物不可字节复现**:同一 source / feature / profile / target 连续构建的 payload 摘要不同(除 PE `TimeDateStamp` 外还有 22 字节 RSDS GUID 差异);.NET publish 相反是确定的(Unity helper 跨两次重新发布逐字节一致),Godot 因 `buildId` 早退未重链也保持逐字节一致。因此「摘要一致」只在源码派生物、.NET 产物与命中工具链内部缓存的产物上成立。 + +新发现的风险(本轮未修,不影响上述结论): + +- 准备步骤的 `cocos-bridge-build` 与同一次应用构建写同一个输出路径 `target///deps/cocos_editor_bridge.dll`(两个 feature 单元同名产物),准备步骤的候选查找可能取到另一单元刚写下的文件;本轮实测两者交替后 cargo 会多一次重链。建议让准备步骤在独立 target 目录构建,或与应用的 feature 集对齐后从同一单元取产物。 + +### 客户端编辑器分支(验收标准第 5 条) + +本机未安装 Unity / Godot / Cocos Creator(`Program Files`、`UnityHub`、scoop shims 均无),进入编辑器分支只会停在「未检测到编辑器进程」,拿不到「helper/扩展被加载」的真机结论,因此**本轮未执行**,需在有三种编辑器的机器上补做。 + +已完成的自动化前段(本轮实测,用安装包解出的客户端、不安装): + +- 从 NSIS 包 7z 解出后直接运行 `genarrative-ai-game-creator-shell.exe`:窗口落在 `http://tauri.localhost/`(生产态嵌入前端,不是 `devUrl`),首页正常渲染,最近项目与模板库可见——说明包内 `plugins/`、`skills`、模板资源都被正确读取(对照:用 `cargo build --release` 直接编出来的 exe 没有 `custom-protocol`,会去连 `127.0.0.1:3080` 并落到 chrome 错误页,不能拿它当打包客户端)。 +- CDP 主世界(`page.target().createCDPSession()` + `Runtime.evaluate`)可读只读投影:`list_agc_plugins` 返回 `agc-cocos-editor` / `agc-unity-editor`(`builtin=true`、`hasRuntime=true`、`adapter` 正确),`list_agc_skill_catalog` 返回 8 条 —— 插件的 JS 入口与 Skill 包都按声明进包。 +- `agc-godot-editor` 插件不在该列表里符合现役语义:`plugin_host.rs::plugin_matches_project` 只在当前项目是 Godot 工程(根或一层子目录有 `project.godot`)时才让它可见。 +- 三种编辑器分支的判据入口:`require_plugin_adapter` 缺适配器时报「当前客户端不支持 X 编辑器桥接」,适配器在 payload/helper 缺失时报各自缺组件文案(如 Godot「插件缺少原生 DLL 资源」),编辑器没开时报探测失败——补做时要按这三类分开记录。 + +### 顺带定性:CI 唯一红项与本 PR 无关 + +run 2980(HEAD `0cf536b6`)唯一失败项是 `tests::sessions::background_agent_runtime_can_write_memory_and_project_files`(`src-tauri/src/tests/sessions.rs:405`,第二个 provider follow-up 请求 `recv_timeout(2s)` 超时): + +- 本 PR 对这条路径零改动:`src/` 下只有 `main.rs`(`#[cfg(test)]` 引入 `package_layout` 单测)与 `agent/codex_cli.rs`(去掉 `codex_package_metadata` 测试模块)两处**测试编译期**改动;`sessions.rs` 与 agent runtime 与合并基线逐字节相同。 +- 把 `src-tauri` 整体切回合并基线 `8f59c034f` 后,同一条命令在本机失败在同一断言(`second llm request: Timeout`)。 +- 本机实测第二个 follow-up 请求耗时 **5.18s / 4.87s**(两次),而测试预算 2s:该断言在本机裕量不足。master run 2979(lane 2 shard 3)也因另一条并发用例失败,属同一类时间预算抖动。 +- 结论:既有测试时间预算问题,不在本 PR 内顺手修。 + +## 验证步骤(Windows 侧执行清单) + +准备:切到本里程碑分支,`npm ci`(需装上 `@openai/codex-win32-x64`),确认 `spacetime --version` 与 `server-rs` 锁定版本一致(仅本地 dev 需要)。 + +1. **准备步骤单独跑(不打包)** + - `npm run agc:bundled-resources:prepare -- --target x86_64-pc-windows-msvc` + - 预期:一行汇总日志;`src-tauri/resources/plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe`、`agc-godot-editor/native/gdextension/` 下的扩展在位。 + - Cocos payload 只在 injection 构建下交付(与迁移前一致):加 `--features=cocos-editor-execute,unity-editor-execute,godot-editor-execute,cocos-editor-injection` 再跑一次,确认 `agc-cocos-editor/native/payload/cocos-editor-bridge.dll` 同时出现在插件工作区与随包目录。 + - 再跑一次:预期全部「命中缓存」,且 `resources/**` 的文件时间戳不变。 +2. **构建期不再写随包资源** + - 取 `src-tauri/resources` 全量快照(相对路径/大小/mtime/sha256)→ `touch apps/ai-game-creator-shell/src-tauri/build.rs` → 再 `cargo build` → 两次快照必须逐项一致。 +3. **构建新鲜度**:源码不变时连续两次 `cargo build --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`,第二次应为秒级 `Finished`,且不再出现 `Compiling genarrative-ai-game-creator-shell`。 +4. **打包一致性**:出一次 Windows 安装包,核对包内 `plugins/` 下三种编辑器分支产物的路径与 sha256 与迁移前一致;`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-run` 必须通过(会触发只读校验,所以要先跑过准备步骤)。 +5. **客户端启动**:进入 Unity/Godot/Cocos 编辑器分支各一次,确认对应 helper/扩展被加载,没有「缺少组件」类提示。 +6. **边界(负例)** + - 删掉 `plugins/agc-unity-editor/dotnet/publish/` 且让工具链不可用后打包:准备步骤必须给出明确失败原因(缺工具链/缺产物),而不是静默产出缺组件的包。 + - 删掉 `target/agc-resource-staging.json` 再跑准备步骤:预期重新生成,产物内容不变(丢缓存只多一次哈希)。 + - 删掉 `plugins/agc-unity-editor/dotnet/publish/win-x64/.agc-source.sha256` 后重跑准备步骤:预期重新执行 dotnet publish,而不是复用旧产物。 + - 手工改 `resources/**` 一个字节后 `cargo build`:只读校验必须失败(该规则覆盖 source 派生内容;prepared 产物只查存在性,见排障经验)。 + +记录:把每步命令、关键输出与结论贴回本里程碑或对应 PR;未通过项回到主规范 §9 记为未决问题。 + +## 证据要求 + +- 自动化:编辑器分支产物的摘要对比、平台门槛用例、配置门禁与包内资源门禁。 +- 运行时:Windows 上一次完整打包与一次客户端启动,确认编辑器分支产物被读取。 +- 边界:缺少外部工具链、缺少组件、非目标平台三种情形下的失败与跳过语义。 diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md new file mode 100644 index 000000000..9fb199b27 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md @@ -0,0 +1,51 @@ +# 【里程碑】AGC 随包资源改由校验器读入 + +| 字段 | 值 | +| ----------- | ----------------------------------------------------------- | +| Version | 1.0 | +| Status | completed(2026-09-27) | +| Date | 2026-09-26 | +| Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | + +## 已定决策(2026-09-27) + +- **实现形态:混合**——生成归 Node(`scripts/prepare-bundled-resources.mjs`),声明与校验归 Rust。依据与对比见主规范 §4.8。 +- **单一声明**:`build_support/package-layout.json` 是唯一人工声明;Rust 侧使用由 `scripts/check-package-layout.mjs` 生成的编译期常量(`package-layout.generated.rs`),门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链;运行期 `codex_bundle.rs` 的公开接口与取值不变。 +- **校验收口边界**:本里程碑对随包 Codex 目录做全量校验(清单 schema/平台/版本、文件集合、逐文件摘要、第三方声明、可执行位、白名单外文件);插件随包目录只校验必需组件与符号链接。插件产物的逐文件摘要校验在 M2 由准备步骤写入树内清单后启用——M1 期间构建脚本仍整体重建 `resources/plugins`,树内清单会被清掉。 +- **独立验证入口**:`AGC_SKIP_RESOURCE_STAGING=1` 让构建脚本只跑只读校验、跳过写入分支。 + +## 目标 + +构建脚本不再需要「自己写随包资源」才能成立:在约定目录已有合规资源时,构建只做只读校验并通过;校验失败时给出明确原因并拒绝继续,而不是静默重新生成。 + +## 范围 + +- 随包资源的合规性判定:平台目录存在、清单 schema 与平台一致、逐文件摘要一致、必需组件齐全、版本与上游锁定一致。 +- 资源生成能力的可复用化:同一份布局与摘要校验能力既能被构建期校验使用,也能被准备步骤使用,不得出现第二份组件白名单。 +- 生成结果的稳定性要求:同一输入重复生成时,产物内容与文件时间戳不发生变化。 + +## 不在范围内 + +- 接入 dev 与发布入口(下一里程碑)。 +- 移除构建脚本里的资源写入分支。 +- 编辑器分支产物(Unity/Godot/Cocos)的生成方式与外部工具链调用。 +- 运行时资源解析顺序、完整性校验语义与打包配置里的资源映射。 +- Linux 产物支持。 + +## 依赖与前置条件 + +- 主规范第 4.3 与第 4.4 节的合同(准备步骤合同、构建脚本退化后的职责边界)。 +- 现有随包资源与清单已由当前实现产出,可用于校验回归。 + +## 验收标准 + +- [ ] 资源合规时,校验路径可独立运行并通过,不依赖构建脚本的写入分支。 +- [ ] 上游锁定版本、平台、逐文件摘要、必需组件四类不一致各自被拒绝,并给出可定位的原因。 +- [ ] 重复执行资源生成,产物内容与文件时间戳不变(幂等)。 +- [ ] 同一份布局与组件白名单只有一处声明,构建期校验与准备步骤共用。 + +## 证据要求 + +- 自动化:资源校验的通过/拒绝用例;同输入重复生成后目录快照对比(内容 + 时间戳)。 +- 运行时:本机在既有随包资源上运行一次校验与一次构建,确认资源被正常读取且构建行为与改造前一致。 +- 边界:目标平台不支持、上游缺失、摘要不匹配、目录被非本工具内容占用四种场景各自的失败输出。 diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md new file mode 100644 index 000000000..9486a877f --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md @@ -0,0 +1,48 @@ +# 【里程碑】AGC 随包资源生成接入 dev 与发布入口 + +| 字段 | 值 | +| ----------- | --------------------------------------------------------------- | +| Version | 1.0 | +| Status | in-progress(2026-09-27 起实施) | +| Date | 2026-09-26 | +| Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | + +## 目标 + +随包资源在客户端开发与发布两条链上,都由启动/打包之前的准备步骤一次性生成;构建脚本不再承担生成职责,源码不变时构建不再重复编译。 + +## 范围 + +- 客户端开发的启动流程:在拉起客户端之前完成资源准备,命中缓存时不重写任何文件。 +- 发布打包流程:Windows 与 macOS 两条链在拉起打包工具之前完成资源准备,包括既有的运行时资源准备点。 +- 纯复制型资源(随包组件与插件工作区)的生成职责从构建脚本迁出。 +- 不打包场景(仅校验、不产包)的放行口径。 + +## 不在范围内 + +- 编辑器分支产物(Unity/Godot/Cocos)的生成方式与外部工具链调用(下一里程碑)。 +- 打包配置里的资源映射、包内资源门禁与安装包形态。 +- 运行时资源解析与完整性校验语义。 +- 构建脚本中与资源无关的既有职责(配置能力、提示词产物、元数据)。 + +## 依赖与前置条件 + +- 上一里程碑的验收通过:资源校验可只读通过、生成幂等、白名单唯一。 +- M1 交付的准备步骤与声明门禁已在位:`apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`(Codex 与插件两条纯复制路径,写临时目录后原子替换,命中缓存不重写)与 `npm run agc:bundled-resources:check`(已进 `agc:typecheck` 链)。本里程碑需要让准备步骤改为在 `resources/plugins` 内写入自己的清单,并停止构建脚本对该目录的整体重建,插件产物的逐文件摘要校验才能启用。 +- 开发与发布两条链在拉起客户端/打包工具之前都有明确可插入的准备阶段。 +- Windows 与 macOS 均需具备可验证的开发环境(两个平台各自验收)。 + +## 验收标准 + +- [x] 客户端开发启动一次成功:不再出现因资源变更而触发的重复构建,客户端与运行器进程稳定存活。(证据:清理 `AGC` dev 探针——`tauri dev` 全程 `Rebuilding application` 0 次、`Running DevCommand` 1 次、主 crate 仅编译 1 次,app 起来后持续处理项目;完整 `npm run agc` 在本机被 SpacetimeDB `Pre-publish check`(401 InvalidSignature / 502 Bad Gateway)阻断,属既有本机环境问题。) +- [x] 源码不变时连续两次构建,第二次为秒级完成;构建脚本声明的输入中不再出现随包资源路径。(证据:`cargo build --no-default-features` 连续三次 0.69 / 0.22 / 0.22 秒;强制构建脚本重跑后 `resources/codex` 与 `resources/plugins` 快照逐项不变。) +- [ ] Windows 与 macOS 打包产物中的随包资源,与迁移前逐项一致(路径、内容摘要、可执行位)。(macOS 侧 `check-macos-bundle.mjs` 待打包验证;Windows 待 M3 归位三处构建期产物后复验。) +- [x] 准备步骤连续执行两次不改变产物内容与时间戳;缺少准备步骤时,打包与启动以明确错误失败,而不是静默产出缺组件的包。(证据:准备步骤 10 条用例含幂等、上游缺失、上游元数据漂移与失败关闭;`AGC_SKIP_RESOURCE_STAGING=1` 在既有产物上只读通过;构建脚本校验缺失组件时 fail closed。) +- [ ] 本机 Rust 门禁(会触发构建脚本的测试入口)与不打包构建路径仍然可用。(macOS 侧已验;Windows 的 `check:rust:shell` 待真机确认。) + +## 证据要求 + +- 自动化:构建新鲜度日志、构建脚本输入清单、准备步骤幂等快照、包内资源门禁脚本结果。 +- 运行时:macOS 与 Windows 各一次客户端启动,确认随包组件被读取而非回退到外部安装。 +- 边界:缺少准备步骤、缓存命中、上游锁定变化三种情形下的行为。 +1 \ No newline at end of file diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6a40e2722..a7b1adf7e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9079,6 +9079,35 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证(真实上游 smoke,本地 dev DB):清空 `agc_model_catalog` 后启动 api-server → 日志 `已按上游模型列表初始化 AGC 模型目录 revision=1 model_count=6`;登录后 `GET /api/llm/models` 返回同一批模型、`displayName` 即上游原名、默认项为排序后第一项;上游不可达/非 2xx 时启动只记录 error、AGC 接口 `503` 且目录保持未初始化;目录已存在时重启不重写。 - 边界(未验证/残留):上游在售模型超过 32 条时同步会失败(目录项上限未改);`qwen-image-3.0` 这类图像模型会一起进入目录,是否对 AGC 隐藏由 owner 在后台停用;混合版本期间未升级的 api-server 会把自己的 AGC 接口打到 `503`,module 与 api-server 必须同批发布/回滚。 +## 2026-09-27 AGC 随包资源改为「单一声明 + 准备步骤生成 + 构建期只读校验」 + +- 背景:随包资源(内置 Codex CLI、插件工作区)由 `build.rs` 在构建期写入 `src-tauri/resources/**`,而这些路径同时被 tauri 配置的 `bundle.resources` 登记成构建输入,cargo 因此永远判 stale:Windows/macOS 每次构建重编主 crate(41–87 秒),macOS dev 反复 `Rebuilding application`、客户端起不来(issue #519)。三轮症状层修复(内容比对、权限跳过、`.taurignore`)都只减少写入次数,没有改变「构建期写被登记文件」这一结构。 +- 决策(形态:混合):准备步骤用 Node(复用 `stage-node-runtime.mjs` / `prepare-macos-codex.mjs` 的下载、`integrity`、临时目录 + rename 原子替换),布局与摘要校验留在 Rust(复用 `codex_bundle.rs` / `godot_bundle.rs`),运行期模块公开接口与取值不变。 +- 决策(单一声明):唯一人工声明是 `apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`(Codex 三元表与组件白名单、上游候选路径、第三方声明来源、插件随包子目录与跳过规则、平台与 feature 门槛)。Node 直接读该 JSON;Rust 读由 `scripts/check-package-layout.mjs` 生成的 `package-layout.generated.rs` 编译期常量(不解析 JSON、不引入生命周期妥协)。门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链,同时校验声明自身不变量:目标唯一、`executable` 属于白名单、每个目标恰有一条第三方声明来源,且 `codex.version` 与应用锁定的 `@openai/codex` 一致。 +- 决策(校验与独立入口):`build.rs` 新增只读校验——Codex 目录校验清单 schema/平台/版本、文件集合、逐文件 sha256、第三方声明、可执行位与白名单外文件;插件目录校验必需组件与整树符号链接。`AGC_SKIP_RESOURCE_STAGING=1` 可跳过写入分支、只跑校验,用于在既有产物上单独验证校验路径。插件产物的逐文件摘要校验留到 M2(届时准备步骤在树内写清单,不再被构建脚本整体重建覆盖)。 +- 影响面:`apps/ai-game-creator-shell/src-tauri/{build.rs,build_support/**}`、`apps/ai-game-creator-shell/scripts/{check-package-layout.mjs,prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs}`、`apps/ai-game-creator-shell/package.json`、根 `package.json`、`.gitignore`、AGC 技术方案 §4.8/§8/§9、M1 里程碑规范与实施计划、开发运维文档。三份 tauri 配置的 `resources` 映射与包内路径不变。 +- 验证:`npm run agc:bundled-resources:check`;`npm run agc:bundled-resources:test`(9 passed,含幂等、上游缺失、非本工具目录、目标不支持、dry-run);`AGC_SKIP_RESOURCE_STAGING=1 cargo check --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` 在准备步骤产物上通过;准备步骤产物与构建脚本产物逐文件一致(相对路径、大小、sha256);连续两次运行准备步骤第二次全部命中缓存,目录快照(含 mtime)不变。 +- 边界(未验证):准备步骤尚未接入 dev 与发布入口(M2);Windows 真机的构建新鲜度与打包未验证;Unity/Godot/Cocos 产物仍由构建脚本生成(M3);Linux 上五条 staging 与校验均为 no-op。 + +## 2026-09-27 AGC 随包资源准备步骤接入 dev 与发布入口,构建脚本退出写入 + +- 背景:M1 只交付了单一声明、准备步骤与只读校验,构建脚本仍在写随包资源,所以 macOS 的 `npm run agc` 仍会因 `resources/codex/mac-native` 被重写而反复重建、`cargo build` 每次重编主 crate(41–87 秒)。 +- 决策(接线):`start-tauri-dev.mjs` 在前端与配套后端就绪之后、spawn Tauri CLI 之前调用准备步骤(命中缓存零写入,日志前缀 `[ai-game-creator-shell]`);`build-release.mjs` 的 `runTauriBuild` 与既有 `stageRuntime(target)` 并列调用 `stageBundledResources(target)`,`tauri build --no-bundle` 仍不强制 staging。两处都保留依赖注入,便于入口测试断言调用顺序与 no-bundle 行为。 +- 决策(写入边界,声明新增 `origin`):`origin: source`(Codex 组件、插件 `src`/`panels`/`skills`/`native/payload`)由准备步骤写;`origin: build`(Unity `dotnet/publish/win-x64`)与外部工具链产物(Godot `native/gdextension`、Cocos payload)由构建脚本在产物生成后写。构建脚本删除 codex 与插件白名单的写入分支及 `stage_plugin_file`/`copy_plugin_tree`/`copy_plugin_file`,改为 `stage_build_generated_plugin_payloads`。 +- 决策(契约收口):插件随包工作区改为与仓库源码逐文件比对(清单 + 逐文件 sha256 + 整树符号链接,构建期派生内容只查存在性),实现移入 `build_support/package_layout.rs` 以复用单测;上游原生包元数据(layoutVersion/version/target/entrypoint/resourcesDir/pathDir)改由准备步骤按声明校验,`build_support/codex_package_metadata.rs` 因失去调用方而删除。准备步骤改为同步实现(全部是本地同步 IO),入口可直接调用而无需子进程。 +- 影响面:`apps/ai-game-creator-shell/scripts/{prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs,start-tauri-dev.mjs,build-release.mjs,build-release.test.mjs}`、`apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts`、`src-tauri/build.rs`、`build_support/{package_layout.rs,package-layout.json,package-layout.generated.rs}`(`codex_package_metadata.rs` 删除)、`src/agent/codex_cli.rs`、技术方案 §4.9、M1/M2 里程碑与运维文档。 +- 验证:`cargo build --no-default-features` 连续三次 0.69 / 0.22 / 0.22 秒全程 fresh;强制构建脚本重跑(`touch build.rs`)后 `resources/codex` 与 `resources/plugins` 的快照(相对路径/大小/mtime/sha256)逐项不变;`cargo test --no-default-features … package_layout` 36 passed;`node --test scripts/prepare-bundled-resources.test.mjs` 10 passed(含上游元数据漂移被拒);`node --test scripts/build-release.test.mjs` 39 passed(含 `stage → bundled → build` 顺序与 no-bundle 不 staging);`npx vitest run tests/start-tauri-dev.test.ts` 12 passed(含「准备步骤先于 CLI 启动」)。 +- 边界(未验证):Windows 真机未验证,且 Unity publish 目录、Godot gdextension、Cocos payload 仍是构建期写入,Windows 构建新鲜度要等 M3 归位;完整 `npm run agc` 在本机被 SpacetimeDB `Pre-publish check`(先后 401 InvalidSignature 与 502 Bad Gateway,属既有本机环境问题)阻断,未跑通整条 dev 启动链路。 + +## 2026-09-27 AGC 编辑器分支产物归位:构建脚本彻底退出写入 + +- 背景:M2 之后构建脚本仍生成 Unity publish 目录、Godot gdextension 与 Cocos bridge payload,这三处写入落在 `resources/plugins/**`(`bundle.resources` 映射目录),Windows 上仍会触发每次重编,`.taurignore` 的 staging 条目也还不能删。 +- 决策(声明扩展):`subdirectories` 新增 `origin: prepared`;`libraryStaging` 增加 `prepare` 与 `files`;新增 `nativePayloads` 与 `plugins.prepareSteps`(程序类型、工作目录、指纹、必需产物)。Godot 随包文件清单改由声明提供——`godot_bundle::BUNDLE_FILES` 从生成的编译期常量取值,不再各写一份。 +- 决策(准备步骤执行器):准备步骤按声明运行 `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File build.ps1`(Unity/Godot,Godot 额外移除 `PSModulePath`)与 `cargo build -p cocos-editor-bridge --target … --features windows-injection`;内容指纹命中且必需产物齐全时零写入;命令执行器可注入,便于在 macOS 上用假执行器覆盖调度、指纹与失败关闭逻辑。 +- 决策(构建脚本瘦身):删除 `prepare_unity_editor_helper`、`prepare_godot_editor_extension`、`stage_cocos_editor_payload`、`stage_build_generated_plugin_payloads` 及其辅助函数(build.rs 415 → 193 行),只留只读校验,并新增「已准备产物存在性 + Godot 随包库深度校验」;`AGC_SKIP_RESOURCE_STAGING` 开关随写入分支一并删除;两份只含 staging 条目的 `.taurignore` 删除。 +- 影响面:`apps/ai-game-creator-shell/src-tauri/build_support/{package-layout.json,package-layout.generated.rs,package_layout.rs,godot_bundle.rs}`、`src-tauri/build.rs`、`scripts/{prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs,check-package-layout.mjs,build-release.mjs}`、两份 `.taurignore`、技术方案 §4.9/§8、M3 里程碑、运维文档、决策日志与排障经验。 +- 验证:准备步骤 13 条用例通过(含三类准备步骤调度、指纹跳过、缺产物失败关闭、幂等与失败关闭);`npm run agc:bundled-resources:check` 通过;`cargo check --no-default-features` 通过(构建脚本仅剩只读校验,且不再出现在随包资源的写入路径上)。 +- 边界(未验证):Windows 真机未验证——powershell/cargo 两条命令路径、Unity/Godot/Cocos 产物归位、包内容一致性与客户端加载,需按 M3 里程碑的验收清单在 Windows 上确认。 ## 2026-09-24 命令入队化与待发消息队列归宿主:放行归 Thread Manager,CLI 直连入口退役 - 决策(词表):「接单 / 拒单」退役,命令边界的成功与失败改叫「入队 / 入队失败」;旧「接单」的语义角色 @@ -9276,3 +9305,15 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 后台 AGC 模型目录新增 `agentMode`,只允许 `codex` / `cc`,缺失的历史目录按 `codex` 兼容;模型选择返回的公开摘要同步携带该绑定。 - 客户端把后台 `codex` 映射到现有 Codex app-server,把 `cc` 映射到独立 Claude Code CLI adapter;不通过替换 Codex JSON-RPC 可执行文件实现。 - Claude Code 只使用隔离环境和 AGC loopback MCP,禁用原生工具;取消通过独立 Direct 回合进程树回收处理。Codex、provider 和自定义 Responses 链路保持原路径。 + +## 2026-09-30 合入 master 时把 Claude Agent SDK sidecar 归位到随包资源准备步骤 + +- 背景:master `fb130d184` 新增 `cc` 执行模式与 Claude Agent SDK sidecar,sidecar 的 staging 写在 `build.rs`(构建期 `remove_dir_all` + 从 `node_modules/@anthropic-ai/**` 复制 `resources/claude-agent`),同时把 `resources/claude-agent` 映射进**基线** `tauri.conf.json`。本分支的 M1–M3(issue #519)已把「构建期写随包资源」定性为结构问题,合入时必须按同一套架构落地,不能把写入分支带回来。 +- 决策(归位实现):新增声明 section `claudeAgent`(锁定版本、资源目录、sidecar 入口源码、上游 SDK 包与平台原生运行时包的目标表、复制跳过规则)。Node 准备步骤整目录原子替换 staging,缓存 key = `layoutVersion + target + 声明版本 + 入口摘要`,命中即零写入;`build.rs` 只读校验:入口与仓库源码逐字节一致、SDK 与原生运行时 `package.json` 版本等于声明、原生运行时在位(unix 还要求可执行位)、随包目录里没有白名单外的文件。 +- 决策(版本单一真源):`CLAUDE_AGENT_SDK_VERSION` 由声明生成;`claude_code_cli.rs` 的 sidecar 身份串改用 `cargo:rustc-env=AGC_CLAUDE_AGENT_SDK_VERSION`,门禁断言声明版本等于 `apps/ai-game-creator-shell/package.json` 与 `agent-sidecar/package.json` 锁定的 `@anthropic-ai/claude-agent-sdk`。 +- 决策(平台映射从基线配置移到平台配置):`resources/claude-agent` 由 `tauri.conf.json` 移入 `tauri.windows.conf.json` 与 `tauri.macos.conf.json`。基线配置同时服务 Linux——CI 只在那里编译壳 crate 且按设计不装 npm 依赖,而 `tauri-build` 会把 `bundle.resources` 的每个路径拷进 target、缺失即失败;留在基线等于要求一份只有 Windows/macOS 才产出的资源。`check-config.mjs` 增加「基线不得声明 `resources/**`」的守卫。 +- 决策(删掉自带的清理逻辑):不保留 master 的 `prune_stale_codex_components`。准备步骤对 staging 单元整目录原子替换已经清掉旧布局残留,构建期另有「白名单外的文件」断言;构建脚本不再删任何人的文件。 +- 影响面:`src-tauri/build_support/{package-layout.json,package-layout.generated.rs,package_layout.rs}`、`src-tauri/build.rs`、`scripts/{prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs,check-package-layout.mjs,check-config.mjs}`、三份 tauri 配置、`src/agent/claude_code_cli.rs`、技术方案 §4.5/§4.9、排障记录。 +- 验证:`npm run agc:bundled-resources:test`(18 passed,含 sidecar 归位、跳过规则、上游缺失、版本漂移、目录被占);`npm run agc:bundled-resources:check`、`check-config.mjs`、`cargo test --bin genarrative-ai-game-creator-shell package_layout::tests`、`cargo check --no-default-features`、`cargo fmt --check`、`check:encoding`、eslint/prettier 全部通过;Windows 真机准备步骤 staging 24 个文件(含 243MB `claude.exe`)后 `cargo check` 不再出现构建期写入。 +- 边界(未验证):macOS 真机的 sidecar 加载与 `check-macos-bundle.mjs` 包内容门禁未在本机验证;Linux 门禁按新配置不再要求 sidecar 资源,需 CI 实跑确认转绿。 +- 关联:issue #519、master `fb130d184`、`docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md`、CI run 3083。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 5c093965c..e47120989 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,14 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-09-30 构建期 staging 撞上不装 npm 依赖的 Linux 门禁:AGC 壳 Rust lane 全红 + +- **现象**:`Project CI` 的 AGC 壳 Rust 三条 lane(`npm run check:native-shells:agc-rust-shard-*`)在 `fb130d184` 之后全部失败,日志只有 `error: failed to run custom build command for genarrative-ai-game-creator-shell` 与 `thread 'main' panicked at build.rs:65:28: Claude Agent SDK 缺失;请先执行 npm ci`(run 3083 / job 17521 实测,1 分钟即失败)。 +- **原因**:Claude Agent SDK sidecar 的 staging 写在 `build.rs`(构建期 `remove_dir_all` + 从 `node_modules/@anthropic-ai/**` 复制),而这三条 lane 按设计**不装 npm 依赖**(`scripts/project-ci-workflow.test.ts` 的 `jobsWithoutNpmInstall` 显式允许它们没有 `node_modules`),构建脚本一跑就必 panic。同一批改动还把 `resources/claude-agent` 映射进**基线** `tauri.conf.json`:`tauri-build` 会把 `bundle.resources` 的每个路径拷进 target,缺失即 fail(`tauri-utils` 的 `ResourcePathNotFound`),所以即使绕开 panic,Linux 也会在资源解析处再红一次。 +- **处理(现行口径)**:随包资源一律由准备步骤在 `tauri dev|build` 之前 staging,`build.rs` 只读校验(sidecar 走声明 section `claudeAgent` + `scripts/prepare-bundled-resources.mjs`);平台专属资源只允许出现在 `tauri..conf.json`,基线 `tauri.conf.json` 里不得出现 `resources/**`——基线同时服务不产出客户端包的 Linux,`check-config.mjs` 已加该守卫。 +- **判据/取证**:`node --test apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs`、`node apps/ai-game-creator-shell/scripts/check-config.mjs`;Linux 侧判据是三条 AGC Rust lane 转绿且构建期不再出现 `Claude Agent SDK 缺失`。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/build.rs`、`apps/ai-game-creator-shell/scripts/{prepare-bundled-resources.mjs,check-config.mjs}`、`apps/ai-game-creator-shell/src-tauri/{tauri.conf.json,tauri.windows.conf.json,tauri.macos.conf.json}`、`.gitea/workflows/project-ci.yml`、CI run 3083。 + ## 2026-09-30 Jenkins release 渠道环境污染 AGC 构建单测 - **现象**:Jenkins `Genarrative-Agc-MacOS-Build` 的 release lane 在执行 `build-release.test.mjs` 时,`release stages Node before Tauri...` 用例报 `Cannot read properties of undefined (reading 'nsis')`。 @@ -112,6 +120,23 @@ - **leader 卡死的兜底**:闸门只有 follower 的有界等待(60s),若提权子进程真的挂死(`Start-Process -Wait` 无超时),`leader_deadline`(5 分钟)之前该 key 一直被占住,之后新调用会接管并按新 leader 执行;被接管后旧 leader 迟到的结果按令牌丢弃,不会覆盖接管者。`clear_game_creator_acl_elevation_denials` 只清「被拒绝」记忆,不清理 running。 - **关联**:`src-tauri/src/acl_repair_gate.rs`、`src-tauri/src/config.rs`、issue #498。 +## 2026-09-29 随包资源的编译产物摘要不可复现,且准备步骤与应用构建共用同一输出路径 + +- **摘要不可复现**:同一 source / feature / profile / target 连续构建的 `cocos-editor-bridge` payload 摘要不同(除 PE `TimeDateStamp` 外还有 RSDS GUID 等 22 字节差异),所以「与迁移前逐项一致」只能对**源码派生物**(JS/HTML/JSON/license/notice,逐字节比对)、**.NET publish 产物**(Unity helper 跨两次重新发布逐字节一致)和**命中工具链内部缓存的产物**(Godot 走 `buildId` 早退,不重链)成立。核对打包一致性时不要用编译产物的 sha256 判回归,改比路径集合 + 导出面(`DllMain`、`cocos_editor_bridge_bootstrap_source`)+ 源码派生物摘要。 +- **共用输出路径**:准备步骤的 `cocos-bridge-build` 用 `cargo build -p cocos-editor-bridge --features windows-injection`,而应用构建带的是 `windows-bootstrap + windows-injection`(`cocos-editor-injection` 的闭包),两个单元写同一个 `target///deps/cocos_editor_bridge.dll`。后构建的单元覆盖先构建的产物时,准备步骤的候选查找会取到「上一次遗留的另一个单元」,交替构建还会多一次重链。要改就从这里改:让准备步骤用独立 target 目录,或与应用的 feature 集对齐。 +- **验证方式**:`runTauriBuild`(`scripts/build-release.mjs`)+ `--bundles nsis`,再 `7z x` 解包比 `plugins/**`;准备步骤连续三次复跑要求 `resources/plugins` 的 32 个文件内容与 mtime 全不变。 + +## 2026-09-27 随包资源的写入方按产物来源分界:源码派生直接复制,需工具链的先由准备步骤产出 + +- **写法**:新增随包内容先判断来源——能从仓库源码复制就写进 `build_support/package-layout.json` 的 `subdirectories`(`origin: source`);需要外部工具链或同一次 cargo 构建才能产出的,写成 `origin: prepared` / `libraryStaging` / `nativePayloads`,并在 `plugins.prepareSteps` 里声明要跑的程序、工作目录、指纹与必需产物——**不要写进构建脚本**(构建脚本自 M3 起只做只读校验,不再生成任何随包资源)。 +- **校验口径**:`origin: source` 的内容在构建期会与仓库源码逐文件比对(插件清单 + 逐文件 sha256 + 整树符号链接),手改这部分会被 `cargo build` 直接拒绝;`origin: prepared` 只查存在性(Godot 随包库额外跑 `godot_bundle::validate`),手改 prepared 产物不会被拒,要改就改准备步骤的来源或声明。 +- **准备步骤指纹**:声明了指纹的步骤(Unity)命中后不会重跑工具链,改 `plugins/**` 源码即失效;指纹戳文件(`publish/win-x64/.agc-source.sha256`)删掉只会多跑一次构建。`resources/plugins` 由准备步骤拥有,不要手工往里放文件。 + +## 2026-09-27 AGC 随包资源的布局只能改声明文件,生成物由门禁锁死 + +- **现象**:直接编辑 `apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs`,或另写一份组件白名单,`npm run agc:typecheck`(链内含 `npm run agc:bundled-resources:check`)会立刻失败并报「随包资源声明与 Rust 常量不一致」。 +- **正确做法**:改 `build_support/package-layout.json`,运行 `npm run agc:bundled-resources:sync` 重新生成;改布局同时递增 `layoutVersion`(参与准备步骤的缓存 key)。声明里的 `codex.version` 必须与应用锁定的 `@openai/codex` 一致,门禁会对照 `apps/ai-game-creator-shell/package.json` 校验。 +- **边界(M1 完成时)**:准备步骤 `scripts/prepare-bundled-resources.mjs` 尚未接入 dev / 发布入口,`npm run agc` 仍由构建脚本 staging;构建脚本当前既写资源又做只读校验,`AGC_SKIP_RESOURCE_STAGING=1` 可只跑校验。构建脚本重建 `resources/plugins` 时会整体删除该目录,所以插件侧的准备步骤清单要等 M2 接管写入后才成立,插件目录现在只校验必需组件与符号链接。 ## 2026-09-24 模型输出的围栏会粘在正文行里:聊天 Markdown 必须先归一化再解析 - **现象**:AGC 对话里代码块解析错位——引言行被当成代码渲染(`…实现细节(game.js):```js`),或者代码块收不住、把后面的正文一起吞进去(`… return centerOn(projection); }````)。文本本身「看起来没问题」,容易被当成渲染器坏了。 @@ -2976,9 +3001,9 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 现象:Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)`、`sccache: error: Timed out waiting for server startup`,或 `sccache: caused by: Failed to send data to or receive data from server / Failed to read response header / failed to fill whole buffer`;真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。 - 原因:环境、Jenkinsfile 或 `server-rs/.cargo/config.toml` 启用了 `sccache` wrapper,但当前 agent 没有可执行的 `sccache`、PATH 中 shim 损坏,或本地 sccache server/client 通道状态损坏。Windows 本机若配置了 `SCCACHE_OSS_*`,sccache daemon 冷启动会先经 OSS/本机代理完成缓存读写检查,再监听 `127.0.0.1:4226`;代理或 OSS 链路慢时,Cargo 的 `sccache rustc -vV` 可能先超时。 -- 处理:保留 `server-rs/.cargo/config.toml` 的 `rustc-wrapper = "sccache"`;本地 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` 在 Windows 下限时执行真实 wrapper 探测 `sccache rustc -vV`,成功才启用 sccache,缺少命令、daemon 启动超时或 wrapper 返回非零时立即给 Rust 子进程注入空 wrapper,回退到直接 rustc,避免损坏的 daemon 阻断启动;显式设置的非 sccache 自定义 wrapper 会被保留。Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro` 的 `sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。 +- 处理:保留 `server-rs/.cargo/config.toml` 的 `rustc-wrapper = "sccache"`;本地 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` 在 Windows 下限时执行真实 wrapper 探测 `sccache rustc -vV`,成功才启用 sccache,缺少命令、daemon 启动超时或 wrapper 返回非零时立即给 Rust 子进程注入空 wrapper,回退到直接 rustc,避免损坏的 daemon 阻断启动;显式设置的非 sccache 自定义 wrapper 会被保留。`npm run agc` 的 Tauri Cargo 原先直接继承启动器环境,用户级 `~/.cargo/config.toml` 的 `rustc-wrapper` 会在这里生效并复现同一故障(表现为 `failed to run rustc to learn about target-specific information`,AGC 前端与配套后端已经起来、只有 Tauri 客户端退出);现在 `start-tauri-dev.mjs` 在启动 Tauri CLI 前调用 `scripts/dev.mjs` 的 `buildLocalRustProcessEnv`,把两个 wrapper 变量显式写进子进程环境——空环境变量同样能覆盖 Cargo 配置文件里的 wrapper,不能只依赖「本机没配 sccache」。Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro` 的 `sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。 - 验证:`rustc -Vv` 能输出版本;本地 `npm run dev` 能完成 `spacetime publish`、`api-server` `/healthz`、主站 Vite 和后台 Vite 启动;冷启动后原始 `cargo check -p api-server` 和 `cargo check -p spacetime-module` 能通过;`sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,证明原始 Cargo/Jenkins 路径仍可使用 sccache/OSS 缓存;Jenkins 日志出现“未找到可用 sccache,改用 rustc 直接构建”后仍继续真实构建。 -- 关联:`scripts/dev.mjs`、`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 +- 关联:`scripts/dev.mjs`、`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 ## 生产发布入口不要沿用旧 Jenkinsfile / 一体化脚本 @@ -6082,7 +6107,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **现状(正确)**:`src/services/appUpdate.ts` 的 `installAppUpdate` 先 `await update.downloadAndInstall(...)`、成功后才清空待装更新并 `restartAppAfterUpdate()`;失败时保留待装更新,重试走同一条链路。 - **判据**:`apps/ai-game-creator-shell/tests/appUpdate.test.ts` 新增「签名校验失败时拒绝安装、不重启进程,并保留待装更新供重试」——插件抛 `signature verification failed` 时断言 ①错误原样上抛 ②`restart_agc_app` 未被调用 ③再次安装仍会走插件调用并在成功后重启。变异验证:把 `restartAppAfterUpdate()` 挪到 `await` 之前,该用例立即以 `expected "spy" to not be called with arguments: [ 'restart_agc_app' ]` 变红。 - **边界**:真正的验签与临时文件清理都在官方插件原生实现里,本地只能证明"客户端不把失败当成功",真机安装闭环仍需已发布包与真实设备。 -- **顺带记一条环境陷阱(2026-09-28 已修)**:`apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/` 下曾有两个 codex 二进制——`bin/codex.exe` 是**真正被解析**的那份(0.155.1),而包根目录那份 `codex.exe` 是 0.147.0 的旧残留(tauri 的 Windows 资源映射只引用 `bin/` 等路径),检查都查不出来,却会让本地核对误判「应用跑的是 0.147.0」。根因是 `src-tauri/build.rs` 的 `stage_codex_target()` 只按布局拷贝、从不清理目录,旧布局的组件会永久留在随包资源目录里。现在加了 `prune_stale_codex_components()`:拷贝前删掉不在本轮布局、也不在 `manifest.json`/`NOTICE.md` 白名单里的文件并收掉空目录;实测重建后根目录 `codex.exe` 被清掉、六个声明组件与清单/声明保留。 +- **顺带记一条环境陷阱(2026-09-28 已修)**:`apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/` 下曾有两个 codex 二进制——`bin/codex.exe` 是**真正被解析**的那份(0.155.1),而包根目录那份 `codex.exe` 是 0.147.0 的旧残留(tauri 的 Windows 资源映射只引用 `bin/` 等路径),检查都查不出来,却会让本地核对误判「应用跑的是 0.147.0」。根因是构建脚本只按布局拷贝、从不清理目录,旧布局的组件会永久留在随包资源目录里。**现行口径(2026-09-30 起)**:随包资源改由准备步骤整目录原子替换(`scripts/prepare-bundled-resources.mjs` 的 `stageAtomically`),旧布局残留随替换消失;构建脚本只剩只读校验,遇到白名单外的文件会立即失败,所以「本机留着旧组件」最多表现为一次可读的失败,不会再静默随包。(2026-09-28 加的构建期 `prune_stale_codex_components()` 已随 M3 退役,实现不再存在。) ## 2026-09-29 Vite dev 冷启动会让 web E2E 的首个 goto 超时,别当成页面回归 diff --git a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md new file mode 100644 index 000000000..6a6f157bd --- /dev/null +++ b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md @@ -0,0 +1,225 @@ +# AGC 随包资源 staging 归位技术方案 + +状态:待评审(方案草案,评审通过前不进入实现) +日期:2026-09-26 +范围:AGC 客户端(`apps/ai-game-creator-shell`)随包资源的生成、校验与打包链路 +关联:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/pitfalls.md` + +## 1. 一句话目标 + +把「随包资源」从 build script 的**产物**改回它的**输入**:由准备步骤在 `tauri dev|build` 之前一次性 staging,`build.rs` 退化为校验者,构建期不再向 `src-tauri/resources/**` 写任何文件。 + +## 2. 目标与非目标 + +### 2.1 目标 + +1. `build.rs` 的输入集合里不再包含任何「本次构建会写」的文件,`cargo` 在源码不变时稳定 fresh。 +2. Tauri dev 的文件监听不再因为 staging 变更重启 `cargo run`(macOS 与 Windows 一致,不依赖 `.taurignore` 兜)。 +3. staging 与上游版本的绑定可验证:版本、平台、逐文件摘要由校验器 fail closed 检出,不会静默发旧二进制。 +4. 打包产物内容与当前口径一致(三份 tauri 配置的 `resources` 映射与包内资源门禁不变)。 +5. 删除症状层补丁(整目录重建的规避、为绕开自触发而加的 `.taurignore` staging 条目)。`prune_staging`、`STAGED_*` 一类命名只出现在未合入的症状层补丁里,主线从未有过,不需要清理。 + +### 2.2 非目标 + +1. 不改发布渠道、版本号机制、签名与上传流程。 +2. 不改运行时资源解析顺序与完整性校验语义(`codex_cli.rs`、`plugin_host.rs`、`environment_check.rs`、`editor_adapters.rs` 保持行为)。 +3. 不统一 `server-rs` 与 `src-tauri` 两个 workspace,不改 target 布局。 +4. 不为 Linux 增加客户端产物(Linux 上五条 staging 全为 no-op,见 §3.6)。 +5. 不改 Windows/macOS 之外的平台支持面。 + +## 3. 现状与证据 + +### 3.1 build script 目前负责五条 staging + +`apps/ai-game-creator-shell/src-tauri/build.rs` 的 `main()` 前段依次执行(`build.rs:225-229`): + +| # | 步骤 | 动作类型 | 平台门槛 | 目标路径 | +|---|---|---|---|---| +| 1 | `stage_bundled_codex_cli` | 纯复制(内容+权限比对) | Windows / macOS | `resources/codex/**` | +| 2 | `prepare_unity_editor_helper` | **外部工具链**(`powershell -File build.ps1`,需 .NET 10 SDK + VS C++ x64) | Windows + unity feature | `resources/plugins/agc-unity-editor/**` | +| 3 | `prepare_godot_editor_extension` | **外部工具链**(`powershell -File build.ps1`,需 CMake ≥ 3.25 + VS17 2022 + Python 3) | Windows + godot feature | `resources/plugins/agc-godot-editor/native/gdextension/**` | +| 4 | `stage_plugin_workspace` | 复制 + 清残留 | Windows / macOS | `resources/plugins/**` | +| 5 | `stage_cocos_editor_payload` | 复制(同一 cargo 构建产出的 cdylib) | Windows | `resources/plugins/agc-cocos-editor/native/payload/**` | + +(Prompt Bundle 生成代码写 `OUT_DIR`,属正确形态;Node 运行时已由 `scripts/stage-node-runtime.mjs` 在发布前一次性 staging,是本次改造的既有先例。) + +### 3.2 这些写入为什么会让 build script 永远失效 + +`tauri-build` 会对 `bundle.resources` 里的**每个文件**发 `cargo:rerun-if-changed`,并把它拷进 target(`tauri-build-2.6.3/src/lib.rs:88-93`)。我们的三份 tauri 配置把 `resources/codex/**` 与 `resources/plugins` 列进了 `bundle.resources`(`tauri.windows.conf.json:7-15`、`tauri.macos.conf.json:8-22`;基线配置故意不含,见 `check-config.mjs:1377-1383`)。 + +于是「构建期写的文件」与「build script 声明的输入」是同一批: + +```text +staging 写 resources/** → tauri-build 把同一批文件登记成输入 → mtime 变化 + ↑ ↓ + └────────────── cargo 判 build script stale,重跑脚本 ←──────┘ +``` + +判据是「输入文件 mtime 比 build script 的输出新」,与目录位置无关:把 staging 搬到 `target/` 也躲不开——登记的是配置里写的那些路径,只要构建期写它们,照样自触发。所以关键不是「产物放哪」,而是「**构建期有没有人写这些被登记的文件**」。 + +### 3.3 已发生的故障 + +| 症状 | 范围 | 观察证据 | +|---|---|---| +| 每次构建都重编主 crate(41–87 秒,从不变 fresh) | Windows + macOS(Linux 上五条 staging 全为 no-op,不受影响) | `CARGO_LOG=cargo::core::compiler::fingerprint=info cargo build --no-default-features` 打印 `stale: changed "resources/codex/mac-native/darwin-arm64/NOTICE.md"`,且 `.fingerprint/**/run-build-script-build-script-build.json` 的 `RerunIfChanged.paths` 含 staging 目标路径 | +| `npm run agc` 反复 `Rebuilding application`,客户端起不来 | 仅 macOS | dev 日志一轮 28 次以上不收敛;原因是被重写的 `resources/codex/mac-native/**` 落在监听范围内且未被忽略 | +| `remove_dir_all` 抛 `DirectoryNotEmpty`,整次启动中断 | 并发重建时 | `清理 macOS Codex staging 失败` | + +### 3.4 三轮症状层修复都没有收口 + +| commit | 日期 | 修的是什么 | 结果 | +|---|---|---|---| +| `299877b19` | 2026-09-11 | 插件资源改成「内容变化才落盘」+ 引入 `.taurignore` | 只覆盖插件路径,且挡不住 cargo stale | +| `710d6dddf` | 2026-09-23(PR #487) | Windows 上「权限一致就不写元数据」 | 只减少一类写入,未解决整目录重建 | +| 本次未合入的 B 改动 | 2026-09-26 | 全部写入改幂等 + 按布局清残留 + 补 `.taurignore` | 实测可行(第二次 `cargo build` 0.25 秒 fresh),但规则靠人守:新增一条写 `resources/**` 的步骤漏改即回退(godot/cocos/plugin.json 正是三个漏点) | + +结论:**只要 build script 继续写这些受跟踪路径,就必须持续为每一处写入维护「无改动不写」,成本随写入点增长**;这是结构问题,不是疏漏问题。 + +## 4. 方案设计 + +### 4.1 原则 + +build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要「由本仓库生成、再随包分发」的内容,都由 `tauri dev|build` 之前的**准备步骤**生成,build script 只校验。 + +### 4.2 目标形态 + +```text +准备步骤(新,唯一写入方) build.rs(退化为校验者) + fetch/校验上游 → 生成到 staging 目录 只读 resources/** → 校验 manifest/hash/版本/平台 + → 原子替换到 resources/** → 不写任何随包资源 + → 写 manifest.json(schema 化) + ↑ ↑ + dev: start-tauri-dev 内、spawn tauri 之前 release: build-release/build-macos-ci 内 +``` + +### 4.3 准备步骤的合同(必须成立的行为) + +1. **调用时机**:`tauri dev` / `tauri build` 之前完成;任何入口都不得依赖 build script 兜底生成。 +2. **缓存与幂等**:以「上游 lockfile `resolved` + `integrity` + 布局版本 + 目标三元」为 key;命中且 manifest 校验通过的 staging 目录不重写任何文件(避免把「产物」变成「每次构建都变」的新源头)。 +3. **原子性**:写入 staging 临时目录后 rename 替换;不得出现半成品目录(杜绝并发下的 `DirectoryNotEmpty`)。 +4. **所有权**:只允许替换由本工具创建并带 manifest 的目录;遇到非本工具目录、符号链接、越界路径必须 fail closed(沿用 `stage-node-runtime.mjs` 与 `prepare-macos-codex.mjs` 既有判定)。 +5. **清理语义**:准备步骤负责删除本轮布局不再产出的残留(否则旧组件会继续被打包),删除范围限于自己的 staging 目录,不得触碰受版本控制的文件(例如 `resources/codex/win-x64/NOTICE.md`)。 +6. **失败语义**:上游缺失、integrity 不匹配、目标平台不支持、外部工具链缺失 → 立即失败并给出可执行提示;不允许「跳过生成、继续打包」。 +7. **可观测**:输出一行汇总(命中缓存 / 重新生成 / 跳过原因),供本地与 CI 排障。 +8. **并发**:不做并发支持(无实际场景)。同一 staging 目录的并发调用未加锁,会以可读错误失败并保留已生成的 staging 目录;需要并发时由调用方外部串行化。 +9. **实际构建目标**:资源准备与 Cargo 必须使用同一目标和 feature 集。正式发布使用发布目标;无显式 `--target` 的 `--no-bundle` 使用宿主平台,Windows/macOS 仍须准备资源,Linux 编译 smoke 不准备桌面平台专属资源。feature 从最终 Tauri/Cargo 参数解析,不能被开发环境变量单独覆盖;dev 启动器转交的应用参数不参与构建参数解析。 + +### 4.4 build.rs 退化后的职责 + +保留: + +1. 读取 `TARGET` 并写 `cargo:rustc-env=AGC_BUILD_TARGET`(运行时定位随包目录依赖它)。 +2. 校验 `resources/**` 与清单一致:平台目录存在、manifest schema/平台/版本、逐文件 sha256、必需组件齐全(缺一即 fail closed)。 +3. 声明**真实输入**:上游源文件、布局表、受版本控制资源(含 macOS 声明文件)的 `rerun-if-changed`;不得声明任何由本脚本或准备步骤生成的文件。 +4. `tauri_build::build()` 与既有 Prompt Bundle、能力与配置校验。 + +删除: + +1. 五条 staging 的写入逻辑(M2 删除了 codex 与插件工作区的写入分支,仍是构建期产物的三处留在 M3)、针对大目录的整目录重建。 +2. 为绕开自触发而加的 `.taurignore` staging 条目与说明(准备步骤在监听启动前完成,不再需要)。 + +### 4.5 资源清单(谁生成、谁消费) + +| 资源 | 生成方式 | 运行时消费方 | dev 是否需要 | +|---|---|---|---| +| `resources/codex/**` | 纯复制(上游平台包 `vendor/`) | `codex_cli.rs`(内置 sidecar 优先,其次 npm 目录、PATH) | macOS dev 只接受真 `.app` 的 `Contents/Resources`,非 `.app` 场景走 npm/PATH 回退;Windows dev 从 exe 同级读取 | +| `resources/plugins/**` | 复制 + 三个编辑器分支的产物 | `plugin_host.rs`(`AGC_PLUGIN_WORKSPACE` → `resource_dir/plugins` → dev 回退仓库 `plugins/`)、`editor_adapters.rs` | dev 有仓库回退,但 cocos/unity/godot payload 仍以随包路径为准 | +| `resources/plugins/agc-unity-editor/**`、`agc-godot-editor/native/gdextension/**` | 外部工具链(Windows 专属) | `editor_adapters.rs` 候选链 | 仅 Windows | +| `resources/node-runtime/**` | 已有:`stage-node-runtime.mjs` | `environment_check.rs`(`agc-node-runtime.v1` 全量 sha256) | 发布与需要随包 Node 的 dev | +| `resources/claude-agent/**` | 纯复制(应用 `agent-sidecar/src/index.mjs` + 上游 `@anthropic-ai/claude-agent-sdk`、`@anthropic-ai/claude-agent-sdk-`) | `claude_code_cli.rs`(`exe_dir/claude-agent/index.mjs`、macOS `.app` 的 `Contents/Resources/claude-agent`) | Windows / macOS dev:准备步骤按声明 staging | +| `design-agent`、`vendor/*` 许可 | 受版本控制 | `design_tools.rs` 等 | 无需 staging | + +### 4.6 入口接线 + +| 入口 | 位置 | 现状 | 改造后 | +|---|---|---|---| +| AGC dev | `start-tauri-dev.mjs`(`runTauriDev` → 预检 → 前端 → `spawnCli`,准备点在前端就绪之后、`spawnCli` 之前) | 无准备步骤,依赖 build script | 在 `spawnCli` 之前调用准备步骤(命中缓存时秒退) | +| Windows 发布 | `build-release.mjs`(`runTauriBuild`,现有 `stageRuntime(target)` 紧邻 spawn tauri) | 只有 Node 运行时走准备步骤 | 同一挂点串上全部 staging | +| macOS 发布 | `build-macos-ci.mjs`(复用 `runTauriBuild`)→ `check-macos-bundle.mjs` | 同上 | 同上 | +| 本机 Rust 门禁 | `ai-game-creator-shell:check:rust:shell`(Windows 上 `cargo test --no-run` 会跑 build script) | 依赖 build script 生成资源 | 校验器在该场景必须能只读通过;需要真实资源的用例沿用既有 fixture,不得依赖本机 staging 产物 | +| `tauri build --no-bundle` | `build-release.mjs` 的 no-bundle 分支(当前跳过 `stageRuntime`) | 不生成 Node 运行时 | 改为:`--no-bundle` 也执行 staging(app 构建本身需要随包资源),只跳过总号发布;校验器在资源缺失时仍然 fail closed | +| CI(Linux) | `.gitea/workflows/project-ci.yml` 的 AGC 分组 | 五条 staging 全为 no-op | 不需要新增准备步骤;分片与 smoke 命令不变 | + +### 4.7 与现有机制的关系 + +1. **已有先例**:`stage-node-runtime.mjs` 已实现 schema 常量、目标平台失败关闭、staging 目录 + rename 原子替换、拒绝覆盖非本工具目录;`prepare-macos-codex.mjs` 已实现 lockfile `integrity` 驱动的下载、缓存与原子替换。准备步骤应复用这两套范式而不是另起一套。 +2. **打包侧不变**:三份 tauri 配置的 `resources` 映射与 `check-config.mjs:1356-1424` 的逐字断言保持不变;`check-macos-bundle.mjs` 对包内 `coding-agent/mac-native`、`game-runtime/node`、`plugins/agc-cocos-editor` 的存在性、架构与 sha256 断言继续作为发布后门禁。 +3. **fail closed 已有兜底**:`tauri-build` 在资源缺失时以 `ResourcePathNotFound` 直接失败;校验器应比它更早、更明确地报错。 + +### 4.8 单一声明与实现形态(M1 定案) + +准备步骤与构建期校验共用一份人工声明:`apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`。 + +| 侧 | 读取方式 | 用途 | +| --- | --- | --- | +| Node 准备步骤(`scripts/prepare-bundled-resources.mjs`) | 直接读声明 JSON | 组件白名单、Codex 上游候选路径、插件随包子目录与跳过规则、平台与 feature 门槛、缓存 key 组成、manifest 序列化 | +| Rust 构建期校验与运行期布局 | 读声明生成的 `build_support/package-layout.generated.rs`(编译期常量) | 只读校验既有产物、运行期定位随包组件(`codex_bundle.rs` 接口不变) | +| 声明门禁 | `scripts/check-package-layout.mjs`(`npm run agc:bundled-resources:check`,已进 `agc:typecheck` 链) | 校验生成物与声明一致,并检查声明自身不变量:目标唯一、`executable` 属于白名单、每个目标恰有一条第三方声明来源、`codex.version` 与应用锁定的 `@openai/codex` 一致 | + +形态选择**混合**:生成归 Node(复用 `stage-node-runtime.mjs` / `prepare-macos-codex.mjs` 的下载、`integrity`、临时目录 + rename 原子替换范式),声明与校验归 Rust(复用 `codex_bundle.rs` / `godot_bundle.rs` 的布局与摘要校验,运行期模块不改公开接口)。理由:Rust 侧没有下载与 lockfile 解析能力(`[build-dependencies]` 无 HTTP 客户端),Node 侧没有 staging 能力;任选单一语言都要迁移另一侧既有资产。Rust 侧刻意不解析 JSON:声明经生成器变成编译期常量,避免运行期解析与生命周期妥协,也让 `&'static` 布局表与现有调用点保持不变。 + +构建脚本自 M3 起只做只读校验(写入分支与 `AGC_SKIP_RESOURCE_STAGING` 开关一并删除),`cargo build/check` 本身就是对既有产物的校验。 + +### 4.9 M2/M3 实况:构建期写入边界 + +「谁写随包资源」按产物来源分界,声明里的 `origin` 字段表达同一口径: + +| 来源 | 例子 | 谁写 | 时机 | +| --- | --- | --- | --- | +| `source`:声明 + 仓库源码即可生成 | `resources/codex/**`、插件工作区的 `src`/`panels`/`skills`/`native/payload` | 准备步骤(Node) | `tauri dev` / `tauri build` 之前 | +| `prepared` / `libraryStaging` / `nativePayloads`:需要外部工具链或同一次 cargo 构建 | Unity `dotnet/publish/win-x64`、Godot `native/gdextension`、Cocos `native/payload` | 准备步骤:先按声明运行 `powershell.exe -File build.ps1` 或 `cargo build -p … --target …`,再复制产物 | 同上 | + +M2 之后构建脚本只做只读校验;M3 之后它也不再生成任何随包资源(连编辑器分支产物一并交给准备步骤),并在校验阶段确认源码派生内容逐文件一致、已准备产物在位、Godot 随包库通过既有深度校验。因此源码不变时 `cargo` 稳定 fresh(macOS 实测连续三次 `cargo build --no-default-features` 为 0.69 / 0.22 / 0.22 秒),`resources/plugins` 也不再需要在 `.taurignore` 里忽略(两份 staging 条目已删除)。 + +准备步骤的 Windows 侧命令执行(powershell / cargo)只在本机无法验证,验收清单见 M3 里程碑规范。 + +**2026-09-30(合并 master)**:新增随包组件 Claude Agent SDK sidecar(cc 执行模式)沿用同一口径——声明 `claudeAgent` section(上游 SDK 包与平台原生运行时包的目标表、复制跳过规则、锁定版本),staging 由准备步骤整目录原子替换,`build.rs` 只读校验(入口与仓库源码一致、SDK 与原生运行时版本等于声明、白名单外文件)。它的 `resources/claude-agent` 映射放在 `tauri.windows.conf.json` 与 `tauri.macos.conf.json`,**不得**回到基线 `tauri.conf.json`:基线同时服务 Linux(CI 只在那里编译壳 crate 且不装 npm 依赖),`tauri-build` 会因资源缺失直接失败。 + +## 5. 兼容与迁移 + +1. **产物兼容**:包内路径、manifest schema(`genarrative-codex-sidecar.v2`、`agc-node-runtime.v1`、插件 `plugin.json`)不变,安装包内容逐项对得上;升级路径不需要用户侧动作。 +2. **过渡期(已完成)**:M1 让准备步骤与构建脚本产物并存并逐文件比对一致,M2 移除了构建脚本里 codex 与插件工作区的写入分支;剩余在构建期写入的三处(Unity publish 目录、Godot gdextension、Cocos payload)随 M3 归位。删除与新增不跨里程碑混在一起。 +3. **本机残留**:M2 已删除 codex 与插件工作区的写入逻辑与整目录重建;`.taurignore` 的 staging 条目及其原因说明保留到 M3——Windows 上仍有三处构建期写入落在 `resources/plugins/**`,去掉忽略会重新引入监听自触发。`resources/**` 仍保持 gitignored。 +4. **回滚**:准备步骤与校验器保持独立可关闭(例如校验器只读、不写),回滚只需恢复 build script 的写入分支,不涉及数据迁移。 + +## 6. 验收标准与证据 + +| 项 | 判据 | +|---|---| +| 构建新鲜度 | 源码不变时连续两次 `cargo build --no-default-features` 第二次为秒级 `Finished`;IDE 的 `cargo check --all-targets` 同样不重复构建 | +| 无自触发 | `cargo:rerun-if-changed` 输出与 fingerprint 记录里不出现任何 `src-tauri/resources/**` 路径 | +| dev 可用 | macOS/Windows `npm run agc` 在准备步骤后一次成功:`Rebuilding application` 为 0 次、`Running DevCommand` 为 1 次,客户端与 Runner 进程稳定存活 | +| 包内容 | `check-macos-bundle.mjs` 全绿;Windows 安装包内 `coding-agent`、`plugins`、`game-runtime/node` 与改造前逐项一致 | +| 失败关闭 | 上游缺失 / integrity 不匹配 / 清单缺组件 / 目标平台不支持 四类场景各自返回明确错误且不产出包 | +| 幂等 | 准备步骤连续执行两次,staging 目录内容与 mtime 不变(不改动受跟踪输入) | +| 门禁 | `check-config.mjs`、`check:encoding`、`check:doc-index`、`git diff --check`、AGC 相关 vitest 与 Rust 测试全绿 | + +未验证项必须在交付记录中标注(例如 Windows 真机行为、真实上游包下载在受限网络下的表现)。 + +## 7. 风险与回滚 + +| 风险 | 影响 | 措施 | +|---|---|---| +| 忘记调用准备步骤(dev 或某个发布入口) | 资源缺失,打包或启动失败 | 校验器 fail closed + 入口测试断言「spawn tauri 前已调用准备步骤」 | +| staging 缓存 key 不覆盖上游变化 | 静默发旧二进制 | key 含 lockfile `resolved`+`integrity`+布局版本+三元;manifest 校验作为第二道闸 | +| 外部工具链步骤(unity/godot)搬出后顺序变化 | Windows 打包失败 | 准备步骤显式声明工具链前置检查;先在 Windows 上单独验证再合入 | +| 并发调用同一 staging 目录 | 失败可读、不丢 staging | 不加锁也不支持并发:替换失败给出可执行提示并保留 staging 目录,需要并发时外部串行化 | +| 迁移期两套生成并存 | 结果漂移 | 并存阶段以「准备步骤生成结果 == build script 生成结果」逐文件比对作为过渡判据 | + +回滚点:准备步骤上线但校验器未启用前,任一步失败都可直接恢复 build script 写入分支,无需数据迁移。 + +## 8. 里程碑拆分(建议) + +| 里程碑 | 交付 | 停止条件 | +|---|---|---| +| M1 校验器化 | 单一声明 + 生成门禁、`build.rs` 只读校验路径、准备步骤脚本(codex + plugins 两条纯复制路径)、缓存与原子替换 | 校验器在既有 staging 产物上全绿且不改变现有构建行为(写入分支与 `AGC_SKIP_RESOURCE_STAGING` 开关在 M3 一并删除) | +| M2 入口接线 | dev 与两个发布入口调用准备步骤;codex/plugins 的写入分支从 build.rs 移除;`cargo` 新鲜度与 dev 不再重建达标 | 已交付(2026-09-27):macOS 侧 §6 前三行达标(连续 `cargo build` 0.69 / 0.22 / 0.22 秒 fresh;`tauri dev` 全程 `Rebuilding application` 0 次、`Running DevCommand` 1 次);Windows 新鲜度待 M3 归位三处构建期产物后复验 | +| M3 外部工具链归位与清理 | unity/godot/cocos 的产物生成移出 build.rs;删除 `.taurignore` 的 staging 条目与相关注释;文档收口 | 已实现(2026-09-27):三处产物改由准备步骤按声明运行 powershell/cargo 后复制,build.rs 只剩只读校验,两份 `.taurignore` 已删除;已通过第五轮 Windows 真机评审:构建脚本不再写资源(90 个文件 0 变化)、第二次 `cargo build` 1.13s fresh、三分支产物齐备、幂等与 fail-closed 通过;仍待验收的是「安装包内产物路径/sha256 比对」与「三个编辑器分支客户端加载」 | + +里程碑规范与单里程碑实现计划按 [`docs/【协作规范】规范驱动开发工作流-2026-09-12.md`](../【协作规范】规范驱动开发工作流-2026-09-12.md) 另立 `docs/project-memory/plans/` 下的临时文件;本方案是它们的主规范来源。 + +## 9. 未决问题 + +1. ~~准备步骤用 Rust bin 还是 Node 脚本?~~ **已定(2026-09-27):混合**——生成归 Node、声明与校验归 Rust,布局与白名单收敛为单一声明文件。机制、门禁与验证入口见 §4.8。 +2. unity/godot 的外部工具链步骤是否值得搬出 build script(它们本身是构建动作,搬出后需要显式前置顺序)——需在 Windows 上确认收益与风险。 +3. `resources/**` 是否需要继续保留在 crate 内(`bundle.resources` 相对路径解析要求),还是改用生成式配置指向 `target/` 下的 staging:前者改动小、后者更彻底,需与 Tauri 的资源解析规则一起评估。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index ee2e9c2e2..10578f8af 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -99,10 +99,16 @@ AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creato AGC 开发态还会按后台 Web 的端口约定额外拉起 `apps/admin-web`:Linux 用当前用户端口段的 `start + 3` 槽位,非 Linux 以 `3102` 为兼容首选并允许统一漂移,`ADMIN_WEB_PORT` 可显式指定且必须避开已解析的 AGC Vite 端口;设置 `AGC_DEV_ADMIN_WEB=0` 可关闭。后台 Vite 与 AGC Vite 一样由 `start-dev-stack.mjs` 直接持有并随启动器退出收束,不走 `npm run dev:admin-web`——后者会整体重写 `.app/dev-stack.json`,覆盖本次配套后端的归属状态;后台 Web 的端口解析、启动失败或运行中意外退出都只打印告警,不阻断也不连带停止 AGC 客户端与配套后端。前端与配套后端就绪后,启动器会打印一行 `[ai-game-creator-shell] 启动汇总:`,依次给出前端、后端、后台、数据库与 `bgfilter-worker` 的实际地址;端口漂移或默认端口被其它工作树占用时,以这一行为准。 -Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID /T /F`。Windows 下每个长驻服务都经 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 会先杀掉包装层(退出码 `0xC000013A`),因此清理不能只看直接子进程是否存活:`taskkill` 对已退出的 PID 只会失败,必须继续按记录下来的根 PID 遍历,并在退出时按本工作树 `api-server.exe` 绝对路径(以及本次自己拉起的 SpacetimeDB `--data-dir`)做一次身份兜底清扫;`scripts/dev-windows-process.mjs` 是这套判定的唯一实现。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。 +Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID /T /F`。Windows 下每个长驻服务都经 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 会先杀掉包装层(退出码 `0xC000013A`),因此清理不能只看直接子进程是否存活:`taskkill` 对已退出的 PID 只会失败,必须继续按记录下来的根 PID 遍历,并在退出时按本工作树 `api-server.exe` 绝对路径(以及本次自己拉起的 SpacetimeDB `--data-dir`)做一次身份兜底清扫;`scripts/dev-windows-process.mjs` 是这套判定的唯一实现。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。 Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter-worker` 默认不主动启用 sccache;只有用户通过 `RUSTC_WRAPPER` 或 `CARGO_BUILD_RUSTC_WRAPPER` 显式配置 wrapper 时才进入处理流程。配置为 sccache 时会限时执行真实 wrapper 探测,成功才使用缓存;未安装、不可执行、超时或两个变量冲突时设置为空值,回退到真实 `rustc`,不阻断启动。完整栈和 `dev:api-server` 把 API 与 BgFilter worker 作为一个 Rust 重启单元:源码变化时先停两个进程,再先启动并验活 worker、最后启动并验活 API,避免两个 `cargo run` 并发链接同一个 Windows 可执行文件。不要把 wrapper 绕过值写成 `rustc`;Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志中的 wrapper 启用、冲突或回退提示。 +`npm run agc` 的 Tauri Cargo 走同一套本地 wrapper 规则:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 在启动 Tauri CLI 前调用 `scripts/dev.mjs` 导出的 `buildLocalRustProcessEnv`,把决定结果显式写进 `RUSTC_WRAPPER` 与 `CARGO_BUILD_RUSTC_WRAPPER`。用户级或仓库级 Cargo 配置里的 `rustc-wrapper`(本地常见为 `~/.cargo/config.toml` 的 sccache)只在环境变量非空时才会被覆盖,所以这两个变量必须由脚本写入而不能留空;否则本机 sccache daemon 状态损坏时,Tauri Cargo 的首次 rustc 探测(`failed to run rustc to learn about target-specific information`)就会中断整个 AGC 启动,而配套后端因为已经在用同一规则而能正常起来。AGC 启动日志出现 `[dev:rust]` 提示即为该规则生效。 + +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(app 构建本身就需要这些资源),只跳过总号发布。**构建脚本完全不生成随包资源**(源码派生内容逐文件比对、已准备产物查存在性、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 构建缓存与磁盘上限 `server-rs/Cargo.toml` 和 `apps/ai-game-creator-shell/src-tauri/Cargo.toml` 是两个独立 Cargo workspace;AGC 会以 path dependency 复用 `agent-runtime-core`、`platform-llm`、`platform-agent` 和 `shared-contracts`,但两边默认仍分别写入 `server-rs/target` 与 `apps/ai-game-creator-shell/src-tauri/target`。这个代码和锁文件边界继续保留,不为节省磁盘直接合并 workspace;生产构建脚本和 Tauri 发布还依赖当前 manifest / lock / target 身份。 diff --git a/package.json b/package.json index cf5a355c7..c26f72011 100644 --- a/package.json +++ b/package.json @@ -184,6 +184,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", @@ -207,7 +211,7 @@ "agent-runtime-core:check": "cargo test --manifest-path server-rs/crates/agent-runtime-core/Cargo.toml", "agent-runtime-orchestration:check": "cargo test --manifest-path server-rs/crates/agent-runtime-orchestration/Cargo.toml", "ai-game-creator-shell:typecheck": "npm --prefix apps/ai-game-creator-shell run typecheck", - "ai-game-creator-shell:check:web": "npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests", + "ai-game-creator-shell:check:web": "npm run ai-game-creator-shell:typecheck && npm --prefix apps/ai-game-creator-shell run bundled-resources:test && npm run test -- apps/ai-game-creator-shell/tests", "ai-game-creator-shell:check:rust:crates": "npm run agent-runtime-core:check && npm run agent-runtime-orchestration:check && cargo test --locked -p platform-llm --manifest-path server-rs/Cargo.toml && cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app && npm run check:generated-bindings", "ai-game-creator-shell:check:rust:shell": "node apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs --shards=4", "ai-game-creator-shell:check:rust": "npm run ai-game-creator-shell:check:rust:crates && npm run ai-game-creator-shell:check:rust:shell", diff --git a/scripts/project-ci-workflow.test.ts b/scripts/project-ci-workflow.test.ts index 283f6a53a..16bc6b87b 100644 --- a/scripts/project-ci-workflow.test.ts +++ b/scripts/project-ci-workflow.test.ts @@ -707,7 +707,7 @@ describe('project CI workflow', () => { 'npm run ai-game-creator-shell:check:web && npm run ai-game-creator-shell:check:rust && npm run ai-game-creator-shell:agent-run:smoke', ); expect(rootPackageJson.scripts?.['ai-game-creator-shell:check:web']).toBe( - 'npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests', + 'npm run ai-game-creator-shell:typecheck && npm --prefix apps/ai-game-creator-shell run bundled-resources:test && npm run test -- apps/ai-game-creator-shell/tests', ); expect(rootPackageJson.scripts?.['ai-game-creator-shell:check:rust']).toBe( 'npm run ai-game-creator-shell:check:rust:crates && npm run ai-game-creator-shell:check:rust:shell',