#!/usr/bin/env node import { cp, mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import { basename, dirname, extname, join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { spawn } from "node:child_process"; const BUILD_SERVICE_BASE_URL = "https://hbuilder.a.yunfeiyun.com"; const DEFAULT_UPLOAD_BASE_URL = "https://s3.hnyfwlw.com/test-ivan"; const DEFAULT_UPLOAD_PREFIX = "hbuilder-cloud/android-build-input"; const ARTIFACT_SCENARIO = "release"; const DEFAULT_POLL_INTERVAL_SECONDS = 3; const CLIENT_ENV_FILENAME = ".android-build-client.env"; const SUPPORTED_KEYSTORE_SUFFIXES = new Set([".keystore", ".jks"]); function printUsage() { console.log(`用法: 1. 在项目根目录创建 ${CLIENT_ENV_FILENAME} 2. 执行 node android-build-client.mjs 默认行为: 1. 自动检测 unpackage/resources 下最新导出的 App 资源 2. 自动读取项目根目录 manifest.json 中的版本号 3. 从 ${CLIENT_ENV_FILENAME} 读取 DCloud AppKey、包名和签名参数 4. 自动将项目根目录下唯一的 .keystore/.jks 签名文件一起打进标准包 5. 打出标准构建包并匿名上传到 ${DEFAULT_UPLOAD_BASE_URL} 6. 请求远程构建服务 ${BUILD_SERVICE_BASE_URL} 7. 默认持续轮询直到完成 `); } async function loadClientEnv(projectRoot) { const envPath = join(projectRoot, CLIENT_ENV_FILENAME); if (!(await fileExists(envPath))) { return {}; } const content = await readFile(envPath, "utf8"); const env = {}; for (const rawLine of content.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith("#")) { continue; } const index = line.indexOf("="); if (index <= 0) { continue; } const key = line.slice(0, index).trim(); let value = line.slice(index + 1).trim(); if ( (value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } env[key] = value; } return env; } function stripJsonComments(text) { let result = ""; let inString = false; let stringQuote = ""; let escaped = false; let inLineComment = false; let inBlockComment = false; for (let index = 0; index < text.length; index += 1) { const char = text[index]; const next = text[index + 1]; if (inLineComment) { if (char === "\n") { inLineComment = false; result += char; } continue; } if (inBlockComment) { if (char === "*" && next === "/") { inBlockComment = false; index += 1; } continue; } if (inString) { result += char; if (escaped) { escaped = false; } else if (char === "\\") { escaped = true; } else if (char === stringQuote) { inString = false; stringQuote = ""; } continue; } if ((char === "\"" || char === "'") && !inString) { inString = true; stringQuote = char; result += char; continue; } if (char === "/" && next === "/") { inLineComment = true; index += 1; continue; } if (char === "/" && next === "*") { inBlockComment = true; index += 1; continue; } result += char; } return result; } function normalizeJsonText(text) { return stripJsonComments(text.replace(/^\uFEFF/, "")); } async function parseJsonFile(path) { return JSON.parse(normalizeJsonText(await readFile(path, "utf8"))); } async function fileExists(path) { try { await stat(path); return true; } catch { return false; } } async function findLatestResourceDir(projectRoot) { const resourcesRoot = join(projectRoot, "unpackage", "resources"); const candidates = await readdir(resourcesRoot, { withFileTypes: true }); let latest = null; for (const entry of candidates) { if (!entry.isDirectory()) { continue; } const dirPath = join(resourcesRoot, entry.name); const manifestPath = join(dirPath, "www", "manifest.json"); if (!(await fileExists(manifestPath))) { continue; } const manifestStat = await stat(manifestPath); if (!latest || manifestStat.mtimeMs > latest.mtimeMs) { latest = { path: dirPath, mtimeMs: manifestStat.mtimeMs }; } } if (!latest) { throw new Error("未找到导出资源: unpackage/resources/*/www/manifest.json"); } return latest.path; } async function loadProjectManifest(projectRoot) { const manifestPath = join(projectRoot, "manifest.json"); if (!(await fileExists(manifestPath))) { throw new Error("缺少项目根目录 manifest.json"); } const data = await parseJsonFile(manifestPath); return { appName: data.name, uniAppId: data.appid, versionName: data.versionName, versionCode: Number(data.versionCode), }; } async function loadResourceMetadata(resourceDir) { const manifestPath = join(resourceDir, "www", "manifest.json"); const data = await parseJsonFile(manifestPath); return { resourceDir, uniAppId: data.id, }; } async function deriveAppPackage(projectRoot) { const environmentPath = join(projectRoot, "config", "environment.js"); if (await fileExists(environmentPath)) { const content = await readFile(environmentPath, "utf8"); const match = content.match(/https:\/\/([a-z0-9-]+)\.agmp\.yunfeiyun\.com/i); if (match?.[1]) { return `com.agmp.${match[1].toLowerCase()}`; } } throw new Error("无法自动推导 Android 包名,请提供环境变量 BUILD_APP_PACKAGE"); } async function resolveAppPackage(projectRoot, clientEnv) { const appPackage = (process.env.BUILD_APP_PACKAGE || clientEnv.BUILD_APP_PACKAGE || "").trim(); if (appPackage) { return appPackage; } return deriveAppPackage(projectRoot); } async function collectNativeResources(projectRoot) { const manifestPath = join(projectRoot, "manifest.json"); if (!(await fileExists(manifestPath))) { return []; } const manifest = await parseJsonFile(manifestPath); const appPlus = manifest["app-plus"] || {}; const distribute = appPlus.distribute || {}; const androidIcons = (distribute.icons || {}).android || {}; const androidSplash = (distribute.splashscreen || {}).android || {}; const densityMap = { hdpi: "drawable-hdpi", xhdpi: "drawable-xhdpi", xxhdpi: "drawable-xxhdpi", xxxhdpi: "drawable-xxxhdpi", }; const mappings = []; const missing = []; for (const [density, relPath] of Object.entries(androidIcons)) { const targetDir = densityMap[density]; if (!targetDir) continue; const absPath = resolve(projectRoot, relPath); if (await fileExists(absPath)) { mappings.push({ source: absPath, target: join(targetDir, "icon.png") }); } else { missing.push(relPath); } } for (const [density, relPath] of Object.entries(androidSplash)) { const targetDir = densityMap[density]; if (!targetDir) continue; const absPath = resolve(projectRoot, relPath); if (await fileExists(absPath)) { mappings.push({ source: absPath, target: join(targetDir, "splash.png") }); } else { missing.push(relPath); } } if (missing.length > 0) { throw new Error(`manifest.json 中声明的 Android 图标或启动图不存在: ${missing.join(", ")}`); } return mappings; } async function findProjectKeystore(projectRoot) { const entries = await readdir(projectRoot, { withFileTypes: true }); const keystoreFiles = entries .filter((entry) => entry.isFile()) .map((entry) => join(projectRoot, entry.name)) .filter((path) => SUPPORTED_KEYSTORE_SUFFIXES.has(extname(path).toLowerCase())); if (keystoreFiles.length === 0) { throw new Error("项目根目录缺少签名文件,请放置一个 .keystore 或 .jks 文件"); } if (keystoreFiles.length > 1) { throw new Error("项目根目录存在多个签名文件,请只保留一个 .keystore 或 .jks 文件"); } return keystoreFiles[0]; } function runCommand(command, args) { return new Promise((resolvePromise, rejectPromise) => { const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; child.stdout.on("data", (chunk) => { stdout += String(chunk); }); child.stderr.on("data", (chunk) => { stderr += String(chunk); }); child.on("error", (error) => { rejectPromise(error); }); child.on("close", (code) => { if (code === 0) { resolvePromise({ stdout, stderr }); return; } rejectPromise(new Error(stderr.trim() || stdout.trim() || `${command} 退出码: ${code}`)); }); }); } function parseAliasNames(text) { const aliases = new Set(); for (const line of text.split(/\r?\n/)) { const trimmed = line.trim(); const aliasMatch = trimmed.match(/^Alias name:\s*(.+)$/i); if (aliasMatch?.[1]) { aliases.add(aliasMatch[1].trim()); continue; } const chineseMatch = trimmed.match(/^别名(?:名称)?[::]\s*(.+)$/); if (chineseMatch?.[1]) { aliases.add(chineseMatch[1].trim()); } } return [...aliases]; } async function detectKeyAlias(keystorePath, keystorePassword) { let output; try { output = await runCommand("keytool", [ "-J-Duser.language=en", "-J-Duser.country=US", "-list", "-v", "-keystore", keystorePath, "-storepass", keystorePassword, ]); } catch (error) { throw new Error(`无法自动解析证书 alias,请安装 keytool 或在 ${CLIENT_ENV_FILENAME} 中填写 BUILD_KEY_ALIAS。原始错误: ${error.message}`); } const aliases = parseAliasNames(`${output.stdout}\n${output.stderr}`); if (aliases.length === 0) { throw new Error(`未能从证书中解析到 alias,请在 ${CLIENT_ENV_FILENAME} 中填写 BUILD_KEY_ALIAS`); } if (aliases.length > 1) { throw new Error(`证书中存在多个 alias: ${aliases.join(", ")},请在 ${CLIENT_ENV_FILENAME} 中显式指定 BUILD_KEY_ALIAS`); } return aliases[0]; } async function createBuildInputArchive(config, resourceMetadata) { const stageRoot = await mkdtemp(join(tmpdir(), "android-build-client-")); const distRoot = config.outputDir; const safeVersion = String(resourceMetadata.versionName).replaceAll("/", "_"); const timestamp = new Date().toISOString().replaceAll(":", "").replaceAll("-", "").replace(/\..+/, ""); const archivePath = join( distRoot, `android-build-input_${resourceMetadata.uniAppId}_v${safeVersion}_${timestamp}.tar.gz`, ); try { await mkdir(join(stageRoot, "www"), { recursive: true }); await mkdir(join(stageRoot, "native-res"), { recursive: true }); await mkdir(join(stageRoot, "meta"), { recursive: true }); await mkdir(join(stageRoot, "signing"), { recursive: true }); await mkdir(distRoot, { recursive: true }); await cp(join(resourceMetadata.resourceDir, "www"), join(stageRoot, "www"), { recursive: true }); const nativeResources = await collectNativeResources(config.projectRoot); for (const mapping of nativeResources) { const destination = join(stageRoot, "native-res", mapping.target); await mkdir(dirname(destination), { recursive: true }); await cp(mapping.source, destination); } await writeFile( join(stageRoot, "meta", "build.json"), JSON.stringify( { uni_appid: resourceMetadata.uniAppId, app_name: resourceMetadata.appName, version_name: resourceMetadata.versionName, version_code: resourceMetadata.versionCode, }, null, 2, ) + "\n", "utf8", ); await cp(config.keystorePath, join(stageRoot, "signing", basename(config.keystorePath))); await runTar(stageRoot, archivePath); return { archivePath, cleanup: () => rm(stageRoot, { recursive: true, force: true }) }; } catch (error) { await rm(stageRoot, { recursive: true, force: true }); throw error; } } function runTar(stageRoot, archivePath) { return new Promise((resolvePromise, rejectPromise) => { const child = spawn("tar", ["-C", stageRoot, "-czf", archivePath, "."], { env: { ...process.env, COPYFILE_DISABLE: "1", }, stdio: "inherit", }); child.on("error", (error) => { rejectPromise(new Error(`执行 tar 失败: ${error.message}`)); }); child.on("close", (code) => { if (code === 0) { resolvePromise(); return; } rejectPromise(new Error(`tar 打包失败,退出码: ${code}`)); }); }); } function encodeS3Path(key) { return key .split("/") .map((segment) => encodeURIComponent(segment).replace(/[!*'()]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)) .join("/"); } async function uploadArchive(config, archivePath, resourceMetadata) { const body = await readFile(archivePath); const version = resourceMetadata.versionName; const datePath = new Date().toISOString().slice(0, 10).replaceAll("-", ""); const timestamp = new Date().toISOString().replaceAll(":", "").replaceAll("-", "").replace(/\..+/, ""); const objectKey = [ config.uploadPrefix, ARTIFACT_SCENARIO, config.appPackage, version, datePath, `${basename(archivePath, ".tar.gz")}_${timestamp}.tar.gz`, ].join("/"); const uploadUrl = `${config.uploadBaseUrl}/${encodeS3Path(objectKey)}`; const response = await fetch(uploadUrl, { method: "PUT", headers: { "Content-Type": "application/gzip", }, body, }); if (!response.ok) { throw new Error(`上传构建包失败: ${response.status} ${await response.text()}`); } return { resourceUrl: `${config.uploadBaseUrl}/${objectKey}`, objectKey, }; } async function requestRemoteBuild(config, resourceMetadata, resourceUrl) { const payload = { resource_url: resourceUrl, app_package: config.appPackage, dcloud_appkey: config.dcloudAppKey, keystore_password: config.keystorePassword, key_alias: config.keyAlias, key_password: config.keyPassword, }; payload.version_name = resourceMetadata.versionName; payload.version_code = resourceMetadata.versionCode; let response; try { response = await fetch(`${config.serviceBaseUrl}/v1/android/builds`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), signal: AbortSignal.timeout(15000), }); } catch (error) { throw new Error(`请求构建服务失败: ${config.serviceBaseUrl} 不可达。原始错误: ${error.message}`); } if (!response.ok) { throw new Error(`请求构建服务失败: ${response.status} ${await response.text()}`); } const data = await response.json(); return { ...data, requestPayload: payload, metadata: resourceMetadata, }; } async function waitForTask(config, taskId) { while (true) { let response; try { response = await fetch(`${config.serviceBaseUrl}/v1/android/builds/${taskId}`, { signal: AbortSignal.timeout(15000), }); } catch (error) { throw new Error( `查询任务状态失败: ${config.serviceBaseUrl} 不可达。原始错误: ${error.message}`, ); } if (!response.ok) { throw new Error(`查询任务状态失败: ${response.status} ${await response.text()}`); } const task = await response.json(); console.log(`[build] 任务状态: ${task.status}`); if (task.status === "succeeded") { return task; } if (task.status === "failed") { throw new Error(task.error_message || "远程构建失败"); } await new Promise((resolvePromise) => setTimeout(resolvePromise, config.pollIntervalSeconds * 1000), ); } } async function main() { if (process.argv.includes("-h") || process.argv.includes("--help")) { printUsage(); return; } const projectRoot = process.cwd(); const clientEnv = await loadClientEnv(projectRoot); const dcloudAppKey = (process.env.BUILD_DCLOUD_APPKEY || clientEnv.BUILD_DCLOUD_APPKEY || "").trim(); if (!dcloudAppKey) { throw new Error(`缺少 BUILD_DCLOUD_APPKEY,请在 ${CLIENT_ENV_FILENAME} 或环境变量中提供`); } const keystorePassword = ( process.env.BUILD_KEYSTORE_PASSWORD || clientEnv.BUILD_KEYSTORE_PASSWORD || "" ).trim(); if (!keystorePassword) { throw new Error(`缺少 BUILD_KEYSTORE_PASSWORD,请在 ${CLIENT_ENV_FILENAME} 或环境变量中提供`); } const keyPassword = ( process.env.BUILD_KEY_PASSWORD || clientEnv.BUILD_KEY_PASSWORD || keystorePassword ).trim(); const projectManifest = await loadProjectManifest(projectRoot); const appPackage = await resolveAppPackage(projectRoot, clientEnv); const keystorePath = await findProjectKeystore(projectRoot); const configuredAlias = (process.env.BUILD_KEY_ALIAS || clientEnv.BUILD_KEY_ALIAS || "").trim(); const keyAlias = configuredAlias || (await detectKeyAlias(keystorePath, keystorePassword)); const config = { projectRoot, serviceBaseUrl: BUILD_SERVICE_BASE_URL, uploadBaseUrl: DEFAULT_UPLOAD_BASE_URL, uploadPrefix: DEFAULT_UPLOAD_PREFIX, appPackage, dcloudAppKey, keystorePath, keystorePassword, keyAlias, keyPassword, outputDir: resolve(projectRoot, "dist"), pollIntervalSeconds: DEFAULT_POLL_INTERVAL_SECONDS, }; const resourceDir = await findLatestResourceDir(projectRoot); const exportMetadata = await loadResourceMetadata(resourceDir); const resourceMetadata = { resourceDir, uniAppId: projectManifest.uniAppId || exportMetadata.uniAppId, appName: projectManifest.appName, versionName: projectManifest.versionName, versionCode: projectManifest.versionCode, }; console.log(`[build] 构建服务: ${config.serviceBaseUrl}`); console.log(`[build] 资源目录: ${resourceDir}`); console.log(`[build] 资源 AppID: ${resourceMetadata.uniAppId}`); console.log(`[build] 包名: ${config.appPackage}`); console.log(`[build] 版本: ${resourceMetadata.versionName} (${resourceMetadata.versionCode})`); console.log(`[build] 签名文件: ${config.keystorePath}`); console.log("[build] 抓包支持: 已固定允许用户证书与明文 HTTP"); const { archivePath, cleanup } = await createBuildInputArchive(config, resourceMetadata); try { console.log(`[build] 标准包已生成: ${archivePath}`); const uploadResult = await uploadArchive(config, archivePath, resourceMetadata); console.log(`[build] 标准包已上传: ${uploadResult.resourceUrl}`); const buildRequest = await requestRemoteBuild(config, resourceMetadata, uploadResult.resourceUrl); console.log(`[build] 已提交构建任务: ${buildRequest.task_id}`); const finalTask = await waitForTask(config, buildRequest.task_id); console.log(`[build] 构建完成: ${finalTask.artifact_url || "-"}`); } finally { await cleanup(); } } main().catch((error) => { console.error(`[build] ${error.message}`); process.exitCode = 1; });