#!/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 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); });