diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index fe13fade7..e6ddb30cd 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -18,6 +18,7 @@ import { resolveReleaseChannel, } from './channel-identity.mjs'; import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs'; +import { prepareBundledResources } from './prepare-bundled-resources.mjs'; import { stageNodeRuntime } from './stage-node-runtime.mjs'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -431,10 +432,29 @@ function writeChannelConfigFile(channel, target, includeNodeRuntime = false) { return configPath; } +/// 随包资源必须在打包工具之前生成:构建脚本只做只读校验,不再生成。 +export function stageBundledResources( + target, + { prepare = prepareBundledResources } = {}, +) { + const summaries = prepare({ + target, + features: new Set(defaultEditorFeatures(target)), + 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, + } = {}, ) { if ( explicitBuildTarget(args) && @@ -444,7 +464,10 @@ export function runTauriBuild( } const tauriArguments = buildTauriBuildArguments(args, context.target); const { channel, target } = context; - if (!args.includes('--no-bundle')) stageRuntime(target); + if (!args.includes('--no-bundle')) { + stageRuntime(target); + stageBundled(target); + } const configPath = writeChannelConfigFile( channel, target, 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 3cdd735c1..3d046d9d8 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -363,6 +363,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 }; @@ -456,6 +458,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( @@ -705,6 +709,7 @@ test('Windows remains the default and explicit Windows overrides macOS environme context, { stageRuntime: () => {}, + stageBundled: () => {}, spawn: (_binary, command) => { assert.ok( command.includes( @@ -818,6 +823,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( @@ -829,11 +838,14 @@ 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']); runTauriBuild(['--no-bundle', '--target', windowsTarget], context, { stageRuntime() { assert.fail('no-bundle must not stage resources'); }, + stageBundled() { + assert.fail('no-bundle must not stage bundled resources'); + }, spawn(_binary, args) { const config = JSON.parse( readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'), @@ -848,6 +860,9 @@ test('release stages Node before Tauri and injects its resource mapping only for stageRuntime() { throw new Error('missing runtime'); }, + stageBundled() { + assert.fail('invalid runtime must prevent bundled staging'); + }, spawn() { assert.fail('invalid runtime must prevent build'); }, @@ -960,6 +975,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/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs index d3a5e1237..023f19ae4 100755 --- a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -81,6 +81,15 @@ function expectInteger(value, at) { return value; } +// 随包子目录来源:`source` 由声明与仓库源码就能生成(准备步骤负责), +// `build` 由构建期工具链产出(构建脚本在产物生成后负责)。 +function expectOrigin(value, at) { + if (value !== 'source' && value !== 'build') { + fail(`${at} 必须是 source 或 build(实际 ${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}'); @@ -101,6 +110,21 @@ function renderStruct(name, fields, level = 0) { 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) { @@ -191,6 +215,7 @@ function parseDeclaration(raw) { const parsed = expectObject(entry, at); return { path: expectString(parsed.path, `${at}.path`), + origin: expectOrigin(parsed.origin, `${at}.origin`), targetContains: optionalStringArray(parsed, 'targetContains', at), targets: optionalStringArray(parsed, 'targets', at), features: optionalStringArray(parsed, 'features', at), @@ -226,6 +251,7 @@ function parseDeclaration(raw) { codex.manifestSchema, 'codex.manifestSchema', ), + packageMetadata: parsePackageMetadata(codex.packageMetadata), resourceDirectory: expectString( codex.resourceDirectory, 'codex.resourceDirectory', @@ -333,6 +359,7 @@ function renderSubdirectory(entry) { 'Subdirectory', [ ['path', rustString(entry.path)], + ['origin', rustString(entry.origin)], ['target_contains', rustStrings(entry.targetContains)], ['targets', rustStrings(entry.targets)], ['features', rustStrings(entry.features)], @@ -359,6 +386,14 @@ function renderGenerated(declaration) { const codex = declaration.codex; 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)], diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs index 7c44d30d4..b5538138f 100755 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -14,12 +14,14 @@ import { createHash, randomBytes } from 'node:crypto'; import { chmodSync, + closeSync, copyFileSync, - createReadStream, existsSync, mkdirSync, + openSync, readdirSync, readFileSync, + readSync, renameSync, rmSync, statSync, @@ -58,11 +60,19 @@ 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 = HOST_TRIPLES.get(`${platform}:${arch}`); + const triple = supportedHostTarget(platform, arch); if (!triple) { fail( `不支持的目标平台:${platform}/${arch}(随包资源声明只覆盖 ${[...HOST_TRIPLES.values()].join('、')})`, @@ -106,13 +116,21 @@ export function stagingUnit(declaration, target) { } function sha256File(file) { - return new Promise((resolve, reject) => { - const hash = createHash('sha256'); - createReadStream(file) - .on('data', (chunk) => hash.update(chunk)) - .on('end', () => resolve(hash.digest('hex'))) - .on('error', reject); - }); + 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) { @@ -176,6 +194,29 @@ export function findCodexSource(declaration, target, roots) { 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, @@ -272,13 +313,14 @@ function assertOwnedUnit(unitPath, declaration, unit) { } /// 期望产物:每个目标的组件摘要与清单文本。 -async function desiredCodex(declaration, unit, roots) { +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, await sha256File(path.join(source, relative))); + hashes.set(relative, sha256File(path.join(source, relative))); } desired.set(member.target, { source, @@ -294,7 +336,7 @@ async function desiredCodex(declaration, unit, roots) { return desired; } -async function unitMatchesExisting(unitPath, declaration, unit, desired) { +function unitMatchesExisting(unitPath, declaration, unit, desired) { if (!existsSync(unitPath)) { return false; } @@ -328,10 +370,7 @@ async function unitMatchesExisting(unitPath, declaration, unit, desired) { return false; } const info = statSync(filePath); - if ( - info.size !== file.size || - (await sha256File(filePath)) !== file.sha256 - ) { + if (info.size !== file.size || sha256File(filePath) !== file.sha256) { return false; } if (relative === member.layout.executable && (info.mode & 0o111) === 0) { @@ -362,7 +401,7 @@ function recordEntryFor(unitPath, declaration, unit, desired) { return { manifests, sizes }; } -async function unitRecordMatches(unitPath, declaration, unit, recorded) { +function unitRecordMatches(unitPath, declaration, unit, recorded) { if (!recorded) { return false; } @@ -461,7 +500,7 @@ function stageAtomically(unitPath, builder) { renameSync(stagingPath, unitPath); } -async function prepareCodex({ +function prepareCodex({ declaration, target, destinationRoot, @@ -483,17 +522,17 @@ async function prepareCodex({ const recorded = record.codex?.[unit.directory]; if ( recorded?.key === key && - (await unitRecordMatches(unitPath, declaration, unit, recorded)) + unitRecordMatches(unitPath, declaration, unit, recorded) ) { return { summary: `${label} 命中缓存(未写入)`, record: undefined }; } - const desired = await desiredCodex(declaration, unit, roots); + const desired = desiredCodex(declaration, unit, roots); const entry = { key, ...recordEntryFor(unitPath, declaration, unit, desired), }; - if (await unitMatchesExisting(unitPath, declaration, unit, desired)) { + if (unitMatchesExisting(unitPath, declaration, unit, desired)) { return { summary: `${label} 命中缓存(内容一致,未写入)`, record: entry }; } if (dryRun) { @@ -615,6 +654,9 @@ function pluginSourceFingerprint(declaration, plugins, target, features) { const lines = []; for (const plugin of plugins) { for (const subdirectory of declaration.plugins.subdirectories) { + if (subdirectory.origin !== 'source') { + continue; + } if (!subdirectoryEnabled(subdirectory, target, features)) { continue; } @@ -638,7 +680,7 @@ function pluginSourceFingerprint(declaration, plugins, target, features) { return sha256Text(lines.join('\n')); } -async function pluginTreeMatches( +function pluginTreeMatches( declaration, plugins, target, @@ -658,6 +700,9 @@ async function pluginTreeMatches( return false; } for (const subdirectory of declaration.plugins.subdirectories) { + if (subdirectory.origin !== 'source') { + continue; + } if (!subdirectoryEnabled(subdirectory, target, features)) { continue; } @@ -675,7 +720,7 @@ async function pluginTreeMatches( if (statSync(source).size !== statSync(staged).size) { return false; } - if ((await sha256File(source)) !== (await sha256File(staged))) { + if (sha256File(source) !== sha256File(staged)) { return false; } } @@ -698,7 +743,7 @@ function assertOwnedPluginRoot(destination, plugins) { } } -async function preparePlugins({ +function preparePlugins({ declaration, target, destinationRoot, @@ -719,13 +764,7 @@ async function preparePlugins({ ); if ( record.plugins?.fingerprint === fingerprint && - (await pluginTreeMatches( - declaration, - plugins, - target, - features, - destination, - )) + pluginTreeMatches(declaration, plugins, target, features, destination) ) { return { summary: `plugins 命中缓存(未写入,${plugins.length} 个插件)`, @@ -747,6 +786,9 @@ async function preparePlugins({ path.join(pluginDestination, declaration.plugins.manifestFileName), ); for (const subdirectory of declaration.plugins.subdirectories) { + if (subdirectory.origin !== 'source') { + continue; + } if (!subdirectoryEnabled(subdirectory, target, features)) { continue; } @@ -765,7 +807,7 @@ async function preparePlugins({ } /// 准备全部随包资源;返回逐条汇总,供入口日志与测试断言。 -export async function prepareBundledResources({ +export function prepareBundledResources({ target = resolveHostTarget(), destinationRoot = SRC_TAURI_DIR, declarationPath = DECLARATION_PATH, @@ -780,7 +822,7 @@ export async function prepareBundledResources({ const record = readRecord(recordPath); const summaries = []; let changed = false; - const codex = await prepareCodex({ + const codex = prepareCodex({ declaration, target, destinationRoot, @@ -802,7 +844,7 @@ export async function prepareBundledResources({ target.includes(needle), ) ) { - const plugins = await preparePlugins({ + const plugins = preparePlugins({ declaration, target, destinationRoot, @@ -844,9 +886,9 @@ function parseArguments(argv) { return args; } -async function main(argv) { +function main(argv) { const args = parseArguments(argv); - const summaries = await prepareBundledResources({ + const summaries = prepareBundledResources({ target: args.target ?? resolveHostTarget(), destinationRoot: args.destinationRoot ?? SRC_TAURI_DIR, dryRun: args.dryRun, @@ -860,11 +902,13 @@ if ( process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) ) { - main(process.argv.slice(2)).catch((error) => { + 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 index 4876a3836..b3cf4479b 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs @@ -43,7 +43,23 @@ function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) { for (const relative of layout.files) { const file = path.join(vendor, relative); fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, `component ${target} ${relative}\n`); + 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); } @@ -152,10 +168,10 @@ function prepare(fixture, overrides = {}) { }); } -test('stages declared codex components with manifest and preserved notice', async () => { +test('stages declared codex components with manifest and preserved notice', () => { const fixture = buildFixture(); try { - const summaries = await prepare(fixture); + const summaries = prepare(fixture); assert.match(summaries[0], /codex x86_64-pc-windows-msvc 重新生成/); const declaration = fixture.declaration; @@ -209,10 +225,10 @@ test('stages declared codex components with manifest and preserved notice', asyn } }); -test('second run is a no-op: identical content and timestamps', async () => { +test('second run is a no-op: identical content and timestamps', () => { const fixture = buildFixture(); try { - await prepare(fixture); + prepare(fixture); const unit = path.join( fixture.destinationRoot, 'resources/codex', @@ -220,7 +236,7 @@ test('second run is a no-op: identical content and timestamps', async () => { ); const plugins = path.join(fixture.destinationRoot, 'resources/plugins'); const before = { codex: snapshot(unit), plugins: snapshot(plugins) }; - const summaries = await prepare(fixture); + const summaries = prepare(fixture); assert.match(summaries[0], /命中缓存/); assert.match(summaries[1], /命中缓存/); assert.deepEqual( @@ -238,12 +254,12 @@ test('second run is a no-op: identical content and timestamps', async () => { } }); -test('stages the macOS universal group with both architectures', async () => { +test('stages the macOS universal group with both architectures', () => { const fixture = buildFixture({ targets: [MAC_TARGET, 'x86_64-apple-darwin'], }); try { - const summaries = await prepare(fixture, { target: MAC_TARGET }); + const summaries = prepare(fixture, { target: MAC_TARGET }); assert.match(summaries[0], /mac-native/); const unit = path.join( fixture.destinationRoot, @@ -270,10 +286,10 @@ test('stages the macOS universal group with both architectures', async () => { } }); -test('copies only whitelisted plugin subdirectories', async () => { +test('copies only whitelisted plugin subdirectories', () => { const fixture = buildFixture(); try { - await prepare(fixture); + prepare(fixture); const staged = path.join( fixture.destinationRoot, 'resources/plugins/agc-demo-editor', @@ -296,14 +312,14 @@ test('copies only whitelisted plugin subdirectories', async () => { } }); -test('fails closed when the upstream package is missing', async () => { +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, }); - await assert.rejects(prepare(fixture), /npm ci/); + assert.throws(() => prepare(fixture), /npm ci/); assert.ok( !fs.existsSync( path.join( @@ -321,11 +337,11 @@ test('fails closed when the upstream package is missing', async () => { } }); -test('fails closed for unsupported targets', async () => { +test('fails closed for unsupported targets', () => { const fixture = buildFixture(); try { - await assert.rejects( - prepare(fixture, { target: 'x86_64-unknown-linux-gnu' }), + assert.throws( + () => prepare(fixture, { target: 'x86_64-unknown-linux-gnu' }), /声明不含目标/, ); assert.throws(() => resolveHostTarget('linux', 'x64'), /不支持的目标平台/); @@ -334,28 +350,28 @@ test('fails closed for unsupported targets', async () => { } }); -test('fails closed when the destination is owned by something else', async () => { +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'); - await assert.rejects(prepare(fixture), /被非本工具内容占用/); + 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, }); - await assert.rejects(prepare(fixture), /插件随包目录被非本工具内容占用/); + assert.throws(() => prepare(fixture), /插件随包目录被非本工具内容占用/); } finally { fixture.cleanup(); } }); -test('dry run writes nothing', async () => { +test('dry run writes nothing', () => { const fixture = buildFixture(); try { - const summaries = await prepare(fixture, { dryRun: true }); + const summaries = prepare(fixture, { dryRun: true }); assert.match(summaries[0], /需要重新生成(dry-run 未写入)/); const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64'); assert.deepEqual( 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 3d72403ae..47a6eaeb3 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -11,6 +11,10 @@ import { resolveAgcDevEndpoint, withAgcDevEndpointEnv, } from './dev-port.mjs'; +import { + prepareBundledResources, + supportedHostTarget, +} from './prepare-bundled-resources.mjs'; import { isAiGameCreatorServer, preflightExistingVite, @@ -68,6 +72,29 @@ function withDevCargoFeatures(argv, features = readDevCargoFeatures()) { return withDefaultCargoFeatures(argv, features); } +/// 随包资源必须在 Tauri 之前生成:构建脚本只做只读校验,不再生成资源。 +/// 命中缓存的重复调用不写任何文件,因此每次 dev 启动都会先跑一次。 +function prepareBundledResourcesBeforeTauri( + features = readDevCargoFeatures(), + { prepare = prepareBundledResources, log = console.log } = {}, +) { + const target = supportedHostTarget(); + if (!target) { + log( + '[ai-game-creator-shell] 当前平台不受随包资源声明覆盖,跳过随包资源准备', + ); + return; + } + const summaries = prepare({ + target, + features: new Set(features), + 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, @@ -96,6 +123,7 @@ async function runTauriDev( spawnCli = spawnTauriCli, waitForCli = waitForChildTermination, terminateTree = terminateChildTree, + prepareResources = prepareBundledResourcesBeforeTauri, } = {}, ) { const endpoint = await resolveDevEndpoint(); @@ -142,8 +170,10 @@ async function runTauriDev( shutdownRequested.then(() => false), ]); if (!prepared || shutdownSignal) return 1; + const devFeatures = readDevCargoFeatures(); + prepareResources(devFeatures); const tauriArguments = buildTauriArguments( - withDevCargoFeatures(argv), + withDevCargoFeatures(argv, devFeatures), endpoint.url, ); child = spawnCli(tauriArguments, { @@ -236,6 +266,7 @@ export { buildTauriArguments, buildTauriDevProcessEnv, isDirectModuleExecution, + prepareBundledResourcesBeforeTauri, runTauriDev, spawnTauriCli, withDevCargoFeatures, diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index 52a397bce..948438b62 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"] @@ -17,145 +18,6 @@ use std::path::PathBuf; use codex_bundle::package_layout; -fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { - let target = env::var("TARGET").expect("Cargo TARGET"); - let resource_directory = manifest_dir.join(package_layout::codex().resource_directory); - if let Some(group) = package_layout::codex_universal_group(&target) { - // Tauri 的 universal 两次 Cargo 编译共用 resource staging, - // 每次都生成完整双架构目录,最终 bundle 不取决于最后编译的切片。 - let staging = resource_directory.join(group.directory); - if staging.exists() { - fs::remove_dir_all(&staging).expect("清理 macOS Codex staging 失败"); - } - for member in group.targets { - stage_codex_target(manifest_dir, member); - } - } 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 source_candidates = - package_layout::codex_source_candidates(app_root, repo_root, target) - .unwrap_or_else(|error| panic!("{error}")); - 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(package_layout::codex().package_metadata_file_name)) - .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(package_layout::codex().resource_directory) - .join(layout.directory); - let notice = target_dir.join(package_layout::codex().notice_file_name); - if let Some(notice_source) = package_layout::codex_notice_source(target) { - if !notice_source.preserve { - // 受版本控制的第三方声明由此处复制;`preserve` 的声明文件本身已在随包目录内。 - let source_notice = manifest_dir.join(notice_source.source); - 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 资源目录失败"); - 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 = - package_layout::sha256_file(&source_path).expect("读取内置 Codex CLI 资源失败"); - let target_matches_source = target_path.is_file() - && package_layout::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(package_layout::codex().manifest_file_name); - 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()); - } -} - fn seed_task_group_id( group: &shared_contracts::game_creation_app::GameCreationAppAgentGroup, ) -> &'static str { @@ -216,76 +78,22 @@ fn validate_staged_resources(manifest_dir: &std::path::Path) { validate_staged_plugin_workspace(manifest_dir, &target); } -/// 只读校验插件随包工作区:插件清单与声明的必需子目录齐备、整树无符号链接、无越界路径。 -/// -/// 插件产物的逐文件摘要校验在准备步骤接管写入后启用——在那之前构建脚本仍会整体重建 -/// `resources/plugins`,任何写在树内的准备步骤清单都会被清掉。 +/// 只读校验插件随包工作区:声明的源码派生内容必须与仓库源码逐文件一致,整树无符号链接。 fn validate_staged_plugin_workspace(manifest_dir: &std::path::Path, target: &str) { - if !package_layout::plugin_staging_applies(target) { - return; - } let declared = package_layout::plugins(); - let destination_root = manifest_dir.join(declared.destination_directory); - if !destination_root.is_dir() { - panic!("插件随包资源目录缺失:{}", destination_root.display()); - } let repo_root = manifest_dir .parent() .and_then(|app_root| app_root.parent()) .and_then(|apps_dir| apps_dir.parent()) .expect("AGC 应用必须位于仓库 apps 目录下"); - if let Ok(entries) = std::fs::read_dir(repo_root.join(declared.source_directory)) { - for entry in entries.flatten() { - let plugin_root = entry.path(); - if !plugin_root.is_dir() || !plugin_root.join(declared.manifest_file_name).is_file() { - continue; - } - let name = entry.file_name(); - let staged = destination_root.join(&name); - if !staged.join(declared.manifest_file_name).is_file() { - panic!( - "随包插件缺少清单:{}", - staged.join(declared.manifest_file_name).display() - ); - } - for subdirectory in declared.subdirectories { - if !package_layout::subdirectory_enabled( - subdirectory, - target, - package_layout::cargo_feature_enabled, - ) { - continue; - } - let relative = package_layout::declared_relative_path(subdirectory.path); - if plugin_root.join(&relative).is_dir() && !staged.join(&relative).is_dir() { - panic!( - "随包插件缺少必需目录:{}(插件 {})", - staged.join(&relative).display(), - name.to_string_lossy() - ); - } - } - for staging in declared.library_staging { - if 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); - if !staged.join(&relative).is_dir() { - panic!("随包库目录缺失:{}", staged.join(&relative).display()); - } - } - } - } - package_layout::collect_tree_files(&destination_root) - .unwrap_or_else(|error| panic!("插件随包资源校验失败:{error}")); + 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"), @@ -299,10 +107,9 @@ fn main() { // AGC_SKIP_RESOURCE_STAGING=1 只做只读校验(要求随包资源已由准备步骤生成), // 用于在既有产物上单独验证校验路径。 if env::var_os("AGC_SKIP_RESOURCE_STAGING").is_none() { - stage_bundled_codex_cli(&manifest_dir); prepare_unity_editor_helper(&manifest_dir); prepare_godot_editor_extension(&manifest_dir); - stage_plugin_workspace(&manifest_dir); + stage_build_generated_plugin_payloads(&manifest_dir); stage_cocos_editor_payload(&manifest_dir); } validate_staged_resources(&manifest_dir); @@ -508,11 +315,11 @@ fn prepare_godot_editor_extension(manifest_dir: &std::path::Path) { 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) { +/// 源码派生的子目录由准备步骤在 `tauri dev|build` 之前写入;这里只补构建期才存在的产物。 +/// 把这批产物也归位到准备步骤(连同编辑器分支产物)在后续里程碑完成。 +fn stage_build_generated_plugin_payloads(manifest_dir: &std::path::Path) { let target = env::var("TARGET").expect("Cargo TARGET"); if !package_layout::plugin_staging_applies(&target) { return; @@ -522,50 +329,36 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) { .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(declared.source_directory); + .expect("AGC 应用必须位于仓库 apps 目录下"); let destination_root = manifest_dir.join(declared.destination_directory); - // 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(declared.manifest_file_name).is_file() { - continue; - } - let name = entry.file_name(); - let destination = destination_root.join(&name); - copy_plugin_file( - &plugin_root.join(declared.manifest_file_name), - &destination.join(declared.manifest_file_name), - ); + let plugins = package_layout::plugin_directories( + &repo_root.join(declared.source_directory), + declared.manifest_file_name, + ) + .unwrap_or_else(|error| panic!("{error}")); + for plugin in plugins { for subdirectory in declared.subdirectories { - if !package_layout::subdirectory_enabled( - subdirectory, - &target, - package_layout::cargo_feature_enabled, - ) { + if !package_layout::subdirectory_is_build_derived(subdirectory) + || !package_layout::subdirectory_enabled( + subdirectory, + &target, + package_layout::cargo_feature_enabled, + ) + { continue; } let relative = package_layout::declared_relative_path(subdirectory.path); - copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative)); + let source = plugin.path.join(&relative); + if !source.is_dir() { + continue; + } + copy_staged_tree( + &source, + &destination_root.join(&plugin.name).join(&relative), + ); } for staging in declared.library_staging { - if name != staging.plugin + if plugin.name != staging.plugin || !package_layout::library_staging_enabled( staging, &target, @@ -577,8 +370,8 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) { let relative = package_layout::declared_relative_path(staging.source_subdirectory); match staging.layout { "godot-bundle" => godot_bundle::stage( - &plugin_root.join(&relative), - &destination.join(&relative), + &plugin.path.join(&relative), + &destination_root.join(&plugin.name).join(&relative), &target, true, ) @@ -586,60 +379,37 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) { other => panic!("未实现的随包库 staging 布局:{other}"), } } - 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) { +/// 复制一棵目录树(按声明跳过构建产物与测试文件);内容一致时不重写。 +fn copy_staged_tree(source: &std::path::Path, destination: &std::path::Path) { + if !source.is_dir() { 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()); + fs::create_dir_all(destination).expect("创建插件资源目录失败"); + for entry in fs::read_dir(source).expect("读取插件资源失败").flatten() { let path = entry.path(); + let file_name = entry.file_name(); + let name = file_name.to_string_lossy().to_string(); + let target = destination.join(&file_name); assert!( - !entry + entry .file_type() .expect("读取插件文件类型失败") .is_symlink(), "插件资源不允许符号链接" ); if path.is_dir() { - let name = entry.file_name(); - if package_layout::skip_directory(&name.to_string_lossy()) { + if !package_layout::skip_directory(&name) { + copy_staged_tree(&path, &target); + } + } else if !package_layout::skip_file_name(&name) { + let bytes = fs::read(&path).expect("读取插件资源失败"); + if fs::read(&target).is_ok_and(|existing| existing == bytes) { continue; } - std::fs::create_dir_all(&target).expect("创建插件资源目录失败"); - copy_plugin_tree(&path, &target); - } else { - // 测试文件与隐藏文件不随包分发。 - let name = entry.file_name(); - if package_layout::skip_file_name(&name.to_string_lossy()) { - continue; - } - stage_plugin_file(&path, &target); + fs::write(&target, bytes).expect("复制插件资源失败"); } } } - -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_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/package-layout.generated.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs index ebfe90b06..3ae75c1cb 100644 --- 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 @@ -12,6 +12,11 @@ pub const CODEX_CLI_VERSION: &str = "codex-cli 0.155.1"; pub const CODEX_MANIFEST_SCHEMA: &str = "genarrative-codex-sidecar.v2"; pub const CODEX: Codex = Codex { + 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", @@ -70,30 +75,35 @@ pub const PLUGINS: Plugins = Plugins { subdirectories: &[ Subdirectory { path: "src", + origin: "source", target_contains: &[], targets: &[], features: &[], }, Subdirectory { path: "panels", + origin: "source", target_contains: &[], targets: &[], features: &[], }, Subdirectory { path: "skills", + origin: "source", target_contains: &[], targets: &[], features: &[], }, Subdirectory { path: "native/payload", + origin: "source", target_contains: &["windows"], targets: &[], features: &[], }, Subdirectory { path: "dotnet/publish/win-x64", + origin: "build", target_contains: &[], targets: &["x86_64-pc-windows-msvc"], features: &["unity-editor-execute"], 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 index 21ac80bbe..56fb12ed2 100644 --- 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 @@ -6,6 +6,11 @@ "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", @@ -83,12 +88,13 @@ "manifestFileName": "plugin.json", "targetContainsAny": ["windows", "apple-darwin"], "subdirectories": [ - { "path": "src" }, - { "path": "panels" }, - { "path": "skills" }, - { "path": "native/payload", "targetContains": ["windows"] }, + { "path": "src", "origin": "source" }, + { "path": "panels", "origin": "source" }, + { "path": "skills", "origin": "source" }, + { "path": "native/payload", "origin": "source", "targetContains": ["windows"] }, { "path": "dotnet/publish/win-x64", + "origin": "build", "targets": ["x86_64-pc-windows-msvc"], "features": ["unity-editor-execute"] } 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 index 28ae352b6..9ce439f32 100644 --- 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 @@ -37,10 +37,20 @@ pub struct NoticeSource { 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, + /// `source`:由声明与仓库源码就能生成(准备步骤负责);`build`:构建期工具链产出(构建脚本负责)。 + pub origin: &'static str, pub target_contains: &'static [&'static str], pub targets: &'static [&'static str], pub features: &'static [&'static str], @@ -58,6 +68,7 @@ pub struct LibraryStaging { #[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, @@ -236,6 +247,158 @@ pub fn declared_relative_path(relative: &str) -> PathBuf { relative.split('/').collect() } +/// 只读校验插件随包工作区。 +/// +/// 声明为源码派生的内容必须与仓库源码逐文件一致(清单 + 逐文件 sha256),构建期派生的子目录 +/// 只要求存在(内容由产出它的构建步骤负责),整树不得出现符号链接;编辑器分支产物不动。 +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(()); + } + if !destination_root.is_dir() { + return Err(format!( + "插件随包资源目录缺失:{}", + destination_root.display() + )); + } + for plugin in plugin_directories(source_root, plugins().manifest_file_name)? { + 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())); + } + if sha256_file(&source_manifest).ok() != sha256_file(&staged_manifest).ok() { + return Err(format!( + "随包插件清单与源码不一致:{}", + staged_manifest.display() + )); + } + for subdirectory in plugins().subdirectories { + if !subdirectory_enabled(subdirectory, target, &feature_enabled) { + continue; + } + let relative = declared_relative_path(subdirectory.path); + let source = plugin.path.join(&relative); + let staged_directory = staged.join(&relative); + if subdirectory_is_build_derived(subdirectory) { + if source.exists() && !staged_directory.is_dir() { + return Err(format!( + "随包构建期产物缺失:{}(插件 {})", + staged_directory.display(), + plugin.name + )); + } + continue; + } + if !source.is_dir() { + continue; + } + if !staged_directory.is_dir() { + return Err(format!( + "随包插件缺少必需目录:{}(插件 {})", + staged_directory.display(), + plugin.name + )); + } + for relative_file in collect_sources(&source)? { + let source_file = source.join(declared_relative_path(&relative_file)); + let staged_file = staged_directory.join(declared_relative_path(&relative_file)); + if !staged_file.is_file() { + return Err(format!("随包插件缺少文件:{}", staged_file.display())); + } + if sha256_file(&source_file).ok() != sha256_file(&staged_file).ok() { + return Err(format!( + "随包插件文件与源码不一致:{}", + staged_file.display() + )); + } + } + } + } + collect_tree_files(destination_root)?; + Ok(()) +} + +/// 声明为「由准备步骤按源码派生」的子目录。 +pub fn subdirectory_is_source_derived(subdirectory: &Subdirectory) -> bool { + subdirectory.origin == "source" +} + +/// 声明为「由构建期工具链产出」的子目录。 +pub fn subdirectory_is_build_derived(subdirectory: &Subdirectory) -> bool { + subdirectory.origin == "build" +} + +/// 仓库插件目录:含清单文件的普通目录,按名字排序。 +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/平台/版本一致、清单文件集合与组件白名单完全一致、 @@ -681,4 +844,151 @@ mod tests { 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 build = PLUGINS + .subdirectories + .iter() + .filter(|entry| subdirectory_is_build_derived(entry)) + .map(|entry| entry.path) + .collect::>(); + assert_eq!(source, ["src", "panels", "skills", "native/payload"]); + assert_eq!(build, ["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"); + } + + #[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 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"]); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs index 918e1e6df..707e5e29a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -8,11 +8,6 @@ use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; #[path = "../../build_support/codex_bundle.rs"] pub(crate) mod codex_bundle; -// 复用构建端校验的既有单测,生产运行时只编译共享布局。 -#[cfg(test)] -#[path = "../../build_support/codex_package_metadata.rs"] -mod codex_package_metadata; - const GAME_CREATOR_CODEX_CLI_EXECUTABLE: &str = "codex"; const GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024; const GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES: usize = 4 * 1024 * 1024; diff --git a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts index dd83b82a8..5cfcd7d3f 100644 --- a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts +++ b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts @@ -28,7 +28,13 @@ const resolveTestEndpoint = async () => 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; @@ -153,6 +159,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; @@ -172,6 +182,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { expect(order).toEqual([ 'preflight', 'frontend-ready', + 'resources', 'spawn', 'exit', 'cleanup', diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md index 62bb4160e..9fb199b27 100644 --- a/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md @@ -3,7 +3,7 @@ | 字段 | 值 | | ----------- | ----------------------------------------------------------- | | Version | 1.0 | -| Status | in-progress(2026-09-27 起实施 M1) | +| Status | completed(2026-09-27) | | Date | 2026-09-26 | | Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md index 72d4a340b..598b6b7f0 100644 --- a/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md @@ -2,9 +2,9 @@ | 字段 | 值 | | ----------- | --------------------------------------------------------------- | -| Version | 1.0 | -| Status | proposed | -| Date | 2026-09-26 | +| Version | 1.0 | +| Status | in-progress(2026-09-27 起实施) | +| Date | 2026-09-26 | | Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | ## 目标 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 43d8879a5..ab546dd32 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9588,3 +9588,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响面:`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` 9 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 启动链路。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index a7e6b7763..730f133a0 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -12,6 +12,12 @@ > 策划历史条目边界:旧策划 V1/V2 已全部退役,当前入口仅使用 Design Agent。下文带日期的旧 Planning V2、Fast GDD、`plan.submit_gdd`、旧 IPC/模块记录仅用于追溯,不能作为恢复旧代码、身份门禁或专属测试的依据;共享问题需在现役调用上核查。现行合同见[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。 +## 2026-09-27 随包资源的写入方按产物来源分界:源码派生归准备步骤,构建期产物仍归构建脚本 + +- **现象**:改造后看到 `resources/plugins` 下 `dotnet/publish/win-x64`、`native/gdextension`、`native/payload` 由构建过程补写,不是回归:这三处只有构建过程才产得出来(Unity helper 的 dotnet publish、Godot gdextension、Cocos cdylib),声明里对应 `origin: build` 或 `libraryStaging`。 +- **写法**:新增随包内容先判断来源——能从仓库源码复制就写进 `build_support/package-layout.json` 的 `subdirectories`(`origin: source`,准备步骤负责);需要外部工具链或同一次 cargo 构建产出的,留在构建脚本并在声明里标注,不要塞进准备步骤(它在 `tauri build` 之前运行,拿不到那些产物)。 +- **校验口径**:`origin: source` 的内容在构建期会与仓库源码逐文件比对(插件清单 + 逐文件 sha256 + 整树符号链接),手改 `resources/**` 会被 `cargo build` 直接拒绝;`origin: build` 只查存在性。`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 常量不一致」。 diff --git a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md index 72f9f4c53..96794f88d 100644 --- a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md +++ b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md @@ -158,6 +158,18 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 准备步骤的验证入口:`AGC_SKIP_RESOURCE_STAGING=1 cargo build/check …` 只跑只读校验、跳过写入分支,用于在既有产物上单独验证校验路径。 +### 4.9 M2 实况:构建期写入边界 + +入口接线后,「谁写随包资源」按**产物来源**分界(声明里的 `origin` 字段表达同一口径): + +| 来源 | 例子 | 谁写 | 时机 | +| --- | --- | --- | --- | +| `source`:声明 + 仓库源码即可生成 | `resources/codex/**`、插件工作区的 `src`/`panels`/`skills`/`native/payload` | 准备步骤(Node) | `tauri dev` / `tauri build` 之前 | +| `build`:只有构建过程才产出 | 插件工作区的 `dotnet/publish/win-x64`(Unity helper 发布物) | 构建脚本 | 产物生成之后、只读校验之前 | +| 外部工具链产物 | `native/gdextension`(Godot)、Cocos payload | 构建脚本 | 同上(本里程碑不动,归位属 M3) | + +因此本里程碑后:Codex 与插件工作区的源码派生内容不再由构建脚本写入(macOS 上已实测连续三次 `cargo build --no-default-features` 为 0.69 / 0.22 / 0.22 秒全程 fresh);Windows 上仍有三处构建期写入落在 `resources/plugins/**`(Unity publish 目录、Godot gdextension、Cocos payload),Windows 的构建新鲜度要等 M3 把这三处也归位到准备步骤(准备步骤先跑各自的构建命令,再复制产物)才达标。 + ## 5. 兼容与迁移 1. **产物兼容**:包内路径、manifest schema(`genarrative-codex-sidecar.v2`、`agc-node-runtime.v1`、插件 `plugin.json`)不变,安装包内容逐项对得上;升级路径不需要用户侧动作。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index ca2b87150..df0b3b975 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -88,6 +88,8 @@ Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter- AGC 随包资源(内置 Codex CLI、插件工作区)的布局与组件白名单只有一份人工声明:`apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`。Node 侧准备步骤直接读它,Rust 侧读由 `node scripts/check-package-layout.mjs --write`(仓库根 `npm run agc:bundled-resources:sync`)生成的 `build_support/package-layout.generated.rs`;门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链,两者不一致直接失败。改布局只能改声明文件再同步生成物,不要手改生成文件,也不要另写第二份白名单。准备步骤是 `node apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`:写临时目录后原子替换、命中缓存不写任何文件、只替换本工具产物、失败即退出并给出可执行提示;其用例为 `npm run agc:bundled-resources:test`。内置 Codex CLI 的上游平台包来自仓库根 `npm ci`,缺失时工具会直接提示重新安装。构建脚本对既有随包产物做只读校验,`AGC_SKIP_RESOURCE_STAGING=1` 可跳过写入分支、只跑校验。 +随包资源由准备步骤在 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 并列调用,`tauri build --no-bundle` 不强制 staging。构建脚本不再生成这些资源(只对既有产物做只读校验,`AGC_SKIP_RESOURCE_STAGING=1` 可只跑校验),因此源码不变时 `cargo build` 稳定 fresh。`resources/plugins` 由准备步骤拥有:不要手工往它下面放东西,准备步骤会按仓库 `plugins/` 与声明重建;**编辑器分支产物**(Unity `dotnet/publish/win-x64`、Godot `native/gdextension`、Cocos payload)目前仍由构建脚本在产物生成后写入,归位属后续里程碑。 + ### 本地 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 身份。