294 lines
10 KiB
JavaScript
294 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 将项目原型页发布到 S3(阿里云 OSS)。
|
||
* 流程:Make Admin export-html 构建静态包 → 按页面独立前缀上传到 OSS。
|
||
*
|
||
* 用法:
|
||
* node scripts/publish-all-to-s3.mjs
|
||
* node scripts/publish-all-to-s3.mjs --dry-run
|
||
* node scripts/publish-all-to-s3.mjs --group prototypes
|
||
*/
|
||
import fs from 'node:fs';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
||
import extract from 'extract-zip';
|
||
import { readServerInfo } from './utils/serverInfo.mjs';
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
const projectRoot = path.resolve(__dirname, '..');
|
||
const CONFIG_PATH = path.join(projectRoot, '.axhub/make/axhub.config.json');
|
||
|
||
function parseArgs(argv) {
|
||
const args = {
|
||
projectId: 'oneos1.2',
|
||
adminOrigin: '',
|
||
dryRun: false,
|
||
group: 'all',
|
||
includeSource: true,
|
||
};
|
||
for (let i = 0; i < argv.length; i += 1) {
|
||
const arg = argv[i];
|
||
if (arg === '--project-id' && argv[i + 1]) args.projectId = argv[++i].trim();
|
||
else if (arg === '--admin-origin' && argv[i + 1]) args.adminOrigin = argv[++i].trim();
|
||
else if (arg === '--group' && argv[i + 1]) args.group = argv[++i].trim();
|
||
else if (arg === '--dry-run') args.dryRun = true;
|
||
else if (arg === '--no-source') args.includeSource = false;
|
||
}
|
||
return args;
|
||
}
|
||
|
||
function resolveAdminOrigin(explicit) {
|
||
if (explicit) return explicit.replace(/\/+$/u, '');
|
||
const info = readServerInfo(projectRoot, 'admin');
|
||
if (info?.origin) return info.origin.replace(/\/+$/u, '');
|
||
return 'http://localhost:53817';
|
||
}
|
||
|
||
function readS3Config() {
|
||
const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
||
const s3 = raw?.cloudPublishing?.s3;
|
||
if (!s3?.accessKeyId || !s3?.secretAccessKey || !s3?.bucket) {
|
||
throw new Error('请在 .axhub/make/axhub.config.json 中配置 cloudPublishing.s3');
|
||
}
|
||
return {
|
||
...s3,
|
||
pathAliases: s3.pathAliases && typeof s3.pathAliases === 'object' ? s3.pathAliases : {},
|
||
includeSource: raw?.cloudPublishing?.publishSettings?.includeSource !== false,
|
||
};
|
||
}
|
||
|
||
function toPublishPath(resource) {
|
||
const raw = resource.filePath || resource.sourcePath || '';
|
||
return String(raw).replace(/\\/gu, '/').replace(/\/index\.tsx?$/iu, '');
|
||
}
|
||
|
||
function resolveObjectPrefix(entry, pathAliases = {}) {
|
||
const alias = pathAliases[entry.id];
|
||
if (alias) return String(alias).replace(/^\/+|\/+$/gu, '');
|
||
|
||
const normalized = entry.path.replace(/^\/+|\/+$/gu, '');
|
||
const prototypeMatch = normalized.match(/^(?:src\/)?prototypes\/(.+)$/u);
|
||
if (prototypeMatch?.[1]) return `prototypes/${prototypeMatch[1]}`;
|
||
const themeMatch = normalized.match(/^(?:src\/)?themes\/(.+)$/u);
|
||
if (themeMatch?.[1]) return `themes/${themeMatch[1]}`;
|
||
return entry.id;
|
||
}
|
||
|
||
function resolvePublicUrl(baseUrl, prefix) {
|
||
const base = String(baseUrl || '').replace(/\/?$/u, '/');
|
||
const key = `${prefix.replace(/^\/+|\/+$/gu, '')}/index.html`;
|
||
return `${base}${key.split('/').map(encodeURIComponent).join('/')}`;
|
||
}
|
||
|
||
function guessContentType(filePath) {
|
||
const ext = path.extname(filePath).toLowerCase();
|
||
const map = {
|
||
'.html': 'text/html; charset=utf-8',
|
||
'.js': 'application/javascript; charset=utf-8',
|
||
'.css': 'text/css; charset=utf-8',
|
||
'.json': 'application/json; charset=utf-8',
|
||
'.webp': 'image/webp',
|
||
'.png': 'image/png',
|
||
'.jpg': 'image/jpeg',
|
||
'.jpeg': 'image/jpeg',
|
||
'.svg': 'image/svg+xml',
|
||
'.woff2': 'font/woff2',
|
||
'.woff': 'font/woff',
|
||
'.txt': 'text/plain; charset=utf-8',
|
||
};
|
||
return map[ext] || 'application/octet-stream';
|
||
}
|
||
|
||
async function collectPublishEntries(adminOrigin, projectId, groupFilter, pathAliases = {}) {
|
||
const res = await fetch(`${adminOrigin}/api/projects/${encodeURIComponent(projectId)}/resources`);
|
||
if (!res.ok) {
|
||
throw new Error(`GET /api/projects/${projectId}/resources failed: ${res.status}`);
|
||
}
|
||
const data = await res.json();
|
||
const groups = groupFilter === 'all' ? ['prototypes', 'themes'] : [groupFilter];
|
||
const entries = [];
|
||
for (const group of groups) {
|
||
for (const item of data.resources?.[group] || []) {
|
||
const publishPath = toPublishPath(item);
|
||
if (!publishPath) continue;
|
||
entries.push({
|
||
group,
|
||
id: item.id,
|
||
title: item.title || item.id,
|
||
path: publishPath,
|
||
prefix: resolveObjectPrefix({ path: publishPath, id: item.id }, pathAliases),
|
||
});
|
||
}
|
||
}
|
||
return entries;
|
||
}
|
||
|
||
async function exportHtmlZip(adminOrigin, projectId, entry, includeSource) {
|
||
const params = new URLSearchParams({
|
||
path: entry.path,
|
||
projectId,
|
||
});
|
||
if (includeSource) params.set('includeSource', 'true');
|
||
const res = await fetch(`${adminOrigin}/api/export-html?${params.toString()}`);
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '');
|
||
let message = text;
|
||
try {
|
||
message = JSON.parse(text).error || text;
|
||
} catch {
|
||
// keep raw text
|
||
}
|
||
throw new Error(message || `export-html failed (${res.status})`);
|
||
}
|
||
const buffer = Buffer.from(await res.arrayBuffer());
|
||
if (buffer.length < 4 || buffer[0] !== 0x50 || buffer[1] !== 0x4b) {
|
||
throw new Error('export-html 未返回有效 ZIP');
|
||
}
|
||
return buffer;
|
||
}
|
||
|
||
function resolveS3Endpoint(s3Config) {
|
||
const bucket = String(s3Config.bucket || '').trim();
|
||
const endpoint = String(s3Config.endpoint || '').trim().replace(/\/+$/u, '');
|
||
const region = String(s3Config.region || 'cn-hangzhou').trim();
|
||
|
||
if (endpoint) {
|
||
// 配置里可能是 bucket 级域名(model-lnoneos.oss-...),AWS SDK 只需区域 endpoint。
|
||
const bucketHostPrefix = bucket ? `${bucket}.` : '';
|
||
if (bucketHostPrefix && endpoint.includes(bucketHostPrefix)) {
|
||
return endpoint.replace(bucketHostPrefix, '');
|
||
}
|
||
return endpoint;
|
||
}
|
||
|
||
return `https://oss-${region}.aliyuncs.com`;
|
||
}
|
||
|
||
function createS3Client(s3Config) {
|
||
return new S3Client({
|
||
region: s3Config.region || 'cn-hangzhou',
|
||
endpoint: resolveS3Endpoint(s3Config),
|
||
forcePathStyle: false,
|
||
credentials: {
|
||
accessKeyId: s3Config.accessKeyId,
|
||
secretAccessKey: s3Config.secretAccessKey,
|
||
},
|
||
});
|
||
}
|
||
|
||
async function uploadDirectory(client, bucket, prefix, dir) {
|
||
const uploaded = [];
|
||
const walk = async (currentDir, relative = '') => {
|
||
for (const name of fs.readdirSync(currentDir)) {
|
||
const abs = path.join(currentDir, name);
|
||
const rel = relative ? `${relative}/${name}` : name;
|
||
if (fs.statSync(abs).isDirectory()) {
|
||
await walk(abs, rel);
|
||
continue;
|
||
}
|
||
const key = [prefix.replace(/^\/+|\/+$/gu, ''), rel].filter(Boolean).join('/');
|
||
await client.send(new PutObjectCommand({
|
||
Bucket: bucket,
|
||
Key: key,
|
||
Body: fs.readFileSync(abs),
|
||
ContentType: guessContentType(abs),
|
||
}));
|
||
uploaded.push(key);
|
||
}
|
||
};
|
||
await walk(dir);
|
||
return uploaded;
|
||
}
|
||
|
||
async function publishEntry(client, s3Config, adminOrigin, projectId, entry, includeSource) {
|
||
const zipBuffer = await exportHtmlZip(adminOrigin, projectId, entry, includeSource);
|
||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'axhub-publish-'));
|
||
const zipPath = path.join(tempDir, 'bundle.zip');
|
||
const extractDir = path.join(tempDir, 'site');
|
||
fs.writeFileSync(zipPath, zipBuffer);
|
||
fs.mkdirSync(extractDir, { recursive: true });
|
||
await extract(zipPath, { dir: extractDir });
|
||
const objectPrefix = s3Config.prefix
|
||
? `${String(s3Config.prefix).replace(/^\/+|\/+$/gu, '')}/${entry.prefix}`
|
||
: entry.prefix;
|
||
const keys = await uploadDirectory(client, s3Config.bucket, objectPrefix, extractDir);
|
||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||
return {
|
||
url: resolvePublicUrl(s3Config.baseUrl, objectPrefix),
|
||
fileCount: keys.length,
|
||
keys,
|
||
};
|
||
}
|
||
|
||
async function main() {
|
||
const args = parseArgs(process.argv.slice(2));
|
||
const adminOrigin = resolveAdminOrigin(args.adminOrigin);
|
||
const s3Config = readS3Config();
|
||
const includeSource = args.includeSource && s3Config.includeSource;
|
||
const entries = await collectPublishEntries(adminOrigin, args.projectId, args.group, s3Config.pathAliases);
|
||
|
||
console.log(`Admin: ${adminOrigin}`);
|
||
console.log(`Bucket: ${s3Config.bucket}`);
|
||
console.log(`Base URL: ${s3Config.baseUrl}`);
|
||
console.log(`Pages to publish: ${entries.length}`);
|
||
|
||
if (args.dryRun) {
|
||
for (const entry of entries) {
|
||
console.log(`[dry-run] ${entry.group}/${entry.id} -> ${entry.prefix}/index.html`);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const client = createS3Client(s3Config);
|
||
const results = [];
|
||
let failed = 0;
|
||
|
||
for (let i = 0; i < entries.length; i += 1) {
|
||
const entry = entries[i];
|
||
const label = `[${i + 1}/${entries.length}] ${entry.group}/${entry.id}`;
|
||
process.stdout.write(`${label} ... `);
|
||
try {
|
||
const result = await publishEntry(client, s3Config, adminOrigin, args.projectId, entry, includeSource);
|
||
console.log(`OK ${result.url} (${result.fileCount} files)`);
|
||
results.push({ ...entry, status: 'success', ...result });
|
||
} catch (error) {
|
||
failed += 1;
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
console.log(`FAIL ${message}`);
|
||
results.push({ ...entry, status: 'failed', error: message });
|
||
}
|
||
}
|
||
|
||
const reportPath = path.join(
|
||
projectRoot,
|
||
'.axhub/make/exports',
|
||
`cloud.publish.s3-all-${new Date().toISOString().replace(/[:.]/gu, '-')}.json`,
|
||
);
|
||
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||
fs.writeFileSync(reportPath, `${JSON.stringify({
|
||
schemaVersion: 2,
|
||
projectId: args.projectId,
|
||
target: 's3',
|
||
method: 'export-html+s3-upload',
|
||
total: entries.length,
|
||
success: entries.length - failed,
|
||
failed,
|
||
completedAt: new Date().toISOString(),
|
||
results: results.map(({ keys, ...rest }) => ({
|
||
...rest,
|
||
fileCount: rest.fileCount ?? keys?.length ?? 0,
|
||
})),
|
||
}, null, 2)}\n`, 'utf8');
|
||
|
||
console.log(`\nDone: ${entries.length - failed}/${entries.length} succeeded`);
|
||
if (failed > 0) process.exitCode = 1;
|
||
console.log(`Report: ${reportPath}`);
|
||
}
|
||
|
||
main().catch((error) => {
|
||
console.error(error instanceof Error ? error.message : error);
|
||
process.exit(1);
|
||
});
|