Files
Genarrative/scripts/agc-template-library-publish.mjs
T
kdletters c5afc02e94
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust crates (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 / AI game creator shell Rust smoke (push) Has been cancelled
模板正文目录门禁:CLI 打包与后台上传统一拒绝身份/版本库/依赖/构建目录
- scripts/agc-template-library-publish.mjs:readProjectFiles 增加正文门禁,任意层级拒绝 .agent/.git/.svn/node_modules,根目录拒绝 dist/build/library/temp/local/.idea/.vscode
- scripts/agc-template-library-publish.test.mjs:新增夹具源目录与门禁用例,覆盖 13 条拒绝路径及 game/dist/**、game/.gitignore 放行
- server-rs/crates/api-server/src/admin_templates.rs:上传归档校验与 CLI 用同一份段名单,新增 template_import_archive_rejects_identity_and_build_directories
- docs/【模板规范】AGC模板包组织指南-2026-09-21.md:已知边界改为「已有机器门禁」,并说明同名目录段只在根目录受限
- docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md:CLI 与后台上传的校验口径补门禁,并追加 2026-09-21 证据(九个模板本地重打包摘要与线上清单逐条一致)
- docs/project-memory/plans/【里程碑】后台模板上传-2026-09-21.md:验收标准 2 补充门禁证据
- docs/project-memory/shared-memory/decision-log.md:新增 2026-09-21 正文目录门禁决策记录
2026-09-21 16:25:35 +08:00

939 lines
32 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 forbiddenSegments = new Set(['.agent', '.git', '.svn', 'node_modules']);
/** 只允许出现在正文根目录之外的构建产物与编辑器工作区目录。 */
const forbiddenRootSegments = new Set([
'dist',
'build',
'library',
'temp',
'local',
'.idea',
'.vscode',
]);
// 与后台「上传模板」共用同一条门禁,依据 docs/【模板规范】AGC模板包组织指南-2026-09-21.md。
const violation = (relative) => {
const segments = relative.split('/');
for (const segment of segments) {
if (forbiddenSegments.has(segment)) {
return `模板正文不允许包含 ${segment} 目录:${relative}`;
}
}
if (forbiddenRootSegments.has(segments[0])) {
return `模板正文根目录不允许包含 ${segments[0]} 目录:${relative}`;
}
return null;
};
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 rejected = violation(relative);
if (rejected) throw new Error(rejected);
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(`无法确认发布锁 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',
);
// 可变指针(说明文档、库清单)写入结果不明时旧请求可能晚到,必须留锁,
// 不能让后续发布者越过它。清单必须最后写,否则会出现「清单已指新对象、
// 说明还是旧版」的可观测窗口。
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;
});
}