#!/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); });