AGC 随包资源准备步骤接入 dev 与发布入口,构建脚本退出写入
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
- start-tauri-dev.mjs 在前端与配套后端就绪后、spawn Tauri CLI 之前调用准备步骤,并保留依赖注入供入口测试断言顺序 - build-release.mjs 的 runTauriBuild 与既有 stageRuntime 并列调用 stageBundledResources,tauri build --no-bundle 仍不强制 staging - 声明新增 origin 字段:source 由准备步骤写,build 由构建脚本在产物生成后写;构建脚本删除 codex 与插件白名单的写入分支及 stage_plugin_file/copy_plugin_tree/copy_plugin_file,改为 stage_build_generated_plugin_payloads - 插件随包工作区改为与仓库源码逐文件比对(清单 + 逐文件 sha256 + 整树符号链接),实现移入 build_support/package_layout.rs 复用单测 - 上游原生包元数据改由准备步骤按声明校验,build_support/codex_package_metadata.rs 因失去调用方删除 - 准备步骤改为同步实现,dev 与发布入口可直接调用而无需子进程 - 测试:build-release 39 passed(新增 stage、bundled、build 顺序与 no-bundle 不 staging)、dev 入口 12 passed(新增准备步骤先于 CLI 启动)、准备步骤 9 passed、package_layout 36 passed - 文档:技术方案 §4.9 记录构建期写入边界,M1 里程碑标记完成,运维文档补入口接线与 resources/plugins 所有权,决策日志与排障经验同步
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
.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("复制插件资源失败");
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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<Vec<PluginDirectory>, 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<BTreeSet<String>, 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::<Vec<_>>();
|
||||
let build = PLUGINS
|
||||
.subdirectories
|
||||
.iter()
|
||||
.filter(|entry| subdirectory_is_build_derived(entry))
|
||||
.map(|entry| entry.path)
|
||||
.collect::<Vec<_>>();
|
||||
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::<Vec<_>>(), ["src/entry.mjs"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -28,7 +28,13 @@ const resolveTestEndpoint = async () => testEndpoint;
|
||||
const runTauriDev = (
|
||||
argv: string[],
|
||||
options: Parameters<typeof runTauriDevImpl>[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',
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
## 目标
|
||||
|
||||
@@ -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 启动链路。
|
||||
|
||||
@@ -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 常量不一致」。
|
||||
|
||||
@@ -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`)不变,安装包内容逐项对得上;升级路径不需要用户侧动作。
|
||||
|
||||
@@ -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 身份。
|
||||
|
||||
Reference in New Issue
Block a user