5db407c3dc
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m12s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m18s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m25s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m22s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m49s
Project CI / AI game creator shell Rust crates (push) Successful in 2m36s
Project CI / Repository checks (push) Failing after 1m11s
Project CI / Frontend tests (push) Failing after 4m37s
Project CI / AI game creator shell web tests (push) Failing after 4m6s
Project CI / Native shell tests (push) Successful in 9m39s
Project CI / Backend tests (push) Successful in 10m12s
## 交付范围 - OSS `agc-dev` bucket 的 `templates/` 前缀作为 AGC 游戏模板库(**去掉 `agc/` 这一层**;`agc/` 继续只放客户端安装包与更新清单)。 - 模板正文改为 zip(zip 根 == AGC 项目根,如 `game/index.html`);清单补齐 `tags`、封面字段与包大小/摘要,`templateVersion` 用于判断是否需要重下。 - 客户端:Rust 模板库模块、模板库全屏页、首页模板推荐位、左侧导航入口。 ## 客户端行为 - `fetch_game_template_library`:读取并校验清单(≤4 MiB),缓存到 `<app_data>/templates/index.json`;网络失败回退本机缓存并标注 `source=cache`。 - `download_game_template`:按清单下载 zip(≤512 MiB),校验字节数与 SHA-256 后解压到 `<app_data>/templates/installed/<id>/<version>/`,最后写 `installed.json` 作为安装完成标记。 - `create_automatic_local_game_project_from_template`:先把模板文件铺进新项目目录,再走既有 `init_local_game_project_at` 补 `.agent` 清单与标准目录;失败删除半成品目录。 - 解压只接受普通文件与目录(拒绝绝对路径、`..`、盘符、符号链接),安装目录名由标识符白名单拼出;远端只信「受信任 OSS 主机 + 对象键」,清单里的地址字段不参与请求。 - UI:搜索(空白分隔多关键词「与」)、标签/运行时筛选、仅看已下载、封面卡片与已下载徽标;「使用模板」在版本落后时先重下再建项。 ## 文档与脚本 - 主规范 `docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`;里程碑与实施计划见 `docs/project-memory/plans/`;`decision-log.md` 记录库路径、清单 schema、安装缓存目录与 CSP 约定。 - 发布脚本 `scripts/agc-template-library-publish.mjs`(`--dry-run` 校验 → 上传 → 回读校验)。 - 首页「灵感推荐」替换为模板库推荐位,删除本机灵感图目录与 `InspirationGallery.tsx`;`tauri.conf.json` 的 `img-src` 放行受信任 OSS 主机用于加载封面。 ## 验证 - `cargo test --bin genarrative-ai-game-creator-shell template_library`:9 passed;`--ignored` 真连检查 `fetches_the_live_template_library_index` 通过。 - `npx vitest run src/features/template-library`:9 passed。 - `npm run typecheck`、`cargo fmt --check`、`npm run check:encoding`、`node scripts/check-doc-index.mjs`、`git diff --check` 均通过。 - OSS 侧匿名复核:`templates/index.json`、各 `cover.png` 与 `template.zip` 均返回 200。 ## 未做 / 待确认 - 本轮未做客户端点击级人工验收(下一步单独验证并把结果补进此 PR)。 - 模板封面目前是占位标题卡,正式封面由后续模板包提供。 --------- Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/396
523 lines
17 KiB
JavaScript
523 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 打包并发布 AGC 模板库到 OSS。
|
||
*
|
||
* 用法:
|
||
* node scripts/agc-template-library-publish.mjs --source <dir> [--dry-run] [--prune]
|
||
* [--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>/{template.zip,cover.*,template.json}` 与库清单 `index.json`;
|
||
* `--prune` 会删除该模板前缀下本次没有产出的旧对象(例如换了封面扩展名)。
|
||
* 上传前完成全部校验,任何一项不合法都不发请求。
|
||
*/
|
||
|
||
import { createHash, createHmac } from 'node:crypto';
|
||
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||
import { join, resolve } from 'node:path';
|
||
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] [--prune]',
|
||
' [--bucket agc-dev] [--endpoint oss-rg-china-mainland.aliyuncs.com] [--prefix templates]',
|
||
' [--index-out <file>]',
|
||
].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,
|
||
prune: false,
|
||
};
|
||
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 === '--dry-run') args.dryRun = true;
|
||
else if (value === '--prune') args.prune = 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;
|
||
}
|
||
|
||
function buildLibrary(source, prefix) {
|
||
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));
|
||
if (templateIds.length === 0) throw new Error('源目录 v1/ 下没有模板');
|
||
|
||
const updatedAt = new Date().toISOString().replace(/\.\d{3}Z$/u, 'Z');
|
||
const templates = [];
|
||
const objects = [];
|
||
const managedPrefixes = [];
|
||
|
||
for (const templateId of templateIds) {
|
||
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 zipKey = `${prefix}/v1/${templateId}/template.zip`;
|
||
const coverKey = `${prefix}/v1/${templateId}/${coverName}`;
|
||
const metadataKey = `${prefix}/v1/${templateId}/template.json`;
|
||
|
||
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: sha256(zipBytes),
|
||
},
|
||
cover: {
|
||
key: coverKey,
|
||
width: Number.isInteger(meta.coverWidth) ? meta.coverWidth : 0,
|
||
height: Number.isInteger(meta.coverHeight) ? meta.coverHeight : 0,
|
||
sha256: sha256(coverBytes),
|
||
},
|
||
files: projectFiles.map((file) => ({
|
||
path: file.path,
|
||
sizeBytes: file.bytes.length,
|
||
sha256: sha256(file.bytes),
|
||
})),
|
||
};
|
||
|
||
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,
|
||
});
|
||
|
||
managedPrefixes.push(`${prefix}/v1/${templateId}/`);
|
||
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: Buffer.from(
|
||
`${JSON.stringify(templateMetadata, null, 2)}\n`,
|
||
'utf8',
|
||
),
|
||
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, managedPrefixes };
|
||
}
|
||
|
||
function createClient({ bucket, endpoint, accessKeyId, accessKeySecret }) {
|
||
function authorize(method, resourcePath, contentType) {
|
||
const date = new Date().toUTCString();
|
||
const stringToSign = `${method}\n\n${contentType}\n${date}\n${resourcePath}`;
|
||
const signature = createHmac('sha1', accessKeySecret)
|
||
.update(stringToSign, 'utf8')
|
||
.digest('base64');
|
||
return { date, authorization: `OSS ${accessKeyId}:${signature}` };
|
||
}
|
||
|
||
async function put(key, body, contentType) {
|
||
const { date, authorization } = authorize(
|
||
'PUT',
|
||
`/${bucket}/${key}`,
|
||
contentType,
|
||
);
|
||
return fetch(`https://${bucket}.${endpoint}/${key}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
Date: date,
|
||
Authorization: authorization,
|
||
...(contentType ? { 'Content-Type': contentType } : {}),
|
||
},
|
||
body,
|
||
});
|
||
}
|
||
|
||
async function get(key) {
|
||
const { date, authorization } = authorize('GET', `/${bucket}/${key}`, '');
|
||
return fetch(`https://${bucket}.${endpoint}/${key}`, {
|
||
headers: { Date: date, Authorization: authorization },
|
||
});
|
||
}
|
||
|
||
async function remove(key) {
|
||
const { date, authorization } = authorize(
|
||
'DELETE',
|
||
`/${bucket}/${key}`,
|
||
'',
|
||
);
|
||
return fetch(`https://${bucket}.${endpoint}/${key}`, {
|
||
method: 'DELETE',
|
||
headers: { Date: date, Authorization: authorization },
|
||
});
|
||
}
|
||
|
||
async function listKeys(prefix) {
|
||
const { date, authorization } = authorize('GET', `/${bucket}/`, '');
|
||
const response = await fetch(
|
||
`https://${bucket}.${endpoint}/?prefix=${encodeURIComponent(prefix)}&max-keys=1000`,
|
||
{ headers: { Date: date, Authorization: authorization } },
|
||
);
|
||
const body = await response.text();
|
||
if (!response.ok)
|
||
throw new Error(
|
||
`列举对象失败:HTTP ${response.status} ${body.slice(0, 200)}`,
|
||
);
|
||
return [...body.matchAll(/<Key>([\s\S]*?)<\/Key>/gu)].map(
|
||
(match) => match[1],
|
||
);
|
||
}
|
||
|
||
return { put, get, remove, listKeys };
|
||
}
|
||
|
||
async function main() {
|
||
const args = parseArgs(process.argv.slice(2));
|
||
if (!args.source) {
|
||
usage();
|
||
throw new Error('必须提供 --source');
|
||
}
|
||
const source = resolve(args.source);
|
||
const { objects, indexJson, managedPrefixes } = buildLibrary(
|
||
source,
|
||
args.prefix,
|
||
);
|
||
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 objects)
|
||
console.log(` PUT ${object.key} (${object.body.length} B)`);
|
||
return;
|
||
}
|
||
|
||
const credentials = loadAccessKeys();
|
||
const client = createClient({ ...args, ...credentials });
|
||
for (const object of objects) {
|
||
const response = await client.put(
|
||
object.key,
|
||
object.body,
|
||
object.contentType,
|
||
);
|
||
if (!response.ok) {
|
||
throw new Error(
|
||
`上传失败 ${object.key}:HTTP ${response.status} ${await response.text()}`,
|
||
);
|
||
}
|
||
console.log(
|
||
`PUT ${object.key} (${object.body.length} B) -> ${response.status}`,
|
||
);
|
||
}
|
||
|
||
if (args.prune) {
|
||
const uploaded = new Set(objects.map((object) => object.key));
|
||
for (const prefix of managedPrefixes) {
|
||
for (const key of await client.listKeys(prefix)) {
|
||
if (uploaded.has(key) || key === prefix) continue;
|
||
const response = await client.remove(key);
|
||
console.log(`DELETE ${key} -> ${response.status}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
const verify = await client.get(`${args.prefix}/index.json`);
|
||
if (!verify.ok) throw new Error(`回读清单失败:HTTP ${verify.status}`);
|
||
const liveIndex = JSON.parse(await verify.text());
|
||
for (const template of liveIndex.templates) {
|
||
const zipResponse = await client.get(template.zipKey);
|
||
const zipBytes = Buffer.from(await zipResponse.arrayBuffer());
|
||
const digest = sha256(zipBytes);
|
||
if (digest !== template.zipSha256) {
|
||
throw new Error(`回读校验失败:${template.zipKey}`);
|
||
}
|
||
console.log(
|
||
`verify ${template.zipKey} size=${zipBytes.length} sha256=${digest.slice(0, 12)}… ok`,
|
||
);
|
||
}
|
||
console.log('模板库发布完成。');
|
||
}
|
||
|
||
main().catch((error) => {
|
||
console.error(`[agc-template-library-publish] ${error.message}`);
|
||
process.exit(1);
|
||
});
|