e8403e547b
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m15s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 4m33s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m33s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m29s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m19s
Project CI / AI game creator shell Rust crates (push) Successful in 1m53s
Project CI / Frontend tests (push) Successful in 4m10s
Project CI / Repository checks (push) Successful in 3m19s
Project CI / Native shell tests (push) Successful in 6m39s
Project CI / Backend tests (push) Successful in 7m27s
Project CI / AI game creator shell web tests (push) Successful in 3m6s
新增 AGC 完整游戏生产 workflow Skill,定义策划、资源、实现、构建、试玩和交付阶段。 将真实美术生成、处理、登记和接入从可选提示改为新游戏交付链路要求。 同步 Skill Pack 清单、系统提示、测试契约和实施计划文档。
215 lines
6.7 KiB
JavaScript
215 lines
6.7 KiB
JavaScript
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { TextDecoder } from 'node:util';
|
|
|
|
export const SKILL_PACK_SCHEMA_VERSION = 'agc-skill-pack.v1';
|
|
export const EXPECTED_SKILL_NAMES = Object.freeze([
|
|
'agc-browser-playtest',
|
|
'agc-client-projection',
|
|
'agc-game-production-workflow',
|
|
'agc-project-structure',
|
|
'agc-web-game-development',
|
|
'taonier-art-assets',
|
|
]);
|
|
|
|
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
|
|
const defaultRoot = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
'../src-tauri/resources/agc-skills',
|
|
);
|
|
|
|
function canonicalTextBytes(filePath) {
|
|
const decoded = utf8Decoder.decode(fs.readFileSync(filePath));
|
|
return Buffer.from(decoded.replaceAll('\r\n', '\n'), 'utf8');
|
|
}
|
|
|
|
export function isSafeSkillRelativePath(value) {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
value.length === 0 ||
|
|
value.includes('\\') ||
|
|
value.includes(':') ||
|
|
value.startsWith('/')
|
|
) {
|
|
return false;
|
|
}
|
|
return value
|
|
.split('/')
|
|
.every(
|
|
(segment) => segment.length > 0 && segment !== '.' && segment !== '..',
|
|
);
|
|
}
|
|
|
|
function skillFilePath(rootDir, skillName, relativePath) {
|
|
if (!isSafeSkillRelativePath(relativePath)) {
|
|
throw new Error(`Skill ${skillName} 包含不安全相对路径: ${relativePath}`);
|
|
}
|
|
const target = path.resolve(rootDir, skillName, ...relativePath.split('/'));
|
|
const skillRoot = path.resolve(rootDir, skillName);
|
|
const prefix = `${skillRoot}${path.sep}`;
|
|
if (!target.startsWith(prefix)) {
|
|
throw new Error(`Skill ${skillName} 路径越过审核根目录: ${relativePath}`);
|
|
}
|
|
return target;
|
|
}
|
|
|
|
export function computeSkillContentFingerprint(rootDir, entry) {
|
|
const digest = crypto.createHash('sha256');
|
|
for (const relativePath of [...entry.files].sort()) {
|
|
const filePath = skillFilePath(rootDir, entry.name, relativePath);
|
|
const bytes = canonicalTextBytes(filePath);
|
|
digest.update(relativePath, 'utf8');
|
|
digest.update(Buffer.from([0]));
|
|
digest.update(bytes);
|
|
digest.update(Buffer.from([0]));
|
|
}
|
|
return digest.digest('hex');
|
|
}
|
|
|
|
function collectBundledFiles(rootDir) {
|
|
const files = [];
|
|
const walk = (directory, prefix) => {
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
const absolutePath = path.join(directory, entry.name);
|
|
if (entry.isSymbolicLink()) {
|
|
throw new Error(`内置 AGC Skill 不允许符号链接: ${relativePath}`);
|
|
}
|
|
if (entry.isDirectory()) {
|
|
walk(absolutePath, relativePath);
|
|
} else if (entry.isFile()) {
|
|
files.push(relativePath.replaceAll('\\', '/'));
|
|
} else {
|
|
throw new Error(`内置 AGC Skill 文件类型不受支持: ${relativePath}`);
|
|
}
|
|
}
|
|
};
|
|
walk(rootDir, '');
|
|
return files.filter((file) => file !== 'manifest.json').sort();
|
|
}
|
|
|
|
function readManifest(rootDir) {
|
|
const manifestPath = path.join(rootDir, 'manifest.json');
|
|
return {
|
|
manifestPath,
|
|
manifest: JSON.parse(fs.readFileSync(manifestPath, 'utf8')),
|
|
};
|
|
}
|
|
|
|
function validateManifestShape(rootDir, manifest) {
|
|
if (manifest?.schemaVersion !== SKILL_PACK_SCHEMA_VERSION) {
|
|
throw new Error('内置 AGC Skill 清单 schemaVersion 不受支持');
|
|
}
|
|
if (typeof manifest.version !== 'string' || manifest.version.trim() === '') {
|
|
throw new Error('内置 AGC Skill 清单缺少版本');
|
|
}
|
|
if (!Array.isArray(manifest.skills)) {
|
|
throw new Error('内置 AGC Skill 清单缺少 skills 数组');
|
|
}
|
|
const names = manifest.skills.map((entry) => entry?.name);
|
|
if (
|
|
names.length !== EXPECTED_SKILL_NAMES.length ||
|
|
[...names].sort().join('\n') !== [...EXPECTED_SKILL_NAMES].sort().join('\n')
|
|
) {
|
|
throw new Error('内置 AGC Skill 清单不等于审核白名单');
|
|
}
|
|
|
|
const declaredFiles = new Set();
|
|
const mismatches = [];
|
|
for (const entry of manifest.skills) {
|
|
if (
|
|
typeof entry.name !== 'string' ||
|
|
!Array.isArray(entry.files) ||
|
|
entry.files.length === 0 ||
|
|
!entry.files.includes('SKILL.md') ||
|
|
new Set(entry.files).size !== entry.files.length
|
|
) {
|
|
throw new Error(
|
|
`内置 AGC Skill ${entry.name ?? '<unknown>'} 元数据不完整`,
|
|
);
|
|
}
|
|
for (const relativePath of entry.files) {
|
|
if (!isSafeSkillRelativePath(relativePath)) {
|
|
throw new Error(
|
|
`内置 AGC Skill ${entry.name} 包含不安全相对路径: ${relativePath}`,
|
|
);
|
|
}
|
|
declaredFiles.add(`${entry.name}/${relativePath}`);
|
|
}
|
|
const actual = computeSkillContentFingerprint(rootDir, entry);
|
|
if (actual !== entry.sha256) {
|
|
mismatches.push({
|
|
name: entry.name,
|
|
expected: entry.sha256,
|
|
actual,
|
|
});
|
|
}
|
|
}
|
|
|
|
const bundledFiles = collectBundledFiles(rootDir);
|
|
if (
|
|
declaredFiles.size !== bundledFiles.length ||
|
|
[...declaredFiles].sort().join('\n') !== bundledFiles.join('\n')
|
|
) {
|
|
throw new Error('内置 AGC Skill 文件集合与审核清单不一致');
|
|
}
|
|
return { mismatches };
|
|
}
|
|
|
|
export function inspectSkillPack(rootDir = defaultRoot) {
|
|
const resolvedRoot = path.resolve(rootDir);
|
|
const { manifestPath, manifest } = readManifest(resolvedRoot);
|
|
const { mismatches } = validateManifestShape(resolvedRoot, manifest);
|
|
return { manifestPath, manifest, mismatches };
|
|
}
|
|
|
|
function incrementPackVersion(version) {
|
|
const match = /^(\d{4}-\d{2}-\d{2})\.(\d+)$/u.exec(version);
|
|
if (!match) {
|
|
throw new Error(
|
|
`无法自动递增 Skill pack 版本 ${version},请使用 YYYY-MM-DD.N 格式`,
|
|
);
|
|
}
|
|
return `${match[1]}.${Number(match[2]) + 1}`;
|
|
}
|
|
|
|
export function syncSkillPackManifest(rootDir = defaultRoot) {
|
|
const inspection = inspectSkillPack(rootDir);
|
|
if (inspection.mismatches.length === 0) {
|
|
return {
|
|
changed: false,
|
|
version: inspection.manifest.version,
|
|
mismatches: [],
|
|
};
|
|
}
|
|
|
|
const mismatchByName = new Map(
|
|
inspection.mismatches.map((mismatch) => [mismatch.name, mismatch.actual]),
|
|
);
|
|
const nextManifest = {
|
|
...inspection.manifest,
|
|
version: incrementPackVersion(inspection.manifest.version),
|
|
skills: inspection.manifest.skills.map((entry) =>
|
|
mismatchByName.has(entry.name)
|
|
? { ...entry, sha256: mismatchByName.get(entry.name) }
|
|
: entry,
|
|
),
|
|
};
|
|
fs.writeFileSync(
|
|
inspection.manifestPath,
|
|
`${JSON.stringify(nextManifest, null, 2)}\n`,
|
|
'utf8',
|
|
);
|
|
const verified = inspectSkillPack(rootDir);
|
|
if (verified.mismatches.length > 0) {
|
|
throw new Error('Skill pack manifest 同步后仍存在内容指纹不匹配');
|
|
}
|
|
return {
|
|
changed: true,
|
|
version: nextManifest.version,
|
|
mismatches: inspection.mismatches,
|
|
};
|
|
}
|