android-build-client.mjs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
  1. #!/usr/bin/env node
  2. import { cp, mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
  3. import { basename, dirname, extname, join, resolve } from "node:path";
  4. import { tmpdir } from "node:os";
  5. import { spawn } from "node:child_process";
  6. const BUILD_SERVICE_BASE_URL = "https://hbuilder.a.yunfeiyun.com";
  7. const DEFAULT_UPLOAD_BASE_URL = "https://s3.hnyfwlw.com/test-ivan";
  8. const DEFAULT_UPLOAD_PREFIX = "hbuilder-cloud/android-build-input";
  9. const ARTIFACT_SCENARIO = "release";
  10. const DEFAULT_POLL_INTERVAL_SECONDS = 3;
  11. const CLIENT_ENV_FILENAME = ".android-build-client.env";
  12. const SUPPORTED_KEYSTORE_SUFFIXES = new Set([".keystore", ".jks"]);
  13. function printUsage() {
  14. console.log(`用法:
  15. 1. 在项目根目录创建 ${CLIENT_ENV_FILENAME}
  16. 2. 执行 node android-build-client.mjs
  17. 默认行为:
  18. 1. 自动检测 unpackage/resources 下最新导出的 App 资源
  19. 2. 自动读取项目根目录 manifest.json 中的版本号与 Android 权限声明
  20. 3. 从 ${CLIENT_ENV_FILENAME} 读取 DCloud AppKey、包名和签名参数
  21. 4. 自动将项目根目录下唯一的 .keystore/.jks 签名文件一起打进标准包
  22. 5. 打出标准构建包并匿名上传到 ${DEFAULT_UPLOAD_BASE_URL}
  23. 6. 请求远程构建服务 ${BUILD_SERVICE_BASE_URL}
  24. 7. 默认持续轮询直到完成
  25. `);
  26. }
  27. async function loadClientEnv(projectRoot) {
  28. const envPath = join(projectRoot, CLIENT_ENV_FILENAME);
  29. if (!(await fileExists(envPath))) {
  30. return {};
  31. }
  32. const content = await readFile(envPath, "utf8");
  33. const env = {};
  34. for (const rawLine of content.split(/\r?\n/)) {
  35. const line = rawLine.trim();
  36. if (!line || line.startsWith("#")) {
  37. continue;
  38. }
  39. const index = line.indexOf("=");
  40. if (index <= 0) {
  41. continue;
  42. }
  43. const key = line.slice(0, index).trim();
  44. let value = line.slice(index + 1).trim();
  45. if (
  46. (value.startsWith("\"") && value.endsWith("\"")) ||
  47. (value.startsWith("'") && value.endsWith("'"))
  48. ) {
  49. value = value.slice(1, -1);
  50. }
  51. env[key] = value;
  52. }
  53. return env;
  54. }
  55. function stripJsonComments(text) {
  56. let result = "";
  57. let inString = false;
  58. let stringQuote = "";
  59. let escaped = false;
  60. let inLineComment = false;
  61. let inBlockComment = false;
  62. for (let index = 0; index < text.length; index += 1) {
  63. const char = text[index];
  64. const next = text[index + 1];
  65. if (inLineComment) {
  66. if (char === "\n") {
  67. inLineComment = false;
  68. result += char;
  69. }
  70. continue;
  71. }
  72. if (inBlockComment) {
  73. if (char === "*" && next === "/") {
  74. inBlockComment = false;
  75. index += 1;
  76. }
  77. continue;
  78. }
  79. if (inString) {
  80. result += char;
  81. if (escaped) {
  82. escaped = false;
  83. } else if (char === "\\") {
  84. escaped = true;
  85. } else if (char === stringQuote) {
  86. inString = false;
  87. stringQuote = "";
  88. }
  89. continue;
  90. }
  91. if ((char === "\"" || char === "'") && !inString) {
  92. inString = true;
  93. stringQuote = char;
  94. result += char;
  95. continue;
  96. }
  97. if (char === "/" && next === "/") {
  98. inLineComment = true;
  99. index += 1;
  100. continue;
  101. }
  102. if (char === "/" && next === "*") {
  103. inBlockComment = true;
  104. index += 1;
  105. continue;
  106. }
  107. result += char;
  108. }
  109. return result;
  110. }
  111. function normalizeJsonText(text) {
  112. return stripJsonComments(text.replace(/^\uFEFF/, ""));
  113. }
  114. async function parseJsonFile(path) {
  115. return JSON.parse(normalizeJsonText(await readFile(path, "utf8")));
  116. }
  117. async function fileExists(path) {
  118. try {
  119. await stat(path);
  120. return true;
  121. } catch {
  122. return false;
  123. }
  124. }
  125. async function findLatestResourceDir(projectRoot) {
  126. const resourcesRoot = join(projectRoot, "unpackage", "resources");
  127. const candidates = await readdir(resourcesRoot, { withFileTypes: true });
  128. let latest = null;
  129. for (const entry of candidates) {
  130. if (!entry.isDirectory()) {
  131. continue;
  132. }
  133. const dirPath = join(resourcesRoot, entry.name);
  134. const manifestPath = join(dirPath, "www", "manifest.json");
  135. if (!(await fileExists(manifestPath))) {
  136. continue;
  137. }
  138. const manifestStat = await stat(manifestPath);
  139. if (!latest || manifestStat.mtimeMs > latest.mtimeMs) {
  140. latest = { path: dirPath, mtimeMs: manifestStat.mtimeMs };
  141. }
  142. }
  143. if (!latest) {
  144. throw new Error("未找到导出资源: unpackage/resources/*/www/manifest.json");
  145. }
  146. return latest.path;
  147. }
  148. function extractAndroidPermissions(manifest) {
  149. // Android 权限声明(XML 声明字符串数组),非空时随 meta/build.json 透传给构建服务,
  150. // 由服务端合并进原生 AndroidManifest.xml;缺失或格式不符时返回空数组(旧包行为不变)
  151. const android = ((((manifest || {})["app-plus"] || {}).distribute || {}).android || {});
  152. const permissions = android.permissions;
  153. if (!Array.isArray(permissions)) {
  154. return [];
  155. }
  156. return permissions.filter((item) => typeof item === "string" && item.trim().length > 0);
  157. }
  158. async function loadProjectManifest(projectRoot) {
  159. const manifestPath = join(projectRoot, "manifest.json");
  160. if (!(await fileExists(manifestPath))) {
  161. throw new Error("缺少项目根目录 manifest.json");
  162. }
  163. const data = await parseJsonFile(manifestPath);
  164. return {
  165. appName: data.name,
  166. uniAppId: data.appid,
  167. versionName: data.versionName,
  168. versionCode: Number(data.versionCode),
  169. androidPermissions: extractAndroidPermissions(data),
  170. };
  171. }
  172. async function loadResourceMetadata(resourceDir) {
  173. const manifestPath = join(resourceDir, "www", "manifest.json");
  174. const data = await parseJsonFile(manifestPath);
  175. return {
  176. resourceDir,
  177. uniAppId: data.id,
  178. };
  179. }
  180. async function deriveAppPackage(projectRoot) {
  181. const environmentPath = join(projectRoot, "config", "environment.js");
  182. if (await fileExists(environmentPath)) {
  183. const content = await readFile(environmentPath, "utf8");
  184. const match = content.match(/https:\/\/([a-z0-9-]+)\.agmp\.yunfeiyun\.com/i);
  185. if (match?.[1]) {
  186. return `com.agmp.${match[1].toLowerCase()}`;
  187. }
  188. }
  189. throw new Error("无法自动推导 Android 包名,请提供环境变量 BUILD_APP_PACKAGE");
  190. }
  191. async function resolveAppPackage(projectRoot, clientEnv) {
  192. const appPackage = (process.env.BUILD_APP_PACKAGE || clientEnv.BUILD_APP_PACKAGE || "").trim();
  193. if (appPackage) {
  194. return appPackage;
  195. }
  196. return deriveAppPackage(projectRoot);
  197. }
  198. // 变体图标约定:项目根目录 appIcon/zk.png(中性版)/ appIcon/yfzk.png(云飞版),
  199. // 1024 原图直接打包不 resize(缺档由 Android 自动缩放),服务端按 isneutral 选中覆盖
  200. const VARIANT_ICON_SOURCES = [
  201. { source: join("appIcon", "zk.png"), target: join("neutral", "icon-src.png"), label: "中性版" },
  202. { source: join("appIcon", "yfzk.png"), target: join("yunfei", "icon-src.png"), label: "云飞版" },
  203. ];
  204. async function collectNativeResources(projectRoot) {
  205. // 变体图标必须存在:找不到直接报错,提示放这两张 1024 原图
  206. const mappings = [];
  207. const missingVariants = [];
  208. for (const item of VARIANT_ICON_SOURCES) {
  209. const absPath = resolve(projectRoot, item.source);
  210. if (await fileExists(absPath)) {
  211. mappings.push({ source: absPath, target: item.target });
  212. } else {
  213. missingVariants.push(item.source);
  214. }
  215. }
  216. if (missingVariants.length > 0) {
  217. throw new Error(
  218. `缺少变体图标: ${missingVariants.join(", ")}。请将 1024x1024 原图放到项目根目录 appIcon/zk.png(中性版)和 appIcon/yfzk.png(云飞版),无需手动切尺寸`,
  219. );
  220. }
  221. const manifestPath = join(projectRoot, "manifest.json");
  222. if (!(await fileExists(manifestPath))) {
  223. return mappings;
  224. }
  225. const manifest = await parseJsonFile(manifestPath);
  226. const appPlus = manifest["app-plus"] || {};
  227. const distribute = appPlus.distribute || {};
  228. // manifest 声明的 Android 图标不再作为 icon 来源(双变体图标由服务端按 isneutral
  229. // 选中 appIcon 下的变体图覆盖四个 drawable),因此这里跳过 icons,只处理启动图 splash
  230. const androidSplash = (distribute.splashscreen || {}).android || {};
  231. const densityMap = {
  232. hdpi: "drawable-hdpi",
  233. xhdpi: "drawable-xhdpi",
  234. xxhdpi: "drawable-xxhdpi",
  235. xxxhdpi: "drawable-xxxhdpi",
  236. };
  237. const missing = [];
  238. for (const [density, relPath] of Object.entries(androidSplash)) {
  239. const targetDir = densityMap[density];
  240. if (!targetDir) continue;
  241. const absPath = resolve(projectRoot, relPath);
  242. if (await fileExists(absPath)) {
  243. mappings.push({ source: absPath, target: join(targetDir, "splash.png") });
  244. } else {
  245. missing.push(relPath);
  246. }
  247. }
  248. if (missing.length > 0) {
  249. throw new Error(`manifest.json 中声明的 Android 启动图不存在: ${missing.join(", ")}`);
  250. }
  251. return mappings;
  252. }
  253. async function findProjectKeystore(projectRoot) {
  254. const entries = await readdir(projectRoot, { withFileTypes: true });
  255. const keystoreFiles = entries
  256. .filter((entry) => entry.isFile())
  257. .map((entry) => join(projectRoot, entry.name))
  258. .filter((path) => SUPPORTED_KEYSTORE_SUFFIXES.has(extname(path).toLowerCase()));
  259. if (keystoreFiles.length === 0) {
  260. throw new Error("项目根目录缺少签名文件,请放置一个 .keystore 或 .jks 文件");
  261. }
  262. if (keystoreFiles.length > 1) {
  263. throw new Error("项目根目录存在多个签名文件,请只保留一个 .keystore 或 .jks 文件");
  264. }
  265. return keystoreFiles[0];
  266. }
  267. function runCommand(command, args) {
  268. return new Promise((resolvePromise, rejectPromise) => {
  269. const child = spawn(command, args, {
  270. stdio: ["ignore", "pipe", "pipe"],
  271. });
  272. let stdout = "";
  273. let stderr = "";
  274. child.stdout.on("data", (chunk) => {
  275. stdout += String(chunk);
  276. });
  277. child.stderr.on("data", (chunk) => {
  278. stderr += String(chunk);
  279. });
  280. child.on("error", (error) => {
  281. rejectPromise(error);
  282. });
  283. child.on("close", (code) => {
  284. if (code === 0) {
  285. resolvePromise({ stdout, stderr });
  286. return;
  287. }
  288. rejectPromise(new Error(stderr.trim() || stdout.trim() || `${command} 退出码: ${code}`));
  289. });
  290. });
  291. }
  292. function parseAliasNames(text) {
  293. const aliases = new Set();
  294. for (const line of text.split(/\r?\n/)) {
  295. const trimmed = line.trim();
  296. const aliasMatch = trimmed.match(/^Alias name:\s*(.+)$/i);
  297. if (aliasMatch?.[1]) {
  298. aliases.add(aliasMatch[1].trim());
  299. continue;
  300. }
  301. const chineseMatch = trimmed.match(/^别名(?:名称)?[::]\s*(.+)$/);
  302. if (chineseMatch?.[1]) {
  303. aliases.add(chineseMatch[1].trim());
  304. }
  305. }
  306. return [...aliases];
  307. }
  308. async function detectKeyAlias(keystorePath, keystorePassword) {
  309. let output;
  310. try {
  311. output = await runCommand("keytool", [
  312. "-J-Duser.language=en",
  313. "-J-Duser.country=US",
  314. "-list",
  315. "-v",
  316. "-keystore",
  317. keystorePath,
  318. "-storepass",
  319. keystorePassword,
  320. ]);
  321. } catch (error) {
  322. throw new Error(`无法自动解析证书 alias,请安装 keytool 或在 ${CLIENT_ENV_FILENAME} 中填写 BUILD_KEY_ALIAS。原始错误: ${error.message}`);
  323. }
  324. const aliases = parseAliasNames(`${output.stdout}\n${output.stderr}`);
  325. if (aliases.length === 0) {
  326. throw new Error(`未能从证书中解析到 alias,请在 ${CLIENT_ENV_FILENAME} 中填写 BUILD_KEY_ALIAS`);
  327. }
  328. if (aliases.length > 1) {
  329. throw new Error(`证书中存在多个 alias: ${aliases.join(", ")},请在 ${CLIENT_ENV_FILENAME} 中显式指定 BUILD_KEY_ALIAS`);
  330. }
  331. return aliases[0];
  332. }
  333. async function createBuildInputArchive(config, resourceMetadata) {
  334. const stageRoot = await mkdtemp(join(tmpdir(), "android-build-client-"));
  335. const distRoot = config.outputDir;
  336. const safeVersion = String(resourceMetadata.versionName).replaceAll("/", "_");
  337. const timestamp = new Date().toISOString().replaceAll(":", "").replaceAll("-", "").replace(/\..+/, "");
  338. const archivePath = join(
  339. distRoot,
  340. `android-build-input_${resourceMetadata.uniAppId}_v${safeVersion}_${timestamp}.tar.gz`,
  341. );
  342. try {
  343. await mkdir(join(stageRoot, "www"), { recursive: true });
  344. await mkdir(join(stageRoot, "native-res"), { recursive: true });
  345. await mkdir(join(stageRoot, "meta"), { recursive: true });
  346. await mkdir(join(stageRoot, "signing"), { recursive: true });
  347. await mkdir(distRoot, { recursive: true });
  348. await cp(join(resourceMetadata.resourceDir, "www"), join(stageRoot, "www"), { recursive: true });
  349. const nativeResources = await collectNativeResources(config.projectRoot);
  350. for (const mapping of nativeResources) {
  351. const destination = join(stageRoot, "native-res", mapping.target);
  352. await mkdir(dirname(destination), { recursive: true });
  353. await cp(mapping.source, destination);
  354. }
  355. const buildMetadata = {
  356. uni_appid: resourceMetadata.uniAppId,
  357. app_name: resourceMetadata.appName,
  358. version_name: resourceMetadata.versionName,
  359. version_code: resourceMetadata.versionCode,
  360. };
  361. if (
  362. Array.isArray(resourceMetadata.androidPermissions) &&
  363. resourceMetadata.androidPermissions.length > 0
  364. ) {
  365. buildMetadata.android_permissions = resourceMetadata.androidPermissions;
  366. }
  367. await writeFile(
  368. join(stageRoot, "meta", "build.json"),
  369. JSON.stringify(buildMetadata, null, 2) + "\n",
  370. "utf8",
  371. );
  372. await cp(config.keystorePath, join(stageRoot, "signing", basename(config.keystorePath)));
  373. await runTar(stageRoot, archivePath);
  374. return { archivePath, cleanup: () => rm(stageRoot, { recursive: true, force: true }) };
  375. } catch (error) {
  376. await rm(stageRoot, { recursive: true, force: true });
  377. throw error;
  378. }
  379. }
  380. function runTar(stageRoot, archivePath) {
  381. return new Promise((resolvePromise, rejectPromise) => {
  382. const child = spawn("tar", ["-C", stageRoot, "-czf", archivePath, "."], {
  383. env: {
  384. ...process.env,
  385. COPYFILE_DISABLE: "1",
  386. },
  387. stdio: "inherit",
  388. });
  389. child.on("error", (error) => {
  390. rejectPromise(new Error(`执行 tar 失败: ${error.message}`));
  391. });
  392. child.on("close", (code) => {
  393. if (code === 0) {
  394. resolvePromise();
  395. return;
  396. }
  397. rejectPromise(new Error(`tar 打包失败,退出码: ${code}`));
  398. });
  399. });
  400. }
  401. function encodeS3Path(key) {
  402. return key
  403. .split("/")
  404. .map((segment) => encodeURIComponent(segment).replace(/[!*'()]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`))
  405. .join("/");
  406. }
  407. async function uploadArchive(config, archivePath, resourceMetadata) {
  408. const body = await readFile(archivePath);
  409. const version = resourceMetadata.versionName;
  410. const datePath = new Date().toISOString().slice(0, 10).replaceAll("-", "");
  411. const timestamp = new Date().toISOString().replaceAll(":", "").replaceAll("-", "").replace(/\..+/, "");
  412. const objectKey = [
  413. config.uploadPrefix,
  414. ARTIFACT_SCENARIO,
  415. config.appPackage,
  416. version,
  417. datePath,
  418. `${basename(archivePath, ".tar.gz")}_${timestamp}.tar.gz`,
  419. ].join("/");
  420. const uploadUrl = `${config.uploadBaseUrl}/${encodeS3Path(objectKey)}`;
  421. const response = await fetch(uploadUrl, {
  422. method: "PUT",
  423. headers: {
  424. "Content-Type": "application/gzip",
  425. },
  426. body,
  427. });
  428. if (!response.ok) {
  429. throw new Error(`上传构建包失败: ${response.status} ${await response.text()}`);
  430. }
  431. return {
  432. resourceUrl: `${config.uploadBaseUrl}/${objectKey}`,
  433. objectKey,
  434. };
  435. }
  436. async function requestRemoteBuild(config, resourceMetadata, resourceUrl) {
  437. const payload = {
  438. resource_url: resourceUrl,
  439. app_package: config.appPackage,
  440. dcloud_appkey: config.dcloudAppKey,
  441. keystore_password: config.keystorePassword,
  442. key_alias: config.keyAlias,
  443. key_password: config.keyPassword,
  444. };
  445. payload.version_name = resourceMetadata.versionName;
  446. payload.version_code = resourceMetadata.versionCode;
  447. let response;
  448. try {
  449. response = await fetch(`${config.serviceBaseUrl}/v1/android/builds`, {
  450. method: "POST",
  451. headers: { "Content-Type": "application/json" },
  452. body: JSON.stringify(payload),
  453. signal: AbortSignal.timeout(15000),
  454. });
  455. } catch (error) {
  456. throw new Error(`请求构建服务失败: ${config.serviceBaseUrl} 不可达。原始错误: ${error.message}`);
  457. }
  458. if (!response.ok) {
  459. throw new Error(`请求构建服务失败: ${response.status} ${await response.text()}`);
  460. }
  461. const data = await response.json();
  462. return {
  463. ...data,
  464. requestPayload: payload,
  465. metadata: resourceMetadata,
  466. };
  467. }
  468. async function waitForTask(config, taskId) {
  469. while (true) {
  470. let response;
  471. try {
  472. response = await fetch(`${config.serviceBaseUrl}/v1/android/builds/${taskId}`, {
  473. signal: AbortSignal.timeout(15000),
  474. });
  475. } catch (error) {
  476. throw new Error(
  477. `查询任务状态失败: ${config.serviceBaseUrl} 不可达。原始错误: ${error.message}`,
  478. );
  479. }
  480. if (!response.ok) {
  481. throw new Error(`查询任务状态失败: ${response.status} ${await response.text()}`);
  482. }
  483. const task = await response.json();
  484. console.log(`[build] 任务状态: ${task.status}`);
  485. if (task.status === "succeeded") {
  486. return task;
  487. }
  488. if (task.status === "failed") {
  489. throw new Error(task.error_message || "远程构建失败");
  490. }
  491. await new Promise((resolvePromise) =>
  492. setTimeout(resolvePromise, config.pollIntervalSeconds * 1000),
  493. );
  494. }
  495. }
  496. async function main() {
  497. if (process.argv.includes("-h") || process.argv.includes("--help")) {
  498. printUsage();
  499. return;
  500. }
  501. const projectRoot = process.cwd();
  502. const clientEnv = await loadClientEnv(projectRoot);
  503. const dcloudAppKey = (process.env.BUILD_DCLOUD_APPKEY || clientEnv.BUILD_DCLOUD_APPKEY || "").trim();
  504. if (!dcloudAppKey) {
  505. throw new Error(`缺少 BUILD_DCLOUD_APPKEY,请在 ${CLIENT_ENV_FILENAME} 或环境变量中提供`);
  506. }
  507. const keystorePassword = (
  508. process.env.BUILD_KEYSTORE_PASSWORD || clientEnv.BUILD_KEYSTORE_PASSWORD || ""
  509. ).trim();
  510. if (!keystorePassword) {
  511. throw new Error(`缺少 BUILD_KEYSTORE_PASSWORD,请在 ${CLIENT_ENV_FILENAME} 或环境变量中提供`);
  512. }
  513. const keyPassword = (
  514. process.env.BUILD_KEY_PASSWORD || clientEnv.BUILD_KEY_PASSWORD || keystorePassword
  515. ).trim();
  516. const projectManifest = await loadProjectManifest(projectRoot);
  517. const appPackage = await resolveAppPackage(projectRoot, clientEnv);
  518. const keystorePath = await findProjectKeystore(projectRoot);
  519. const configuredAlias = (process.env.BUILD_KEY_ALIAS || clientEnv.BUILD_KEY_ALIAS || "").trim();
  520. const keyAlias = configuredAlias || (await detectKeyAlias(keystorePath, keystorePassword));
  521. const config = {
  522. projectRoot,
  523. serviceBaseUrl: BUILD_SERVICE_BASE_URL,
  524. uploadBaseUrl: DEFAULT_UPLOAD_BASE_URL,
  525. uploadPrefix: DEFAULT_UPLOAD_PREFIX,
  526. appPackage,
  527. dcloudAppKey,
  528. keystorePath,
  529. keystorePassword,
  530. keyAlias,
  531. keyPassword,
  532. outputDir: resolve(projectRoot, "dist"),
  533. pollIntervalSeconds: DEFAULT_POLL_INTERVAL_SECONDS,
  534. };
  535. const resourceDir = await findLatestResourceDir(projectRoot);
  536. const exportMetadata = await loadResourceMetadata(resourceDir);
  537. const resourceMetadata = {
  538. resourceDir,
  539. uniAppId: projectManifest.uniAppId || exportMetadata.uniAppId,
  540. appName: projectManifest.appName,
  541. versionName: projectManifest.versionName,
  542. versionCode: projectManifest.versionCode,
  543. androidPermissions: projectManifest.androidPermissions,
  544. };
  545. console.log(`[build] 构建服务: ${config.serviceBaseUrl}`);
  546. console.log(`[build] 资源目录: ${resourceDir}`);
  547. console.log(`[build] 资源 AppID: ${resourceMetadata.uniAppId}`);
  548. console.log(`[build] 包名: ${config.appPackage}`);
  549. console.log(`[build] 版本: ${resourceMetadata.versionName} (${resourceMetadata.versionCode})`);
  550. console.log(`[build] Android 权限声明: ${resourceMetadata.androidPermissions.length} 条`);
  551. console.log(`[build] 签名文件: ${config.keystorePath}`);
  552. console.log("[build] 抓包支持: 已固定允许用户证书与明文 HTTP");
  553. const { archivePath, cleanup } = await createBuildInputArchive(config, resourceMetadata);
  554. try {
  555. console.log(`[build] 标准包已生成: ${archivePath}`);
  556. const uploadResult = await uploadArchive(config, archivePath, resourceMetadata);
  557. console.log(`[build] 标准包已上传: ${uploadResult.resourceUrl}`);
  558. const buildRequest = await requestRemoteBuild(config, resourceMetadata, uploadResult.resourceUrl);
  559. console.log(`[build] 已提交构建任务: ${buildRequest.task_id}`);
  560. const finalTask = await waitForTask(config, buildRequest.task_id);
  561. console.log(`[build] 构建完成: ${finalTask.artifact_url || "-"}`);
  562. } finally {
  563. await cleanup();
  564. }
  565. }
  566. main().catch((error) => {
  567. console.error(`[build] ${error.message}`);
  568. process.exitCode = 1;
  569. });