|
|
@@ -0,0 +1,591 @@
|
|
|
+#!/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;
|
|
|
+});
|