补齐模板库模板源、空白模板与确定性打包
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m45s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m42s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m58s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m0s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m18s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m24s
Project CI / Repository checks (pull_request) Failing after 22s
Project CI / Frontend tests (pull_request) Failing after 4m47s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m28s
Project CI / Native shell tests (pull_request) Successful in 8m51s

- 新增仓库模板源 apps/ai-game-creator-shell/template-library/:空白网页、空白二维画布、空白三维场景三个空白模板,以及 Phaser 2D、Three.js 3D 两个起步工程模板
- 发布脚本改为按 project/ 现场打包确定性 zip(条目排序 + 固定时间戳),支持 svg 封面、--prune 清理旧对象与 --index-out
- 模板库新增真连检查:读取线上清单、下载并安装线上模板包后校验文件落盘
- 刷新线上清单 fixture,并同步技术方案与实施计划的封面格式、模板源与发布方式
This commit is contained in:
kdletters
2026-09-17 11:24:50 +08:00
parent 8ee50e8b94
commit 7eaaa1a499
41 changed files with 1962 additions and 86 deletions
+228 -68
View File
@@ -1,36 +1,47 @@
#!/usr/bin/env node
/**
* 发布 AGC 模板库到 OSS。
* 打包并发布 AGC 模板库到 OSS。
*
* 用法:
* node scripts/agc-template-library-publish.mjs --source <dir> [--dry-run]
* node scripts/agc-template-library-publish.mjs --source <dir> [--dry-run] [--prune]
* [--bucket agc-dev] [--endpoint oss-rg-china-mainland.aliyuncs.com] [--prefix templates]
* [--index-out <file>]
*
* 源目录结构(模板正文是 zip,zip 根 == AGC 项目根,例如 game/index.html):
* 源目录结构(仓库内为 `apps/ai-game-creator-shell/template-library/`):
* <source>/v1/<templateId>/meta.json 模板元数据(title/summary/tags/runtime/engine/…)
* <source>/v1/<templateId>/template.zip 模板包
* <source>/v1/<templateId>/cover.png 封面图
* <source>/v1/<templateId>/cover.(png|jpg|jpeg|webp|svg) 封面图
* <source>/v1/<templateId>/project/** 模板正文(就是解压后的项目根内容)
*
* 脚本会按包内容生成 `v1/<templateId>/template.json` 与库清单 `index.json`
* 然后上传 zip、封面、模板元数据与清单。上传前会完成全部校验,任何一项不合法都不发请求。
* 脚本按 `project/` 现场打包 `template.zip`zip 根 == AGC 项目根),再上传
* `v1/<id>/{template.zip,cover.*,template.json}` 与库清单 `index.json`
* `--prune` 会删除该模板前缀下本次没有产出的旧对象(例如换了封面扩展名)。
* 上传前完成全部校验,任何一项不合法都不发请求。
*/
import { createHash, createHmac } from 'node:crypto';
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { deflateRawSync } from 'node:zlib';
const SCHEMA_VERSION = 'agc-template-library.v1';
const TEMPLATE_SCHEMA_VERSION = 'agc-template.v1';
const TEMPLATE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
const TEMPLATE_VERSION_PATTERN = /^[a-z0-9][a-z0-9._-]{0,31}$/u;
const RUNTIMES = new Set(['html', 'unity', 'godot', 'cocos']);
const COVER_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
const COVER_CONTENT_TYPES = new Map([
['.png', 'image/png'],
['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'],
['.webp', 'image/webp'],
['.svg', 'image/svg+xml'],
]);
function usage() {
console.log(
[
'用法: node scripts/agc-template-library-publish.mjs --source <dir> [--dry-run]',
'用法: node scripts/agc-template-library-publish.mjs --source <dir> [--dry-run] [--prune]',
' [--bucket agc-dev] [--endpoint oss-rg-china-mainland.aliyuncs.com] [--prefix templates]',
' [--index-out <file>]',
].join('\n'),
);
}
@@ -43,7 +54,9 @@ function parseArgs(argv) {
process.env.AGC_TEMPLATE_LIBRARY_ENDPOINT?.trim() ||
'oss-rg-china-mainland.aliyuncs.com',
prefix: 'templates',
indexOut: '',
dryRun: false,
prune: false,
};
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
@@ -51,7 +64,9 @@ function parseArgs(argv) {
else if (value === '--bucket') args.bucket = argv[(index += 1)] ?? '';
else if (value === '--endpoint') args.endpoint = argv[(index += 1)] ?? '';
else if (value === '--prefix') args.prefix = argv[(index += 1)] ?? '';
else if (value === '--index-out') args.indexOut = argv[(index += 1)] ?? '';
else if (value === '--dry-run') args.dryRun = true;
else if (value === '--prune') args.prune = true;
else if (value === '--help' || value === '-h') {
usage();
process.exit(0);
@@ -97,25 +112,102 @@ function loadAccessKeys() {
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex');
function walkFiles(directory, base = directory) {
const files = [];
for (const entry of readdirSync(directory, { withFileTypes: true }).sort(
(left, right) => left.name.localeCompare(right.name),
)) {
const full = join(directory, entry.name);
if (entry.isDirectory()) files.push(...walkFiles(full, base));
else {
const bytes = readFileSync(full);
files.push({
path: full
.slice(base.length + 1)
.split('\\')
.join('/'),
sizeBytes: bytes.length,
sha256: sha256(bytes),
});
const CRC32_TABLE = (() => {
const table = new Uint32Array(256);
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
table[index] = value >>> 0;
}
return table;
})();
function crc32(buffer) {
let crc = 0xffffffff;
for (const byte of buffer) {
crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
/**
* 生成确定性的 ZIP:条目按路径排序、固定 DOS 时间戳、UTF-8 名称位标记。
* 同一份 `project/` 内容重复打包得到相同摘要,便于比对发布结果。
*/
function buildZip(entries) {
const chunks = [];
const central = [];
let offset = 0;
for (const entry of entries) {
const nameBytes = Buffer.from(entry.path, 'utf8');
const content = entry.bytes;
const compressed = deflateRawSync(content, { level: 9 });
const checksum = crc32(content);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x0800, 6);
local.writeUInt16LE(8, 8);
local.writeUInt16LE(0, 10);
local.writeUInt16LE(0x0021, 12);
local.writeUInt32LE(checksum, 14);
local.writeUInt32LE(compressed.length, 18);
local.writeUInt32LE(content.length, 22);
local.writeUInt16LE(nameBytes.length, 26);
local.writeUInt16LE(0, 28);
chunks.push(local, nameBytes, compressed);
const directory = Buffer.alloc(46);
directory.writeUInt32LE(0x02014b50, 0);
directory.writeUInt16LE(20, 4);
directory.writeUInt16LE(20, 6);
directory.writeUInt16LE(0x0800, 8);
directory.writeUInt16LE(8, 10);
directory.writeUInt16LE(0, 12);
directory.writeUInt16LE(0x0021, 14);
directory.writeUInt32LE(checksum, 16);
directory.writeUInt32LE(compressed.length, 20);
directory.writeUInt32LE(content.length, 24);
directory.writeUInt16LE(nameBytes.length, 28);
directory.writeUInt16LE(0, 30);
directory.writeUInt16LE(0, 32);
directory.writeUInt16LE(0, 34);
directory.writeUInt16LE(0, 36);
directory.writeUInt32LE(0, 38);
directory.writeUInt32LE(offset, 42);
central.push(directory, nameBytes);
offset += local.length + nameBytes.length + compressed.length;
}
const centralBytes = Buffer.concat(central);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(0, 4);
end.writeUInt16LE(0, 6);
end.writeUInt16LE(entries.length, 8);
end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(centralBytes.length, 12);
end.writeUInt32LE(offset, 16);
end.writeUInt16LE(0, 20);
return Buffer.concat([...chunks, centralBytes, end]);
}
function readProjectFiles(projectRoot) {
const files = [];
const walk = (directory, prefix) => {
for (const entry of readdirSync(directory, { withFileTypes: true }).sort(
(left, right) => left.name.localeCompare(right.name),
)) {
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
const full = join(directory, entry.name);
if (entry.isDirectory()) walk(full, relative);
else if (entry.isFile())
files.push({ path: relative, bytes: readFileSync(full) });
else throw new Error(`模板正文包含不支持的条目:${full}`);
}
};
walk(projectRoot, '');
if (files.length === 0) throw new Error(`模板正文为空:${projectRoot}`);
return files;
}
@@ -123,10 +215,12 @@ function readMeta(templateRoot, templateId) {
const metaPath = join(templateRoot, 'meta.json');
if (!existsSync(metaPath)) throw new Error(`${templateId} 缺少 meta.json`);
const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
if (!TEMPLATE_ID_PATTERN.test(meta.id ?? ''))
if (!TEMPLATE_ID_PATTERN.test(meta.id ?? '')) {
throw new Error(`${templateId} 的 meta.id 非法`);
if (meta.id !== templateId)
}
if (meta.id !== templateId) {
throw new Error(`${templateId} 目录名与 meta.id 不一致:${meta.id}`);
}
if (typeof meta.title !== 'string' || !meta.title.trim()) {
throw new Error(`${templateId} 缺少 title`);
}
@@ -140,12 +234,14 @@ function readMeta(templateRoot, templateId) {
}
if (
!Array.isArray(meta.tags) ||
meta.tags.length === 0 ||
meta.tags.some((tag) => typeof tag !== 'string' || !tag.trim())
) {
throw new Error(`${templateId} 的 tags 必须是非空字符串数组`);
}
if (
typeof meta.entry !== 'string' ||
!meta.entry.trim() ||
meta.entry.includes('..') ||
meta.entry.startsWith('/')
) {
@@ -167,25 +263,23 @@ function buildLibrary(source, prefix) {
const updatedAt = new Date().toISOString().replace(/\.\d{3}Z$/u, 'Z');
const templates = [];
const objects = [];
const managedPrefixes = [];
for (const templateId of templateIds) {
const templateRoot = join(versionRoot, templateId);
const meta = readMeta(templateRoot, templateId);
const zipPath = join(templateRoot, 'template.zip');
if (!existsSync(zipPath))
throw new Error(`${templateId} 缺少 template.zip`);
const zipBytes = readFileSync(zipPath);
const coverEntries = readdirSync(templateRoot).filter((name) =>
COVER_EXTENSIONS.has(name.slice(name.lastIndexOf('.')).toLowerCase()),
const projectFiles = readProjectFiles(join(templateRoot, 'project'));
const zipBytes = buildZip(projectFiles);
const coverNames = readdirSync(templateRoot).filter((name) =>
COVER_CONTENT_TYPES.has(name.slice(name.lastIndexOf('.')).toLowerCase()),
);
if (coverEntries.length !== 1) {
throw new Error(`${templateId} 必须且只能有一张封面图(png/jpg/webp`);
if (coverNames.length !== 1) {
throw new Error(
`${templateId} 必须且只能有一张封面图(png/jpg/webp/svg`,
);
}
const coverName = coverEntries[0];
const coverName = coverNames[0];
const coverBytes = readFileSync(join(templateRoot, coverName));
const projectFiles = walkFiles(join(templateRoot, 'project'));
if (projectFiles.length === 0)
throw new Error(`${templateId} 的 project/ 为空`);
const zipKey = `${prefix}/v1/${templateId}/template.zip`;
const coverKey = `${prefix}/v1/${templateId}/${coverName}`;
@@ -214,7 +308,11 @@ function buildLibrary(source, prefix) {
height: Number.isInteger(meta.coverHeight) ? meta.coverHeight : 0,
sha256: sha256(coverBytes),
},
files: projectFiles,
files: projectFiles.map((file) => ({
path: file.path,
sizeBytes: file.bytes.length,
sha256: sha256(file.bytes),
})),
};
templates.push({
@@ -238,16 +336,15 @@ function buildLibrary(source, prefix) {
metadataKey,
});
managedPrefixes.push(`${prefix}/v1/${templateId}/`);
objects.push(
{ key: zipKey, body: zipBytes, contentType: 'application/zip' },
{
key: coverKey,
body: coverBytes,
contentType: coverName.endsWith('.webp')
? 'image/webp'
: coverName.endsWith('.png')
? 'image/png'
: 'image/jpeg',
contentType: COVER_CONTENT_TYPES.get(
coverName.slice(coverName.lastIndexOf('.')).toLowerCase(),
),
},
{
key: metadataKey,
@@ -272,37 +369,72 @@ function buildLibrary(source, prefix) {
body: Buffer.from(`${JSON.stringify(indexJson, null, 2)}\n`, 'utf8'),
contentType: 'application/json',
});
writeFileSync(
join(source, 'index.json'),
`${JSON.stringify(indexJson, null, 2)}\n`,
'utf8',
);
return { objects, indexJson };
return { objects, indexJson, managedPrefixes };
}
function createClient({ bucket, endpoint, accessKeyId, accessKeySecret }) {
async function call(
method,
key,
{ body = Buffer.alloc(0), contentType = '' } = {},
) {
function authorize(method, resourcePath, contentType) {
const date = new Date().toUTCString();
const stringToSign = `${method}\n\n${contentType}\n${date}\n/${bucket}/${key}`;
const stringToSign = `${method}\n\n${contentType}\n${date}\n${resourcePath}`;
const signature = createHmac('sha1', accessKeySecret)
.update(stringToSign, 'utf8')
.digest('base64');
const response = await fetch(`https://${bucket}.${endpoint}/${key}`, {
method,
return { date, authorization: `OSS ${accessKeyId}:${signature}` };
}
async function put(key, body, contentType) {
const { date, authorization } = authorize(
'PUT',
`/${bucket}/${key}`,
contentType,
);
return fetch(`https://${bucket}.${endpoint}/${key}`, {
method: 'PUT',
headers: {
Date: date,
Authorization: `OSS ${accessKeyId}:${signature}`,
Authorization: authorization,
...(contentType ? { 'Content-Type': contentType } : {}),
},
...(method === 'PUT' ? { body } : {}),
body,
});
return response;
}
return { call };
async function get(key) {
const { date, authorization } = authorize('GET', `/${bucket}/${key}`, '');
return fetch(`https://${bucket}.${endpoint}/${key}`, {
headers: { Date: date, Authorization: authorization },
});
}
async function remove(key) {
const { date, authorization } = authorize(
'DELETE',
`/${bucket}/${key}`,
'',
);
return fetch(`https://${bucket}.${endpoint}/${key}`, {
method: 'DELETE',
headers: { Date: date, Authorization: authorization },
});
}
async function listKeys(prefix) {
const { date, authorization } = authorize('GET', `/${bucket}/`, '');
const response = await fetch(
`https://${bucket}.${endpoint}/?prefix=${encodeURIComponent(prefix)}&max-keys=1000`,
{ headers: { Date: date, Authorization: authorization } },
);
const body = await response.text();
if (!response.ok)
throw new Error(
`列举对象失败:HTTP ${response.status} ${body.slice(0, 200)}`,
);
return [...body.matchAll(/<Key>([\s\S]*?)<\/Key>/gu)].map(
(match) => match[1],
);
}
return { put, get, remove, listKeys };
}
async function main() {
@@ -312,7 +444,17 @@ async function main() {
throw new Error('必须提供 --source');
}
const source = resolve(args.source);
const { objects, indexJson } = buildLibrary(source, args.prefix);
const { objects, indexJson, managedPrefixes } = buildLibrary(
source,
args.prefix,
);
if (args.indexOut) {
writeFileSync(
resolve(args.indexOut),
`${JSON.stringify(indexJson, null, 2)}\n`,
'utf8',
);
}
console.log(
`模板库:${indexJson.templates.length} 个模板 -> oss://${args.bucket}/${args.prefix}/`,
);
@@ -327,10 +469,15 @@ async function main() {
console.log(` PUT ${object.key} (${object.body.length} B)`);
return;
}
const credentials = loadAccessKeys();
const client = createClient({ ...args, ...credentials });
for (const object of objects) {
const response = await client.call('PUT', object.key, object);
const response = await client.put(
object.key,
object.body,
object.contentType,
);
if (!response.ok) {
throw new Error(
`上传失败 ${object.key}HTTP ${response.status} ${await response.text()}`,
@@ -340,10 +487,23 @@ async function main() {
`PUT ${object.key} (${object.body.length} B) -> ${response.status}`,
);
}
const verify = await client.call('GET', `${args.prefix}/index.json`);
if (args.prune) {
const uploaded = new Set(objects.map((object) => object.key));
for (const prefix of managedPrefixes) {
for (const key of await client.listKeys(prefix)) {
if (uploaded.has(key) || key === prefix) continue;
const response = await client.remove(key);
console.log(`DELETE ${key} -> ${response.status}`);
}
}
}
const verify = await client.get(`${args.prefix}/index.json`);
if (!verify.ok) throw new Error(`回读清单失败:HTTP ${verify.status}`);
const liveIndex = JSON.parse(await verify.text());
for (const template of liveIndex.templates) {
const zipResponse = await client.call('GET', template.zipKey);
const zipResponse = await client.get(template.zipKey);
const zipBytes = Buffer.from(await zipResponse.arrayBuffer());
const digest = sha256(zipBytes);
if (digest !== template.zipSha256) {