Files
Genarrative/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs
T
kdletters d75547b36d 自动化 Skill 指纹同步并发布 AGC 0.1.10
新增 skill-pack-manifest 脚本统一计算并校验内置 AGC Skill 内容摘要,--write 模式自动递增清单版本并同步 SHA-256。

新增 check-skill-pack 只读门禁与 Node 回归测试,无参数默认只读,只有单个 --write 才进入写入模式。

AGC typecheck 与 release build 接入 Skill 清单指纹校验,内容漂移时直接列出 Skill 与实际摘要阻断构建。

修正 agc-client-projection 清单指纹并升级 Skill pack 版本到 2026-08-26.3。

升级 AGC 标准版到 0.1.10,同步 package、Cargo、Tauri 与 npm 工作区锁文件。

首页空输入占位符锚定到编辑区,避免整页滚动后占位文本脱离输入框。

更新 AGC 实施计划中 Skill 指纹同步与只读门禁说明。
2026-08-26 23:23:24 +08:00

214 lines
6.6 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-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,
};
}