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