Files
OneOS1.2/scripts/publish-all-to-s3.mjs

290 lines
9.8 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
/**
* 将项目原型页发布到 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,
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) {
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) {
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 }),
});
}
}
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);
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);
});