#!/usr/bin/env node /** * 打包并发布 AGC 模板库到 OSS。 * * 用法: * node scripts/agc-template-library-publish.mjs --source [--dry-run] * [--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//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 [--dry-run]', ' [--bucket agc-dev] [--endpoint oss-rg-china-mainland.aliyuncs.com] [--prefix templates]', ' [--index-out ]', ' [--only ] 只发布指定模板,保留线上其他模板', ].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*)?|>\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); }); }