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