Files
Genarrative/scripts/agc-template-library-publish.mjs
T
kdletters f4ebc45612
Project CI / AI game creator shell Rust smoke (push) Successful in 1m56s
Project CI / AI game creator shell Rust crates (push) Successful in 1m23s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 4m48s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 7m38s
Project CI / Frontend tests (push) Successful in 10m0s
Project CI / Native shell tests (push) Successful in 11m37s
Project CI / Repository checks (push) Successful in 8m49s
Project CI / Backend tests (push) Successful in 12m24s
Project CI / AI game creator shell web tests (push) Successful in 4m50s
模板库线上对齐仓库源:递增版本重发并把说明文档纳入发布
- 7 个内容已变的模板 templateVersion 升到 0.1.1,blank-2d-canvas 与 blank-web 保持 0.1.0
- 发布脚本把仓库 README.md 覆盖写到 templates/README.md(上限 64 KiB,写在清单之前并回读校验),线上说明文档不再是手工副本
- 修复守卫拒绝时直接 process.exit(1) 触发 libuv 断言崩溃、看不到拒绝原因的问题
- 新增发布用例:说明文档随发布覆盖写且写在清单之前、源目录没有 README 时不写线上文档
- 模板库新增仓库侧 README.md 源文件(发布后与线上逐字节一致)
- 技术方案补说明文档契约与 2026-09-21 重发布记录;决策记录与踩坑记录各补一条
- 验证:node --test 27 项、真实发布 28 个对象回读通过、线上清单与 README 核对一致、AGC 壳 3 项线上用例重跑通过、check:doc-index、check:encoding、eslint、git diff --check
2026-09-21 14:51:39 +08:00

912 lines
31 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';
/** 说明文档上限:它随每次发布覆盖写 `templates/README.md`,不做内容寻址。 */
const README_MAX_BYTES = 64 * 1024;
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',
});
// 说明文档不是内容寻址对象:它固定挂在 `templates/README.md`,随每次发布覆盖,
// 客户端从不读取它,但运维和后台都需要看到与当次契约一致的说明。
const readmePath = join(source, 'README.md');
const readmeBody = existsSync(readmePath) ? readFileSync(readmePath) : null;
if (readmeBody && readmeBody.length > README_MAX_BYTES) {
throw new Error(`README.md 超过 ${README_MAX_BYTES} 字节上限`);
}
return { objects, indexJson, readmeBody };
}
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',
);
// 可变指针(说明文档、库清单)写入结果不明时旧请求可能晚到,必须留锁,
// 不能让后续发布者越过它。清单必须最后写,否则会出现「清单已指新对象、
// 说明还是旧版」的可观测窗口。
const pointerWrites = [
...(library.readmeBody
? [
{
key: `${prefix}/README.md`,
body: library.readmeBody,
contentType: 'text/markdown; charset=utf-8',
},
]
: []),
{ key: indexKey, body: indexBytes, contentType: 'application/json' },
];
for (const pointer of pointerWrites) {
releaseAllowed = false;
const published = await checkedRequest(
() => client.put(pointer.key, pointer.body, pointer.contentType),
`发布 ${pointer.key}`,
);
if (published.ok) {
releaseAllowed = true;
} else if (
published.status >= 400 &&
published.status < 500 &&
published.status !== 408
) {
releaseAllowed = true;
throw new Error(`发布 ${pointer.key} 被拒绝:HTTP ${published.status}`);
} else {
throw new Error(
`发布 ${pointer.key} 结果不明:HTTP ${published.status}`,
);
}
await verifyObject(client, pointer.key, pointer.body);
}
} 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)`);
}
if (library.readmeBody) {
console.log(
` PUT ${args.prefix}/README.md (${library.readmeBody.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)` 会在 fetch 句柄尚未关闭时触发 libuv 断言崩溃
// (Windows 上实测只剩一句 Assertion failed,看不到拒绝原因);
// 交给事件循环自然退出,保留真实错误信息。
process.exitCode = 1;
});
}