android-build-client.mjs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  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 中的版本号
  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. async function loadProjectManifest(projectRoot) {
  149. const manifestPath = join(projectRoot, "manifest.json");
  150. if (!(await fileExists(manifestPath))) {
  151. throw new Error("缺少项目根目录 manifest.json");
  152. }
  153. const data = await parseJsonFile(manifestPath);
  154. return {
  155. appName: data.name,
  156. uniAppId: data.appid,
  157. versionName: data.versionName,
  158. versionCode: Number(data.versionCode),
  159. };
  160. }
  161. async function loadResourceMetadata(resourceDir) {
  162. const manifestPath = join(resourceDir, "www", "manifest.json");
  163. const data = await parseJsonFile(manifestPath);
  164. return {
  165. resourceDir,
  166. uniAppId: data.id,
  167. };
  168. }
  169. async function deriveAppPackage(projectRoot) {
  170. const environmentPath = join(projectRoot, "config", "environment.js");
  171. if (await fileExists(environmentPath)) {
  172. const content = await readFile(environmentPath, "utf8");
  173. const match = content.match(/https:\/\/([a-z0-9-]+)\.agmp\.yunfeiyun\.com/i);
  174. if (match?.[1]) {
  175. return `com.agmp.${match[1].toLowerCase()}`;
  176. }
  177. }
  178. throw new Error("无法自动推导 Android 包名,请提供环境变量 BUILD_APP_PACKAGE");
  179. }
  180. async function resolveAppPackage(projectRoot, clientEnv) {
  181. const appPackage = (process.env.BUILD_APP_PACKAGE || clientEnv.BUILD_APP_PACKAGE || "").trim();
  182. if (appPackage) {
  183. return appPackage;
  184. }
  185. return deriveAppPackage(projectRoot);
  186. }
  187. async function collectNativeResources(projectRoot) {
  188. const manifestPath = join(projectRoot, "manifest.json");
  189. if (!(await fileExists(manifestPath))) {
  190. return [];
  191. }
  192. const manifest = await parseJsonFile(manifestPath);
  193. const appPlus = manifest["app-plus"] || {};
  194. const distribute = appPlus.distribute || {};
  195. const androidIcons = (distribute.icons || {}).android || {};
  196. const androidSplash = (distribute.splashscreen || {}).android || {};
  197. const densityMap = {
  198. hdpi: "drawable-hdpi",
  199. xhdpi: "drawable-xhdpi",
  200. xxhdpi: "drawable-xxhdpi",
  201. xxxhdpi: "drawable-xxxhdpi",
  202. };
  203. const mappings = [];
  204. const missing = [];
  205. for (const [density, relPath] of Object.entries(androidIcons)) {
  206. const targetDir = densityMap[density];
  207. if (!targetDir) continue;
  208. const absPath = resolve(projectRoot, relPath);
  209. if (await fileExists(absPath)) {
  210. mappings.push({ source: absPath, target: join(targetDir, "icon.png") });
  211. } else {
  212. missing.push(relPath);
  213. }
  214. }
  215. for (const [density, relPath] of Object.entries(androidSplash)) {
  216. const targetDir = densityMap[density];
  217. if (!targetDir) continue;
  218. const absPath = resolve(projectRoot, relPath);
  219. if (await fileExists(absPath)) {
  220. mappings.push({ source: absPath, target: join(targetDir, "splash.png") });
  221. } else {
  222. missing.push(relPath);
  223. }
  224. }
  225. if (missing.length > 0) {
  226. throw new Error(`manifest.json 中声明的 Android 图标或启动图不存在: ${missing.join(", ")}`);
  227. }
  228. return mappings;
  229. }
  230. async function findProjectKeystore(projectRoot) {
  231. const entries = await readdir(projectRoot, { withFileTypes: true });
  232. const keystoreFiles = entries
  233. .filter((entry) => entry.isFile())
  234. .map((entry) => join(projectRoot, entry.name))
  235. .filter((path) => SUPPORTED_KEYSTORE_SUFFIXES.has(extname(path).toLowerCase()));
  236. if (keystoreFiles.length === 0) {
  237. throw new Error("项目根目录缺少签名文件,请放置一个 .keystore 或 .jks 文件");
  238. }
  239. if (keystoreFiles.length > 1) {
  240. throw new Error("项目根目录存在多个签名文件,请只保留一个 .keystore 或 .jks 文件");
  241. }
  242. return keystoreFiles[0];
  243. }
  244. function runCommand(command, args) {
  245. return new Promise((resolvePromise, rejectPromise) => {
  246. const child = spawn(command, args, {
  247. stdio: ["ignore", "pipe", "pipe"],
  248. });
  249. let stdout = "";
  250. let stderr = "";
  251. child.stdout.on("data", (chunk) => {
  252. stdout += String(chunk);
  253. });
  254. child.stderr.on("data", (chunk) => {
  255. stderr += String(chunk);
  256. });
  257. child.on("error", (error) => {
  258. rejectPromise(error);
  259. });
  260. child.on("close", (code) => {
  261. if (code === 0) {
  262. resolvePromise({ stdout, stderr });
  263. return;
  264. }
  265. rejectPromise(new Error(stderr.trim() || stdout.trim() || `${command} 退出码: ${code}`));
  266. });
  267. });
  268. }
  269. function parseAliasNames(text) {
  270. const aliases = new Set();
  271. for (const line of text.split(/\r?\n/)) {
  272. const trimmed = line.trim();
  273. const aliasMatch = trimmed.match(/^Alias name:\s*(.+)$/i);
  274. if (aliasMatch?.[1]) {
  275. aliases.add(aliasMatch[1].trim());
  276. continue;
  277. }
  278. const chineseMatch = trimmed.match(/^别名(?:名称)?[::]\s*(.+)$/);
  279. if (chineseMatch?.[1]) {
  280. aliases.add(chineseMatch[1].trim());
  281. }
  282. }
  283. return [...aliases];
  284. }
  285. async function detectKeyAlias(keystorePath, keystorePassword) {
  286. let output;
  287. try {
  288. output = await runCommand("keytool", [
  289. "-J-Duser.language=en",
  290. "-J-Duser.country=US",
  291. "-list",
  292. "-v",
  293. "-keystore",
  294. keystorePath,
  295. "-storepass",
  296. keystorePassword,
  297. ]);
  298. } catch (error) {
  299. throw new Error(`无法自动解析证书 alias,请安装 keytool 或在 ${CLIENT_ENV_FILENAME} 中填写 BUILD_KEY_ALIAS。原始错误: ${error.message}`);
  300. }
  301. const aliases = parseAliasNames(`${output.stdout}\n${output.stderr}`);
  302. if (aliases.length === 0) {
  303. throw new Error(`未能从证书中解析到 alias,请在 ${CLIENT_ENV_FILENAME} 中填写 BUILD_KEY_ALIAS`);
  304. }
  305. if (aliases.length > 1) {
  306. throw new Error(`证书中存在多个 alias: ${aliases.join(", ")},请在 ${CLIENT_ENV_FILENAME} 中显式指定 BUILD_KEY_ALIAS`);
  307. }
  308. return aliases[0];
  309. }
  310. async function createBuildInputArchive(config, resourceMetadata) {
  311. const stageRoot = await mkdtemp(join(tmpdir(), "android-build-client-"));
  312. const distRoot = config.outputDir;
  313. const safeVersion = String(resourceMetadata.versionName).replaceAll("/", "_");
  314. const timestamp = new Date().toISOString().replaceAll(":", "").replaceAll("-", "").replace(/\..+/, "");
  315. const archivePath = join(
  316. distRoot,
  317. `android-build-input_${resourceMetadata.uniAppId}_v${safeVersion}_${timestamp}.tar.gz`,
  318. );
  319. try {
  320. await mkdir(join(stageRoot, "www"), { recursive: true });
  321. await mkdir(join(stageRoot, "native-res"), { recursive: true });
  322. await mkdir(join(stageRoot, "meta"), { recursive: true });
  323. await mkdir(join(stageRoot, "signing"), { recursive: true });
  324. await mkdir(distRoot, { recursive: true });
  325. await cp(join(resourceMetadata.resourceDir, "www"), join(stageRoot, "www"), { recursive: true });
  326. const nativeResources = await collectNativeResources(config.projectRoot);
  327. for (const mapping of nativeResources) {
  328. const destination = join(stageRoot, "native-res", mapping.target);
  329. await mkdir(dirname(destination), { recursive: true });
  330. await cp(mapping.source, destination);
  331. }
  332. await writeFile(
  333. join(stageRoot, "meta", "build.json"),
  334. JSON.stringify(
  335. {
  336. uni_appid: resourceMetadata.uniAppId,
  337. app_name: resourceMetadata.appName,
  338. version_name: resourceMetadata.versionName,
  339. version_code: resourceMetadata.versionCode,
  340. },
  341. null,
  342. 2,
  343. ) + "\n",
  344. "utf8",
  345. );
  346. await cp(config.keystorePath, join(stageRoot, "signing", basename(config.keystorePath)));
  347. await runTar(stageRoot, archivePath);
  348. return { archivePath, cleanup: () => rm(stageRoot, { recursive: true, force: true }) };
  349. } catch (error) {
  350. await rm(stageRoot, { recursive: true, force: true });
  351. throw error;
  352. }
  353. }
  354. function runTar(stageRoot, archivePath) {
  355. return new Promise((resolvePromise, rejectPromise) => {
  356. const child = spawn("tar", ["-C", stageRoot, "-czf", archivePath, "."], {
  357. env: {
  358. ...process.env,
  359. COPYFILE_DISABLE: "1",
  360. },
  361. stdio: "inherit",
  362. });
  363. child.on("error", (error) => {
  364. rejectPromise(new Error(`执行 tar 失败: ${error.message}`));
  365. });
  366. child.on("close", (code) => {
  367. if (code === 0) {
  368. resolvePromise();
  369. return;
  370. }
  371. rejectPromise(new Error(`tar 打包失败,退出码: ${code}`));
  372. });
  373. });
  374. }
  375. function encodeS3Path(key) {
  376. return key
  377. .split("/")
  378. .map((segment) => encodeURIComponent(segment).replace(/[!*'()]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`))
  379. .join("/");
  380. }
  381. async function uploadArchive(config, archivePath, resourceMetadata) {
  382. const body = await readFile(archivePath);
  383. const version = resourceMetadata.versionName;
  384. const datePath = new Date().toISOString().slice(0, 10).replaceAll("-", "");
  385. const timestamp = new Date().toISOString().replaceAll(":", "").replaceAll("-", "").replace(/\..+/, "");
  386. const objectKey = [
  387. config.uploadPrefix,
  388. ARTIFACT_SCENARIO,
  389. config.appPackage,
  390. version,
  391. datePath,
  392. `${basename(archivePath, ".tar.gz")}_${timestamp}.tar.gz`,
  393. ].join("/");
  394. const uploadUrl = `${config.uploadBaseUrl}/${encodeS3Path(objectKey)}`;
  395. const response = await fetch(uploadUrl, {
  396. method: "PUT",
  397. headers: {
  398. "Content-Type": "application/gzip",
  399. },
  400. body,
  401. });
  402. if (!response.ok) {
  403. throw new Error(`上传构建包失败: ${response.status} ${await response.text()}`);
  404. }
  405. return {
  406. resourceUrl: `${config.uploadBaseUrl}/${objectKey}`,
  407. objectKey,
  408. };
  409. }
  410. async function requestRemoteBuild(config, resourceMetadata, resourceUrl) {
  411. const payload = {
  412. resource_url: resourceUrl,
  413. app_package: config.appPackage,
  414. dcloud_appkey: config.dcloudAppKey,
  415. keystore_password: config.keystorePassword,
  416. key_alias: config.keyAlias,
  417. key_password: config.keyPassword,
  418. };
  419. payload.version_name = resourceMetadata.versionName;
  420. payload.version_code = resourceMetadata.versionCode;
  421. let response;
  422. try {
  423. response = await fetch(`${config.serviceBaseUrl}/v1/android/builds`, {
  424. method: "POST",
  425. headers: { "Content-Type": "application/json" },
  426. body: JSON.stringify(payload),
  427. signal: AbortSignal.timeout(15000),
  428. });
  429. } catch (error) {
  430. throw new Error(`请求构建服务失败: ${config.serviceBaseUrl} 不可达。原始错误: ${error.message}`);
  431. }
  432. if (!response.ok) {
  433. throw new Error(`请求构建服务失败: ${response.status} ${await response.text()}`);
  434. }
  435. const data = await response.json();
  436. return {
  437. ...data,
  438. requestPayload: payload,
  439. metadata: resourceMetadata,
  440. };
  441. }
  442. async function waitForTask(config, taskId) {
  443. while (true) {
  444. let response;
  445. try {
  446. response = await fetch(`${config.serviceBaseUrl}/v1/android/builds/${taskId}`, {
  447. signal: AbortSignal.timeout(15000),
  448. });
  449. } catch (error) {
  450. throw new Error(
  451. `查询任务状态失败: ${config.serviceBaseUrl} 不可达。原始错误: ${error.message}`,
  452. );
  453. }
  454. if (!response.ok) {
  455. throw new Error(`查询任务状态失败: ${response.status} ${await response.text()}`);
  456. }
  457. const task = await response.json();
  458. console.log(`[build] 任务状态: ${task.status}`);
  459. if (task.status === "succeeded") {
  460. return task;
  461. }
  462. if (task.status === "failed") {
  463. throw new Error(task.error_message || "远程构建失败");
  464. }
  465. await new Promise((resolvePromise) =>
  466. setTimeout(resolvePromise, config.pollIntervalSeconds * 1000),
  467. );
  468. }
  469. }
  470. async function main() {
  471. if (process.argv.includes("-h") || process.argv.includes("--help")) {
  472. printUsage();
  473. return;
  474. }
  475. const projectRoot = process.cwd();
  476. const clientEnv = await loadClientEnv(projectRoot);
  477. const dcloudAppKey = (process.env.BUILD_DCLOUD_APPKEY || clientEnv.BUILD_DCLOUD_APPKEY || "").trim();
  478. if (!dcloudAppKey) {
  479. throw new Error(`缺少 BUILD_DCLOUD_APPKEY,请在 ${CLIENT_ENV_FILENAME} 或环境变量中提供`);
  480. }
  481. const keystorePassword = (
  482. process.env.BUILD_KEYSTORE_PASSWORD || clientEnv.BUILD_KEYSTORE_PASSWORD || ""
  483. ).trim();
  484. if (!keystorePassword) {
  485. throw new Error(`缺少 BUILD_KEYSTORE_PASSWORD,请在 ${CLIENT_ENV_FILENAME} 或环境变量中提供`);
  486. }
  487. const keyPassword = (
  488. process.env.BUILD_KEY_PASSWORD || clientEnv.BUILD_KEY_PASSWORD || keystorePassword
  489. ).trim();
  490. const projectManifest = await loadProjectManifest(projectRoot);
  491. const appPackage = await resolveAppPackage(projectRoot, clientEnv);
  492. const keystorePath = await findProjectKeystore(projectRoot);
  493. const configuredAlias = (process.env.BUILD_KEY_ALIAS || clientEnv.BUILD_KEY_ALIAS || "").trim();
  494. const keyAlias = configuredAlias || (await detectKeyAlias(keystorePath, keystorePassword));
  495. const config = {
  496. projectRoot,
  497. serviceBaseUrl: BUILD_SERVICE_BASE_URL,
  498. uploadBaseUrl: DEFAULT_UPLOAD_BASE_URL,
  499. uploadPrefix: DEFAULT_UPLOAD_PREFIX,
  500. appPackage,
  501. dcloudAppKey,
  502. keystorePath,
  503. keystorePassword,
  504. keyAlias,
  505. keyPassword,
  506. outputDir: resolve(projectRoot, "dist"),
  507. pollIntervalSeconds: DEFAULT_POLL_INTERVAL_SECONDS,
  508. };
  509. const resourceDir = await findLatestResourceDir(projectRoot);
  510. const exportMetadata = await loadResourceMetadata(resourceDir);
  511. const resourceMetadata = {
  512. resourceDir,
  513. uniAppId: projectManifest.uniAppId || exportMetadata.uniAppId,
  514. appName: projectManifest.appName,
  515. versionName: projectManifest.versionName,
  516. versionCode: projectManifest.versionCode,
  517. };
  518. console.log(`[build] 构建服务: ${config.serviceBaseUrl}`);
  519. console.log(`[build] 资源目录: ${resourceDir}`);
  520. console.log(`[build] 资源 AppID: ${resourceMetadata.uniAppId}`);
  521. console.log(`[build] 包名: ${config.appPackage}`);
  522. console.log(`[build] 版本: ${resourceMetadata.versionName} (${resourceMetadata.versionCode})`);
  523. console.log(`[build] 签名文件: ${config.keystorePath}`);
  524. console.log("[build] 抓包支持: 已固定允许用户证书与明文 HTTP");
  525. const { archivePath, cleanup } = await createBuildInputArchive(config, resourceMetadata);
  526. try {
  527. console.log(`[build] 标准包已生成: ${archivePath}`);
  528. const uploadResult = await uploadArchive(config, archivePath, resourceMetadata);
  529. console.log(`[build] 标准包已上传: ${uploadResult.resourceUrl}`);
  530. const buildRequest = await requestRemoteBuild(config, resourceMetadata, uploadResult.resourceUrl);
  531. console.log(`[build] 已提交构建任务: ${buildRequest.task_id}`);
  532. const finalTask = await waitForTask(config, buildRequest.task_id);
  533. console.log(`[build] 构建完成: ${finalTask.artifact_url || "-"}`);
  534. } finally {
  535. await cleanup();
  536. }
  537. }
  538. main().catch((error) => {
  539. console.error(`[build] ${error.message}`);
  540. process.exitCode = 1;
  541. });