|
@@ -1,605 +0,0 @@
|
|
|
-#!/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);
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// 变体图标约定:项目根目录 appIcon/zk.png(中性版)/ appIcon/yfzk.png(云飞版),
|
|
|
|
|
-// 1024 原图直接打包不 resize(缺档由 Android 自动缩放),服务端按 isneutral 选中覆盖
|
|
|
|
|
-const VARIANT_ICON_SOURCES = [
|
|
|
|
|
- { source: join("appIcon", "zk.png"), target: join("neutral", "icon-src.png"), label: "中性版" },
|
|
|
|
|
- { source: join("appIcon", "yfzk.png"), target: join("yunfei", "icon-src.png"), label: "云飞版" },
|
|
|
|
|
-];
|
|
|
|
|
-
|
|
|
|
|
-async function collectNativeResources(projectRoot) {
|
|
|
|
|
- // 变体图标必须存在:找不到直接报错,提示放这两张 1024 原图
|
|
|
|
|
- const mappings = [];
|
|
|
|
|
- const missingVariants = [];
|
|
|
|
|
- for (const item of VARIANT_ICON_SOURCES) {
|
|
|
|
|
- const absPath = resolve(projectRoot, item.source);
|
|
|
|
|
- if (await fileExists(absPath)) {
|
|
|
|
|
- mappings.push({ source: absPath, target: item.target });
|
|
|
|
|
- } else {
|
|
|
|
|
- missingVariants.push(item.source);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- if (missingVariants.length > 0) {
|
|
|
|
|
- throw new Error(
|
|
|
|
|
- `缺少变体图标: ${missingVariants.join(", ")}。请将 1024x1024 原图放到项目根目录 appIcon/zk.png(中性版)和 appIcon/yfzk.png(云飞版),无需手动切尺寸`,
|
|
|
|
|
- );
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const manifestPath = join(projectRoot, "manifest.json");
|
|
|
|
|
- if (!(await fileExists(manifestPath))) {
|
|
|
|
|
- return mappings;
|
|
|
|
|
- }
|
|
|
|
|
- const manifest = await parseJsonFile(manifestPath);
|
|
|
|
|
- const appPlus = manifest["app-plus"] || {};
|
|
|
|
|
- const distribute = appPlus.distribute || {};
|
|
|
|
|
- // manifest 声明的 Android 图标不再作为 icon 来源(双变体图标由服务端按 isneutral
|
|
|
|
|
- // 选中 appIcon 下的变体图覆盖四个 drawable),因此这里跳过 icons,只处理启动图 splash
|
|
|
|
|
- const androidSplash = (distribute.splashscreen || {}).android || {};
|
|
|
|
|
- const densityMap = {
|
|
|
|
|
- hdpi: "drawable-hdpi",
|
|
|
|
|
- xhdpi: "drawable-xhdpi",
|
|
|
|
|
- xxhdpi: "drawable-xxhdpi",
|
|
|
|
|
- xxxhdpi: "drawable-xxxhdpi",
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- const missing = [];
|
|
|
|
|
- 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;
|
|
|
|
|
-});
|
|
|