7eaaa1a499
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m45s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m42s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m58s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m0s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m18s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m24s
Project CI / Repository checks (pull_request) Failing after 22s
Project CI / Frontend tests (pull_request) Failing after 4m47s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m28s
Project CI / Native shell tests (pull_request) Successful in 8m51s
- 新增仓库模板源 apps/ai-game-creator-shell/template-library/:空白网页、空白二维画布、空白三维场景三个空白模板,以及 Phaser 2D、Three.js 3D 两个起步工程模板 - 发布脚本改为按 project/ 现场打包确定性 zip(条目排序 + 固定时间戳),支持 svg 封面、--prune 清理旧对象与 --index-out - 模板库新增真连检查:读取线上清单、下载并安装线上模板包后校验文件落盘 - 刷新线上清单 fixture,并同步技术方案与实施计划的封面格式、模板源与发布方式
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);
|
||
});
|