d2d78fa3d4
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m34s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 2m3s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m7s
Project CI / Repository checks (pull_request) Failing after 25s
Project CI / Native shell tests (pull_request) Failing after 1m44s
Project CI / Frontend tests (pull_request) Successful in 2m12s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m3s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m30s
修复 --no-bundle 构建前的资源准备接线。 拒绝插件随包目录中的未声明文件并同步Rust校验。 增加staging替换失败后的旧资源恢复与回归测试。
1756 lines
52 KiB
JavaScript
Executable File
1756 lines
52 KiB
JavaScript
Executable File
#!/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 <triple>] [--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('<platform>', layout.platform)
|
||
.replaceAll('<target>', 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 (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,
|
||
'插件资源',
|
||
)) {
|
||
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) {
|
||
allowedFiles.add(
|
||
`${plugin.name}/${staging.sourceSubdirectory}/${relative}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
for (const payload of declaration.plugins.nativePayloads ?? []) {
|
||
if (
|
||
plugin.name === payload.plugin &&
|
||
nativePayloadEnabled(payload, target, features)
|
||
) {
|
||
allowedFiles.add(
|
||
`${plugin.name}/${payload.destinationSubdirectory}/${payload.destinationFileName ?? payload.sourceFileName}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
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} <value>);拒绝回退到宿主默认值,以免准备错目标`,
|
||
);
|
||
}
|
||
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;
|
||
}
|
||
}
|