02a7abeb3f
Project CI / AI game creator shell Rust crates (push) Successful in 2m53s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m42s
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
- 后台新增「模板管理」页:按权限查看并编辑模板名称/简介/标签/封面,支持上架与下架;路由、侧栏、tab 权限、API 客户端与 DTO 同步接入 - api-server 新增 /admin/api/agc-templates 读取与更新路由,复用现有后台鉴权与 tab 权限校验,权限不足按既有失败关闭口径拒绝 - module-assets 收敛模板库纯规则(active/inactive 投影、索引 schema 与重复项校验、ZIP 大小与摘要核对),platform-oss 承担对象存储读写、不可变对象复用与发布互斥锁 - shared-contracts 补后台模板 DTO;spacetime-module 账号存储适配保持既有 schema 不变 - AGC 壳内置模板索引与建项读取改为内容寻址对象,补模板安装、越界归档、索引不匹配等用例 - 模板库发布脚本支持定向发布与上架/下架合并、内容地址复用与锁语义,CLI 守卫用例同步;jenkins 两条客户端打包管线统一触发邮件通知 Job 并传递首装包链接 - 内置 5 个 Cocos 官方模板载荷与索引 fixture(内容地址对象,共 90 个文件) - 补「后台模板管理」里程碑与实施计划,更新模板库技术方案、开发运维文档与共享记忆 - 验证:admin-web 39 项用例与 typecheck、发布脚本 25 项用例、api-server 与 AGC 壳编译、模板库定向 Rust 用例(AGC 壳 18 项、module-assets/platform-oss 16 项、后台权限 1 项)、check:encoding / check:doc-index / check:production-ops / rustfmt 全部通过
877 lines
29 KiB
JavaScript
877 lines
29 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 打包并发布 AGC 模板库到 OSS。
|
||
*
|
||
* 用法:
|
||
* node scripts/agc-template-library-publish.mjs --source <dir> [--dry-run]
|
||
* [--bucket agc-dev] [--endpoint oss-rg-china-mainland.aliyuncs.com] [--prefix templates]
|
||
* [--index-out <file>]
|
||
*
|
||
* 源目录结构(仓库内为 `apps/ai-game-creator-shell/template-library/`):
|
||
* <source>/v1/<templateId>/meta.json 模板元数据(title/summary/tags/runtime/engine/…)
|
||
* <source>/v1/<templateId>/cover.(png|jpg|jpeg|webp|svg) 封面图
|
||
* <source>/v1/<templateId>/project/** 模板正文(就是解压后的项目根内容)
|
||
*
|
||
* 脚本按 `project/` 现场打包 `template.zip`(zip 根 == AGC 项目根),再上传
|
||
* `v1/<id>/sha256/<内容摘要>/<文件名>` 与库清单 `index.json`。
|
||
* 内容对象只创建、不覆盖;持有 OSS 发布锁时读取并更新库清单。
|
||
*/
|
||
|
||
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
||
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||
import { join, resolve } from 'node:path';
|
||
import { pathToFileURL } from 'node:url';
|
||
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 <dir> [--dry-run]',
|
||
' [--bucket agc-dev] [--endpoint oss-rg-china-mainland.aliyuncs.com] [--prefix templates]',
|
||
' [--index-out <file>]',
|
||
' [--only <id,id,...>] 只发布指定模板,保留线上其他模板',
|
||
].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,
|
||
only: [],
|
||
};
|
||
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 === '--only') {
|
||
args.only = (argv[(index += 1)] ?? '').split(',');
|
||
if (args.only.some((id) => !TEMPLATE_ID_PATTERN.test(id))) {
|
||
throw new Error('--only 必须是逗号分隔的模板 ID');
|
||
}
|
||
} else if (value === '--dry-run') args.dryRun = 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;
|
||
}
|
||
|
||
export function buildLibrary(source, prefix, only = []) {
|
||
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));
|
||
for (const id of only) {
|
||
if (!templateIds.includes(id)) throw new Error(`源目录中不存在模板:${id}`);
|
||
}
|
||
if (templateIds.length === 0) throw new Error('源目录 v1/ 下没有模板');
|
||
|
||
const updatedAt = new Date().toISOString().replace(/\.\d{3}Z$/u, 'Z');
|
||
const templates = [];
|
||
const objects = [];
|
||
|
||
for (const templateId of templateIds) {
|
||
if (only.length && !only.includes(templateId)) continue;
|
||
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 zipHash = sha256(zipBytes);
|
||
const coverHash = sha256(coverBytes);
|
||
const objectPrefix = `${prefix}/v1/${templateId}/sha256`;
|
||
const zipKey = `${objectPrefix}/${zipHash}/template.zip`;
|
||
const coverKey = `${objectPrefix}/${coverHash}/${coverName}`;
|
||
|
||
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: zipHash,
|
||
},
|
||
cover: {
|
||
key: coverKey,
|
||
width: Number.isInteger(meta.coverWidth) ? meta.coverWidth : 0,
|
||
height: Number.isInteger(meta.coverHeight) ? meta.coverHeight : 0,
|
||
sha256: coverHash,
|
||
},
|
||
files: projectFiles.map((file) => ({
|
||
path: file.path,
|
||
sizeBytes: file.bytes.length,
|
||
sha256: sha256(file.bytes),
|
||
})),
|
||
};
|
||
const metadataBytes = Buffer.from(
|
||
`${JSON.stringify(templateMetadata, null, 2)}\n`,
|
||
'utf8',
|
||
);
|
||
const metadataKey = `${objectPrefix}/${sha256(metadataBytes)}/template.json`;
|
||
|
||
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,
|
||
});
|
||
|
||
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: metadataBytes,
|
||
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 };
|
||
}
|
||
|
||
export function mergeLibraryIndex(existing, selected) {
|
||
if (
|
||
existing.schemaVersion !== SCHEMA_VERSION ||
|
||
existing.library !== selected.library ||
|
||
existing.libraryVersion !== selected.libraryVersion ||
|
||
!Array.isArray(existing.templates) ||
|
||
(existing.inactiveTemplates !== undefined &&
|
||
!Array.isArray(existing.inactiveTemplates))
|
||
) {
|
||
throw new Error('线上模板库清单契约不匹配,不能合并');
|
||
}
|
||
const entries = new Map();
|
||
const inactive = new Map();
|
||
for (const entry of existing.templates) {
|
||
if (!TEMPLATE_ID_PATTERN.test(entry.id) || entries.has(entry.id)) {
|
||
throw new Error('线上模板库含非法或重复 ID,不能合并');
|
||
}
|
||
entries.set(entry.id, entry);
|
||
}
|
||
for (const entry of existing.inactiveTemplates ?? []) {
|
||
if (
|
||
!TEMPLATE_ID_PATTERN.test(entry.id) ||
|
||
entries.has(entry.id) ||
|
||
inactive.has(entry.id)
|
||
) {
|
||
throw new Error('线上模板库含非法或重复 ID,不能合并');
|
||
}
|
||
inactive.set(entry.id, entry);
|
||
}
|
||
for (const entry of selected.templates) {
|
||
if (inactive.has(entry.id)) inactive.set(entry.id, entry);
|
||
else entries.set(entry.id, entry);
|
||
}
|
||
return {
|
||
...existing,
|
||
updatedAt: selected.updatedAt,
|
||
templates: [...entries.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
||
inactiveTemplates: [...inactive.values()].sort((a, b) =>
|
||
a.id.localeCompare(b.id),
|
||
),
|
||
};
|
||
}
|
||
|
||
export function createClient({
|
||
bucket,
|
||
endpoint,
|
||
accessKeyId,
|
||
accessKeySecret,
|
||
fetchImpl = globalThis.fetch,
|
||
}) {
|
||
async function request(
|
||
method,
|
||
key,
|
||
body,
|
||
contentType = '',
|
||
extraHeaders = {},
|
||
subresource = '',
|
||
) {
|
||
const headers = new Headers(extraHeaders);
|
||
const date = new Date().toUTCString();
|
||
headers.set('Date', date);
|
||
if (contentType) headers.set('Content-Type', contentType);
|
||
const ossHeaders = [...headers.entries()]
|
||
.filter(([name]) => name.startsWith('x-oss-'))
|
||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||
.map(([name, value]) => `${name}:${value.trim()}\n`)
|
||
.join('');
|
||
const suffix = subresource ? `?${subresource}` : '';
|
||
const resource = `/${bucket}/${key}${suffix}`;
|
||
const stringToSign = `${method}\n${headers.get('Content-MD5') ?? ''}\n${headers.get('Content-Type') ?? ''}\n${date}\n${ossHeaders}${resource}`;
|
||
const signature = createHmac('sha1', accessKeySecret)
|
||
.update(stringToSign, 'utf8')
|
||
.digest('base64');
|
||
headers.set('Authorization', `OSS ${accessKeyId}:${signature}`);
|
||
const encodedKey = key.split('/').map(encodeURIComponent).join('/');
|
||
return fetchImpl(`https://${bucket}.${endpoint}/${encodedKey}${suffix}`, {
|
||
method,
|
||
headers,
|
||
...(body === undefined ? {} : { body }),
|
||
redirect: 'error',
|
||
signal: AbortSignal.timeout(30_000),
|
||
});
|
||
}
|
||
|
||
return {
|
||
put: (key, body, contentType, headers = {}) =>
|
||
request('PUT', key, body, contentType, headers),
|
||
get: (key) => request('GET', key),
|
||
remove: (key) => request('DELETE', key),
|
||
getBucketVersioning: () =>
|
||
request('GET', '', undefined, '', {}, 'versioning'),
|
||
};
|
||
}
|
||
|
||
function validatePrefix(prefix) {
|
||
if (
|
||
typeof prefix !== 'string' ||
|
||
!/^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/u.test(prefix) ||
|
||
prefix.split('/').some((part) => !part || part === '.' || part === '..')
|
||
) {
|
||
throw new Error('模板库对象前缀无效');
|
||
}
|
||
}
|
||
|
||
function validateLibraryIndex(index, prefix, label) {
|
||
if (
|
||
!index ||
|
||
index.schemaVersion !== SCHEMA_VERSION ||
|
||
index.library !== 'agc-game-templates' ||
|
||
index.libraryVersion !== 1 ||
|
||
!Array.isArray(index.templates)
|
||
) {
|
||
throw new Error(`${label}清单契约不匹配`);
|
||
}
|
||
const ids = new Set();
|
||
const validHash = (hash) =>
|
||
typeof hash === 'string' && /^[a-f0-9]{64}$/iu.test(hash);
|
||
if (
|
||
index.inactiveTemplates !== undefined &&
|
||
!Array.isArray(index.inactiveTemplates)
|
||
) {
|
||
throw new Error(`${label}下架模板清单格式非法`);
|
||
}
|
||
for (const entry of [
|
||
...index.templates,
|
||
...(index.inactiveTemplates ?? []),
|
||
]) {
|
||
if (
|
||
!entry ||
|
||
typeof entry.id !== 'string' ||
|
||
!TEMPLATE_ID_PATTERN.test(entry.id ?? '') ||
|
||
ids.has(entry.id) ||
|
||
typeof entry.templateVersion !== 'string' ||
|
||
!TEMPLATE_VERSION_PATTERN.test(entry.templateVersion ?? '') ||
|
||
typeof entry.title !== 'string' ||
|
||
!entry.title.trim() ||
|
||
!RUNTIMES.has(entry.runtime) ||
|
||
!Array.isArray(entry.tags) ||
|
||
entry.tags.some((tag) => typeof tag !== 'string' || !tag.trim()) ||
|
||
!Number.isSafeInteger(entry.zipSizeBytes) ||
|
||
entry.zipSizeBytes <= 0 ||
|
||
!validHash(entry.zipSha256) ||
|
||
(entry.coverSha256 !== undefined &&
|
||
(typeof entry.coverSha256 !== 'string' ||
|
||
(entry.coverSha256 !== '' && !validHash(entry.coverSha256)))) ||
|
||
[
|
||
'summary',
|
||
'engine',
|
||
'engineVersion',
|
||
'updatedAt',
|
||
'entry',
|
||
'metadataKey',
|
||
].some(
|
||
(field) =>
|
||
entry[field] !== undefined && typeof entry[field] !== 'string',
|
||
) ||
|
||
['coverWidth', 'coverHeight'].some(
|
||
(field) =>
|
||
entry[field] !== undefined &&
|
||
(!Number.isInteger(entry[field]) ||
|
||
entry[field] < 0 ||
|
||
entry[field] > 0xffffffff),
|
||
)
|
||
) {
|
||
throw new Error(`${label}清单含非法或重复模板条目`);
|
||
}
|
||
const objectPrefix = `${prefix}/v1/${entry.id}/`;
|
||
for (const key of [entry.zipKey, entry.coverKey, entry.metadataKey].filter(
|
||
(key, index) => index < 2 || key,
|
||
)) {
|
||
if (
|
||
typeof key !== 'string' ||
|
||
!key.startsWith(objectPrefix) ||
|
||
/[\\?#\r\n\0]/u.test(key) ||
|
||
key.split('/').some((part) => !part || part === '.' || part === '..')
|
||
) {
|
||
throw new Error(`${label}清单对象键不在模板前缀内`);
|
||
}
|
||
}
|
||
ids.add(entry.id);
|
||
}
|
||
}
|
||
|
||
function planLibraryIndex(existing, selected, { prefix }) {
|
||
validateLibraryIndex(selected, prefix, '本地');
|
||
if (!existing) return selected;
|
||
validateLibraryIndex(existing, prefix, '线上');
|
||
const previous = new Map(
|
||
[...existing.templates, ...(existing.inactiveTemplates ?? [])].map(
|
||
(entry) => [entry.id, entry],
|
||
),
|
||
);
|
||
for (const entry of selected.templates) {
|
||
const old = previous.get(entry.id);
|
||
if (
|
||
old?.templateVersion === entry.templateVersion &&
|
||
(old.zipSha256.toLowerCase() !== entry.zipSha256.toLowerCase() ||
|
||
old.zipSizeBytes !== entry.zipSizeBytes)
|
||
) {
|
||
throw new Error(
|
||
`${entry.id}@${entry.templateVersion} 同版本 ZIP 内容或尺寸变化,请递增 templateVersion`,
|
||
);
|
||
}
|
||
}
|
||
return mergeLibraryIndex(existing, selected);
|
||
}
|
||
|
||
async function checkedRequest(run, label) {
|
||
try {
|
||
return await run();
|
||
} catch {
|
||
throw new Error(`${label}失败或响应不明`);
|
||
}
|
||
}
|
||
|
||
async function readResponseBytes(response, label) {
|
||
try {
|
||
return Buffer.from(await response.arrayBuffer());
|
||
} catch {
|
||
throw new Error(`${label}读取失败或响应不明`);
|
||
}
|
||
}
|
||
|
||
function parseResponseJson(bytes, label) {
|
||
try {
|
||
return JSON.parse(bytes.toString('utf8'));
|
||
} catch {
|
||
throw new Error(`${label}不是有效 JSON`);
|
||
}
|
||
}
|
||
|
||
async function readExistingIndex(client, key) {
|
||
const response = await checkedRequest(() => client.get(key), '读取线上清单');
|
||
if (response.status === 404) return null;
|
||
if (!response.ok)
|
||
throw new Error(`读取线上清单失败:HTTP ${response.status}`);
|
||
const index = parseResponseJson(
|
||
await readResponseBytes(response, '线上清单'),
|
||
'线上清单',
|
||
);
|
||
if (!index || typeof index !== 'object' || Array.isArray(index)) {
|
||
throw new Error('线上清单契约不匹配');
|
||
}
|
||
return index;
|
||
}
|
||
|
||
async function assertBucketVersioningDisabled(client) {
|
||
const response = await checkedRequest(
|
||
() => client.getBucketVersioning(),
|
||
'检查 Bucket 版本控制',
|
||
);
|
||
if (response.status !== 200) {
|
||
throw new Error(`无法确认 Bucket 版本控制状态:HTTP ${response.status}`);
|
||
}
|
||
const xml = (await readResponseBytes(response, 'Bucket 版本控制'))
|
||
.toString('utf8')
|
||
.trim();
|
||
// 只接受 OSS 明确返回的空配置;Status、未知节点、声明或属性均不能当成未启用。
|
||
const emptyConfiguration =
|
||
/^(?:<\?xml\s+version=(["'])1\.[01]\1(?:\s+encoding=(["'])(?:UTF-8|utf-8)\2)?(?:\s+standalone=(["'])(?:yes|no)\3)?\s*\?>\s*)?<VersioningConfiguration(?:\s+xmlns=(["'])https?:\/\/doc\.oss-cn-hangzhou\.aliyuncs\.com\/?\4)?\s*(?:\/>|>\s*<\/VersioningConfiguration>)$/u;
|
||
if (!emptyConfiguration.test(xml)) {
|
||
throw new Error('Bucket 版本控制已启用、已暂停或状态无法判定,停止发布');
|
||
}
|
||
}
|
||
|
||
function contentObjects(library, prefix) {
|
||
const { objects, indexJson } = library;
|
||
const indexKey = `${prefix}/index.json`;
|
||
if (!Array.isArray(objects) || objects.at(-1)?.key !== indexKey) {
|
||
throw new Error('本地发布对象必须以库清单结尾');
|
||
}
|
||
const expected = new Set(
|
||
indexJson.templates.flatMap((entry) => [
|
||
entry.zipKey,
|
||
entry.coverKey,
|
||
entry.metadataKey,
|
||
]),
|
||
);
|
||
const seen = new Set();
|
||
const result = objects.slice(0, -1);
|
||
for (const object of result) {
|
||
if (
|
||
!expected.has(object.key) ||
|
||
seen.has(object.key) ||
|
||
!Buffer.isBuffer(object.body)
|
||
) {
|
||
throw new Error('本地发布对象缺失、重复或不在清单内');
|
||
}
|
||
const parts = object.key.split('/');
|
||
if (parts.at(-3) !== 'sha256' || parts.at(-2) !== sha256(object.body)) {
|
||
throw new Error('本地对象键与内容 SHA-256 不一致');
|
||
}
|
||
seen.add(object.key);
|
||
}
|
||
if (seen.size !== expected.size)
|
||
throw new Error('本地清单引用的内容对象不完整');
|
||
for (const entry of indexJson.templates) {
|
||
const zip = result.find((object) => object.key === entry.zipKey);
|
||
if (
|
||
zip.body.length !== entry.zipSizeBytes ||
|
||
sha256(zip.body) !== entry.zipSha256.toLowerCase()
|
||
) {
|
||
throw new Error('本地 ZIP 与清单摘要或尺寸不一致');
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
async function verifyObject(client, key, expected) {
|
||
const response = await checkedRequest(() => client.get(key), '回读对象');
|
||
if (!response.ok) throw new Error(`回读对象失败:HTTP ${response.status}`);
|
||
if (!(await readResponseBytes(response, '对象')).equals(expected)) {
|
||
throw new Error(`回读对象内容不一致:${key}`);
|
||
}
|
||
}
|
||
|
||
async function releasePublishLock(client, lockKey, owner) {
|
||
const response = await checkedRequest(
|
||
() => client.get(lockKey),
|
||
'读取发布锁 owner',
|
||
);
|
||
if (!response.ok)
|
||
throw new Error(`无法确认发布锁 owner:HTTP ${response.status}`);
|
||
const lock = parseResponseJson(
|
||
await readResponseBytes(response, '发布锁'),
|
||
'发布锁',
|
||
);
|
||
if (lock?.owner !== owner)
|
||
throw new Error('发布锁 owner 已变化,不能释放其他发布者的锁');
|
||
const removed = await checkedRequest(
|
||
() => client.remove(lockKey),
|
||
'释放发布锁',
|
||
);
|
||
if (!removed.ok) throw new Error(`释放发布锁失败:HTTP ${removed.status}`);
|
||
}
|
||
|
||
export async function publishLibrary(
|
||
library,
|
||
{ client, prefix = 'templates', only = [], log = console.log },
|
||
) {
|
||
validatePrefix(prefix);
|
||
validateLibraryIndex(library.indexJson, prefix, '本地');
|
||
const objects = contentObjects(library, prefix);
|
||
const indexKey = `${prefix}/index.json`;
|
||
const lockKey = `${prefix}/.publish-lock.json`;
|
||
await assertBucketVersioningDisabled(client);
|
||
const owner = randomUUID();
|
||
const lockBody = Buffer.from(
|
||
JSON.stringify({ owner, createdAt: new Date().toISOString() }),
|
||
);
|
||
const acquired = await checkedRequest(
|
||
() =>
|
||
client.put(lockKey, lockBody, 'application/json', {
|
||
'x-oss-forbid-overwrite': 'true',
|
||
}),
|
||
'获取发布锁',
|
||
);
|
||
if (acquired.status === 409)
|
||
throw new Error('模板发布锁已被占用,请等待当前发布结束后重新执行');
|
||
if (acquired.status !== 200)
|
||
throw new Error(`获取发布锁未明确成功:HTTP ${acquired.status}`);
|
||
|
||
let releaseAllowed = true;
|
||
let failure;
|
||
let indexJson;
|
||
try {
|
||
const existing = await readExistingIndex(client, indexKey);
|
||
indexJson = planLibraryIndex(existing, library.indexJson, { prefix, only });
|
||
for (const object of objects) {
|
||
const response = await checkedRequest(
|
||
() =>
|
||
client.put(object.key, object.body, object.contentType, {
|
||
'x-oss-forbid-overwrite': 'true',
|
||
}),
|
||
'上传内容对象',
|
||
);
|
||
if (!response.ok && response.status !== 409) {
|
||
throw new Error(`上传内容对象失败:HTTP ${response.status}`);
|
||
}
|
||
await verifyObject(client, object.key, object.body);
|
||
log(`verify ${object.key} (${object.body.length} B) ok`);
|
||
}
|
||
const indexBytes = Buffer.from(
|
||
`${JSON.stringify(indexJson, null, 2)}\n`,
|
||
'utf8',
|
||
);
|
||
// 指针写入结果不明时旧请求可能晚到,必须留锁,不能让后续发布者越过它。
|
||
releaseAllowed = false;
|
||
const published = await checkedRequest(
|
||
() => client.put(indexKey, indexBytes, 'application/json'),
|
||
'发布清单',
|
||
);
|
||
if (published.ok) {
|
||
releaseAllowed = true;
|
||
} else if (
|
||
published.status >= 400 &&
|
||
published.status < 500 &&
|
||
published.status !== 408
|
||
) {
|
||
releaseAllowed = true;
|
||
throw new Error(`发布清单被拒绝:HTTP ${published.status}`);
|
||
} else {
|
||
throw new Error(`发布清单结果不明:HTTP ${published.status}`);
|
||
}
|
||
await verifyObject(client, indexKey, indexBytes);
|
||
} catch (error) {
|
||
failure = error;
|
||
} finally {
|
||
if (releaseAllowed) {
|
||
try {
|
||
await releasePublishLock(client, lockKey, owner);
|
||
} catch (error) {
|
||
failure = new Error(
|
||
failure ? `${failure.message};${error.message}` : error.message,
|
||
);
|
||
}
|
||
} else {
|
||
failure = new Error(
|
||
`${failure?.message ?? '发布清单结果不明'};已保留本次发布锁,须确认在途请求已结束后再处理`,
|
||
);
|
||
}
|
||
}
|
||
if (failure) throw failure;
|
||
return indexJson;
|
||
}
|
||
async function main() {
|
||
const args = parseArgs(process.argv.slice(2));
|
||
if (!args.source) {
|
||
usage();
|
||
throw new Error('必须提供 --source');
|
||
}
|
||
validatePrefix(args.prefix);
|
||
const library = buildLibrary(resolve(args.source), args.prefix, args.only);
|
||
let indexJson;
|
||
if (args.dryRun) {
|
||
const publicClient = {
|
||
get: (key) =>
|
||
fetch(
|
||
`https://${args.bucket}.${args.endpoint}/${key.split('/').map(encodeURIComponent).join('/')}`,
|
||
{ redirect: 'error', signal: AbortSignal.timeout(30_000) },
|
||
),
|
||
};
|
||
const existing = await readExistingIndex(
|
||
publicClient,
|
||
`${args.prefix}/index.json`,
|
||
);
|
||
indexJson = planLibraryIndex(existing, library.indexJson, args);
|
||
} else {
|
||
const client = createClient({ ...args, ...loadAccessKeys() });
|
||
indexJson = await publishLibrary(library, {
|
||
client,
|
||
prefix: args.prefix,
|
||
only: args.only,
|
||
});
|
||
}
|
||
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 library.objects.slice(0, -1)) {
|
||
console.log(` PUT ${object.key} (${object.body.length} B)`);
|
||
}
|
||
console.log(
|
||
` PUT ${args.prefix}/index.json (${Buffer.byteLength(`${JSON.stringify(indexJson, null, 2)}\n`)} B)`,
|
||
);
|
||
} else {
|
||
console.log('模板库发布完成。');
|
||
}
|
||
}
|
||
if (
|
||
process.argv[1] &&
|
||
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
||
) {
|
||
main().catch((error) => {
|
||
console.error(`[agc-template-library-publish] ${error.message}`);
|
||
process.exit(1);
|
||
});
|
||
}
|