#!/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; } }