Files
Genarrative/scripts/agc-template-library-publish.mjs
T
kdletters 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
新增后台 AGC 模板管理与模板库内容寻址落库
- 后台新增「模板管理」页:按权限查看并编辑模板名称/简介/标签/封面,支持上架与下架;路由、侧栏、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 全部通过
2026-09-21 13:56:20 +08:00

877 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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(`无法确认发布锁 ownerHTTP ${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);
});
}