#!/usr/bin/env node
/**
* 打包并发布 AGC 模板库到 OSS。
*
* 用法:
* node scripts/agc-template-library-publish.mjs --source
[--dry-run] [--prune]
* [--bucket agc-dev] [--endpoint oss-rg-china-mainland.aliyuncs.com] [--prefix templates]
* [--index-out ]
*
* 源目录结构(仓库内为 `apps/ai-game-creator-shell/template-library/`):
* /v1//meta.json 模板元数据(title/summary/tags/runtime/engine/…)
* /v1//cover.(png|jpg|jpeg|webp|svg) 封面图
* /v1//project/** 模板正文(就是解压后的项目根内容)
*
* 脚本按 `project/` 现场打包 `template.zip`(zip 根 == AGC 项目根),再上传
* `v1//{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_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 [--dry-run] [--prune]',
' [--bucket agc-dev] [--endpoint oss-rg-china-mainland.aliyuncs.com] [--prefix templates]',
' [--index-out ]',
].join('\n'),
);
}
function parseArgs(argv) {
const args = {
source: '',
bucket: process.env.AGC_TEMPLATE_LIBRARY_BUCKET?.trim() || 'agc-dev',
endpoint:
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];
if (value === '--source') args.source = argv[(index += 1)] ?? '';
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);
} else throw new Error(`未知参数:${value}`);
}
return args;
}
function loadAccessKeys() {
if (
process.env.ALIYUN_OSS_ACCESS_KEY_ID &&
process.env.ALIYUN_OSS_ACCESS_KEY_SECRET
) {
return {
accessKeyId: process.env.ALIYUN_OSS_ACCESS_KEY_ID.trim(),
accessKeySecret: process.env.ALIYUN_OSS_ACCESS_KEY_SECRET,
};
}
const secretsPath = resolve('.env.secrets.local');
if (!existsSync(secretsPath)) {
throw new Error(
'缺少 OSS 凭据:请设置 ALIYUN_OSS_ACCESS_KEY_ID / ALIYUN_OSS_ACCESS_KEY_SECRET',
);
}
const secrets = Object.fromEntries(
readFileSync(secretsPath, 'utf8')
.split(/\r?\n/u)
.map((line) => /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(line.trim()))
.filter(Boolean)
.map((match) => [match[1], match[2].replace(/^"|"$/gu, '')]),
);
if (
!secrets.ALIYUN_OSS_ACCESS_KEY_ID ||
!secrets.ALIYUN_OSS_ACCESS_KEY_SECRET
) {
throw new Error('仓库 .env.secrets.local 缺少 ALIYUN_OSS_* 凭据');
}
return {
accessKeyId: secrets.ALIYUN_OSS_ACCESS_KEY_ID,
accessKeySecret: secrets.ALIYUN_OSS_ACCESS_KEY_SECRET,
};
}
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex');
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;
}
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 ?? '')) {
throw new Error(`${templateId} 的 meta.id 非法`);
}
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`);
}
if (!TEMPLATE_VERSION_PATTERN.test(meta.templateVersion ?? '')) {
throw new Error(`${templateId} 缺少合法的 templateVersion`);
}
if (!RUNTIMES.has(meta.runtime ?? '')) {
throw new Error(
`${templateId} 的 runtime 必须是 ${[...RUNTIMES].join(' / ')}`,
);
}
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('/')
) {
throw new Error(`${templateId} 的 entry 非法`);
}
return meta;
}
function buildLibrary(source, prefix) {
const versionRoot = join(source, 'v1');
if (!existsSync(versionRoot))
throw new Error(`源目录缺少 v1/:${versionRoot}`);
const templateIds = readdirSync(versionRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort((left, right) => left.localeCompare(right));
if (templateIds.length === 0) throw new Error('源目录 v1/ 下没有模板');
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 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 (coverNames.length !== 1) {
throw new Error(
`${templateId} 必须且只能有一张封面图(png/jpg/webp/svg)`,
);
}
const coverName = coverNames[0];
const coverBytes = readFileSync(join(templateRoot, coverName));
const zipKey = `${prefix}/v1/${templateId}/template.zip`;
const coverKey = `${prefix}/v1/${templateId}/${coverName}`;
const metadataKey = `${prefix}/v1/${templateId}/template.json`;
const templateMetadata = {
schemaVersion: TEMPLATE_SCHEMA_VERSION,
id: meta.id,
title: meta.title,
summary: meta.summary ?? '',
tags: meta.tags,
runtime: meta.runtime,
engine: meta.engine ?? '',
engineVersion: meta.engineVersion ?? '',
templateVersion: meta.templateVersion,
updatedAt,
entry: meta.entry,
zip: {
key: zipKey,
sizeBytes: zipBytes.length,
sha256: sha256(zipBytes),
},
cover: {
key: coverKey,
width: Number.isInteger(meta.coverWidth) ? meta.coverWidth : 0,
height: Number.isInteger(meta.coverHeight) ? meta.coverHeight : 0,
sha256: sha256(coverBytes),
},
files: projectFiles.map((file) => ({
path: file.path,
sizeBytes: file.bytes.length,
sha256: sha256(file.bytes),
})),
};
templates.push({
id: templateMetadata.id,
title: templateMetadata.title,
summary: templateMetadata.summary,
tags: templateMetadata.tags,
runtime: templateMetadata.runtime,
engine: templateMetadata.engine,
engineVersion: templateMetadata.engineVersion,
templateVersion: templateMetadata.templateVersion,
updatedAt,
entry: templateMetadata.entry,
zipKey,
zipSizeBytes: templateMetadata.zip.sizeBytes,
zipSha256: templateMetadata.zip.sha256,
coverKey,
coverWidth: templateMetadata.cover.width,
coverHeight: templateMetadata.cover.height,
coverSha256: templateMetadata.cover.sha256,
metadataKey,
});
managedPrefixes.push(`${prefix}/v1/${templateId}/`);
objects.push(
{ key: zipKey, body: zipBytes, contentType: 'application/zip' },
{
key: coverKey,
body: coverBytes,
contentType: COVER_CONTENT_TYPES.get(
coverName.slice(coverName.lastIndexOf('.')).toLowerCase(),
),
},
{
key: metadataKey,
body: Buffer.from(
`${JSON.stringify(templateMetadata, null, 2)}\n`,
'utf8',
),
contentType: 'application/json',
},
);
}
const indexJson = {
schemaVersion: SCHEMA_VERSION,
library: 'agc-game-templates',
libraryVersion: 1,
updatedAt,
templates,
};
objects.push({
key: `${prefix}/index.json`,
body: Buffer.from(`${JSON.stringify(indexJson, null, 2)}\n`, 'utf8'),
contentType: 'application/json',
});
return { objects, indexJson, managedPrefixes };
}
function createClient({ bucket, endpoint, accessKeyId, accessKeySecret }) {
function authorize(method, resourcePath, contentType) {
const date = new Date().toUTCString();
const stringToSign = `${method}\n\n${contentType}\n${date}\n${resourcePath}`;
const signature = createHmac('sha1', accessKeySecret)
.update(stringToSign, 'utf8')
.digest('base64');
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: authorization,
...(contentType ? { 'Content-Type': contentType } : {}),
},
body,
});
}
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(/([\s\S]*?)<\/Key>/gu)].map(
(match) => match[1],
);
}
return { put, get, remove, listKeys };
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (!args.source) {
usage();
throw new Error('必须提供 --source');
}
const source = resolve(args.source);
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}/`,
);
for (const template of indexJson.templates) {
console.log(
` - ${template.id}@${template.templateVersion} tags=${template.tags.join('/')} zip=${template.zipSizeBytes}B sha256=${template.zipSha256.slice(0, 12)}…`,
);
}
if (args.dryRun) {
console.log('dry-run:未上传。计划上传对象:');
for (const object of objects)
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.put(
object.key,
object.body,
object.contentType,
);
if (!response.ok) {
throw new Error(
`上传失败 ${object.key}:HTTP ${response.status} ${await response.text()}`,
);
}
console.log(
`PUT ${object.key} (${object.body.length} B) -> ${response.status}`,
);
}
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.get(template.zipKey);
const zipBytes = Buffer.from(await zipResponse.arrayBuffer());
const digest = sha256(zipBytes);
if (digest !== template.zipSha256) {
throw new Error(`回读校验失败:${template.zipKey}`);
}
console.log(
`verify ${template.zipKey} size=${zipBytes.length} sha256=${digest.slice(0, 12)}… ok`,
);
}
console.log('模板库发布完成。');
}
main().catch((error) => {
console.error(`[agc-template-library-publish] ${error.message}`);
process.exit(1);
});