@huoye/app-ship 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +503 -0
- package/dist/cli.js +4298 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +4167 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1304 -0
- package/dist/index.d.ts +1304 -0
- package/dist/index.js +4118 -0
- package/dist/index.js.map +1 -0
- package/package.json +89 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,4298 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
3
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
4
|
+
}) : x)(function(x) {
|
|
5
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// src/utils/upload-manager.ts
|
|
10
|
+
import path9 from "path";
|
|
11
|
+
|
|
12
|
+
// src/utils/logger.ts
|
|
13
|
+
import chalk from "chalk";
|
|
14
|
+
var ConsoleLogger = class {
|
|
15
|
+
info(message) {
|
|
16
|
+
console.log(chalk.blue("\u2139\uFE0F " + message));
|
|
17
|
+
}
|
|
18
|
+
success(message) {
|
|
19
|
+
console.log(chalk.green("\u2705 " + message));
|
|
20
|
+
}
|
|
21
|
+
warn(message) {
|
|
22
|
+
console.log(chalk.yellow("\u26A0\uFE0F " + message));
|
|
23
|
+
}
|
|
24
|
+
error(message) {
|
|
25
|
+
console.log(chalk.red("\u274C " + message));
|
|
26
|
+
}
|
|
27
|
+
title(message) {
|
|
28
|
+
console.log(chalk.bold.cyan("\n" + message + "\n"));
|
|
29
|
+
}
|
|
30
|
+
newLine() {
|
|
31
|
+
console.log("");
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
var logger = new ConsoleLogger();
|
|
35
|
+
|
|
36
|
+
// src/adapters/pgyer_uploader.ts
|
|
37
|
+
import fs3 from "fs-extra";
|
|
38
|
+
import axios from "axios";
|
|
39
|
+
import FormData from "form-data";
|
|
40
|
+
|
|
41
|
+
// src/utils/retry-strategy.ts
|
|
42
|
+
var RetryStrategy = class {
|
|
43
|
+
config;
|
|
44
|
+
constructor(config = {}) {
|
|
45
|
+
this.config = {
|
|
46
|
+
maxRetries: config.maxRetries ?? 3,
|
|
47
|
+
initialDelayMs: config.initialDelayMs ?? 1e3,
|
|
48
|
+
backoffMultiplier: config.backoffMultiplier ?? 2,
|
|
49
|
+
maxDelayMs: config.maxDelayMs ?? 3e4
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 根据错误类型判断是否应该重试
|
|
54
|
+
*/
|
|
55
|
+
shouldRetry(error, retryCount) {
|
|
56
|
+
if (retryCount >= this.config.maxRetries) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
const category = this.classifyError(error);
|
|
60
|
+
return this.isRetryableCategory(category);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 对错误进行分类
|
|
64
|
+
*/
|
|
65
|
+
classifyError(error) {
|
|
66
|
+
const message = error.message.toLowerCase();
|
|
67
|
+
const errorStr = JSON.stringify(error).toLowerCase();
|
|
68
|
+
if (message.includes("econnrefused") || message.includes("enotfound") || message.includes("etimedout") || message.includes("network") || message.includes("socket hang up") || message.includes("socket") || message.includes("econnreset") || message.includes("epipe") || message.includes("hang up")) {
|
|
69
|
+
return "NETWORK_ERROR" /* NETWORK_ERROR */;
|
|
70
|
+
}
|
|
71
|
+
if (message.includes("401") || message.includes("403") || message.includes("unauthorized") || message.includes("forbidden") || message.includes("invalid") || message.includes("token")) {
|
|
72
|
+
return "AUTH_ERROR" /* AUTH_ERROR */;
|
|
73
|
+
}
|
|
74
|
+
if (message.includes("500") || message.includes("502") || message.includes("503") || message.includes("504") || message.includes("server error") || message.includes("\u670D\u52A1\u5668\u7AEF\u5F02\u5E38")) {
|
|
75
|
+
return "SERVER_ERROR" /* SERVER_ERROR */;
|
|
76
|
+
}
|
|
77
|
+
if (message.includes("400") || message.includes("404") || message.includes("bad request") || message.includes("not found")) {
|
|
78
|
+
return "CLIENT_ERROR" /* CLIENT_ERROR */;
|
|
79
|
+
}
|
|
80
|
+
if (message.includes("timeout") || message.includes("rate limit") || message.includes("too many") || message.includes("429")) {
|
|
81
|
+
return "TEMPORARY_ERROR" /* TEMPORARY_ERROR */;
|
|
82
|
+
}
|
|
83
|
+
return "UNKNOWN_ERROR" /* UNKNOWN_ERROR */;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 判断错误类别是否可重试
|
|
87
|
+
*/
|
|
88
|
+
isRetryableCategory(category) {
|
|
89
|
+
const retryableCategories = [
|
|
90
|
+
"NETWORK_ERROR" /* NETWORK_ERROR */,
|
|
91
|
+
"TEMPORARY_ERROR" /* TEMPORARY_ERROR */,
|
|
92
|
+
"SERVER_ERROR" /* SERVER_ERROR */
|
|
93
|
+
];
|
|
94
|
+
return retryableCategories.includes(category);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* 计算延迟时间(使用指数退避策略)
|
|
98
|
+
*/
|
|
99
|
+
calculateDelay(retryCount) {
|
|
100
|
+
const exponentialDelay = this.config.initialDelayMs * Math.pow(this.config.backoffMultiplier, retryCount);
|
|
101
|
+
const jitter = exponentialDelay * (Math.random() * 0.2);
|
|
102
|
+
const delay = Math.min(exponentialDelay + jitter, this.config.maxDelayMs);
|
|
103
|
+
return Math.round(delay);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* 等待指定时间
|
|
107
|
+
*/
|
|
108
|
+
async sleep(ms) {
|
|
109
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 执行带重试的异步函数
|
|
113
|
+
*/
|
|
114
|
+
async execute(fn, operationName = "Operation") {
|
|
115
|
+
let lastError;
|
|
116
|
+
let attempt = 0;
|
|
117
|
+
while (attempt <= this.config.maxRetries) {
|
|
118
|
+
attempt++;
|
|
119
|
+
try {
|
|
120
|
+
logger.info(`${operationName} (Attempt ${attempt}/${this.config.maxRetries + 1})`);
|
|
121
|
+
const result = await fn();
|
|
122
|
+
if (attempt > 1) {
|
|
123
|
+
logger.success(`${operationName} succeeded after ${attempt} attempts`);
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
success: true,
|
|
127
|
+
data: result,
|
|
128
|
+
totalAttempts: attempt
|
|
129
|
+
};
|
|
130
|
+
} catch (error) {
|
|
131
|
+
lastError = error;
|
|
132
|
+
const errorMessage = error.message || String(error);
|
|
133
|
+
const category = this.classifyError(error);
|
|
134
|
+
logger.warn(`${operationName} failed (Attempt ${attempt}): ${errorMessage} [${category}]`);
|
|
135
|
+
if (!this.shouldRetry(error, attempt)) {
|
|
136
|
+
logger.error(`${operationName} failed with non-retryable error: ${errorMessage}`);
|
|
137
|
+
return {
|
|
138
|
+
success: false,
|
|
139
|
+
error,
|
|
140
|
+
totalAttempts: attempt,
|
|
141
|
+
lastError: errorMessage
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (attempt <= this.config.maxRetries) {
|
|
145
|
+
const delayMs = this.calculateDelay(attempt - 1);
|
|
146
|
+
logger.info(
|
|
147
|
+
`Retrying ${operationName} after ${delayMs}ms (${this.config.maxRetries - attempt + 1} retries left)...`
|
|
148
|
+
);
|
|
149
|
+
await this.sleep(delayMs);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
success: false,
|
|
155
|
+
error: lastError || new Error(`${operationName} failed after all retries`),
|
|
156
|
+
totalAttempts: attempt,
|
|
157
|
+
lastError: lastError?.message
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
var DEFAULT_RETRY_CONFIG = {
|
|
162
|
+
maxRetries: 3,
|
|
163
|
+
initialDelayMs: 1e3,
|
|
164
|
+
backoffMultiplier: 2,
|
|
165
|
+
maxDelayMs: 3e4
|
|
166
|
+
};
|
|
167
|
+
function createUploadRetryStrategy(config) {
|
|
168
|
+
return new RetryStrategy({
|
|
169
|
+
...DEFAULT_RETRY_CONFIG,
|
|
170
|
+
...config
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/utils/apple-jwt-generator.ts
|
|
175
|
+
import crypto from "crypto";
|
|
176
|
+
import fs from "fs-extra";
|
|
177
|
+
var AppleJWTGenerator = class _AppleJWTGenerator {
|
|
178
|
+
issuerId;
|
|
179
|
+
keyId;
|
|
180
|
+
privateKey;
|
|
181
|
+
constructor(issuerId, keyId, privateKey) {
|
|
182
|
+
this.issuerId = issuerId;
|
|
183
|
+
this.keyId = keyId;
|
|
184
|
+
this.privateKey = privateKey;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* 从文件路径加载私钥内容
|
|
188
|
+
*/
|
|
189
|
+
static async fromFile(issuerId, keyId, privateKeyPath) {
|
|
190
|
+
try {
|
|
191
|
+
const privateKey = await fs.readFile(privateKeyPath, "utf-8");
|
|
192
|
+
return new _AppleJWTGenerator(issuerId, keyId, privateKey);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
throw new Error(`Failed to load Apple private key from ${privateKeyPath}: ${error.message}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* 从内容字符串创建生成器
|
|
199
|
+
*/
|
|
200
|
+
static fromContent(issuerId, keyId, privateKeyContent) {
|
|
201
|
+
return new _AppleJWTGenerator(issuerId, keyId, privateKeyContent);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* 生成 JWT 令牌
|
|
205
|
+
* @returns Base64URL 编码的 JWT 字符串
|
|
206
|
+
*/
|
|
207
|
+
generate() {
|
|
208
|
+
try {
|
|
209
|
+
const header = this.createHeader();
|
|
210
|
+
const headerEncoded = this.base64urlEncode(JSON.stringify(header));
|
|
211
|
+
const payload = this.createPayload();
|
|
212
|
+
const payloadEncoded = this.base64urlEncode(JSON.stringify(payload));
|
|
213
|
+
const signatureInput = `${headerEncoded}.${payloadEncoded}`;
|
|
214
|
+
const signature = this.sign(signatureInput);
|
|
215
|
+
return `${signatureInput}.${signature}`;
|
|
216
|
+
} catch (error) {
|
|
217
|
+
logger.error(`Failed to generate Apple JWT: ${error.message}`);
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* 创建 JWT Header
|
|
223
|
+
*/
|
|
224
|
+
createHeader() {
|
|
225
|
+
return {
|
|
226
|
+
alg: "ES256",
|
|
227
|
+
kid: this.keyId,
|
|
228
|
+
typ: "JWT"
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* 创建 JWT Payload
|
|
233
|
+
* 有效期: 20 分钟(1200 秒)
|
|
234
|
+
*/
|
|
235
|
+
createPayload() {
|
|
236
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
237
|
+
return {
|
|
238
|
+
iss: this.issuerId,
|
|
239
|
+
aud: "appstoreconnect-v1",
|
|
240
|
+
exp: now + 1200,
|
|
241
|
+
// 20 minutes
|
|
242
|
+
iat: now
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* 使用 ES256 算法签名
|
|
247
|
+
* ES256 = ECDSA with SHA-256
|
|
248
|
+
*/
|
|
249
|
+
sign(data) {
|
|
250
|
+
const sign = crypto.createSign("SHA256");
|
|
251
|
+
sign.update(data);
|
|
252
|
+
sign.end();
|
|
253
|
+
const signature = sign.sign({
|
|
254
|
+
key: this.privateKey,
|
|
255
|
+
format: "pem"
|
|
256
|
+
});
|
|
257
|
+
const { r, s } = this.parseDERSignature(signature);
|
|
258
|
+
const combined = Buffer.concat([r, s]);
|
|
259
|
+
return this.base64urlEncode(combined);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* 解析 DER 格式的签名并提取 r 和 s
|
|
263
|
+
*/
|
|
264
|
+
parseDERSignature(derSignature) {
|
|
265
|
+
let offset = 0;
|
|
266
|
+
offset += 2;
|
|
267
|
+
if (derSignature[offset] === 129 || derSignature[offset] === 130) {
|
|
268
|
+
offset += 1;
|
|
269
|
+
} else {
|
|
270
|
+
offset += 1;
|
|
271
|
+
}
|
|
272
|
+
offset += 1;
|
|
273
|
+
const rLength = derSignature[offset];
|
|
274
|
+
offset += 1;
|
|
275
|
+
let r = derSignature.subarray(offset, offset + rLength);
|
|
276
|
+
offset += rLength;
|
|
277
|
+
if (r[0] === 0 && r.length > 32) {
|
|
278
|
+
r = r.subarray(1);
|
|
279
|
+
}
|
|
280
|
+
r = this.padBuffer(r, 32);
|
|
281
|
+
offset += 1;
|
|
282
|
+
const sLength = derSignature[offset];
|
|
283
|
+
offset += 1;
|
|
284
|
+
let s = derSignature.subarray(offset, offset + sLength);
|
|
285
|
+
if (s[0] === 0 && s.length > 32) {
|
|
286
|
+
s = s.subarray(1);
|
|
287
|
+
}
|
|
288
|
+
s = this.padBuffer(s, 32);
|
|
289
|
+
return { r: Buffer.from(r), s: Buffer.from(s) };
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* 将 Buffer 填充或截断到指定长度
|
|
293
|
+
*/
|
|
294
|
+
padBuffer(buf, length) {
|
|
295
|
+
if (buf.length === length) return buf;
|
|
296
|
+
if (buf.length > length) return buf.subarray(buf.length - length);
|
|
297
|
+
const padded = Buffer.alloc(length);
|
|
298
|
+
buf.copy(padded, length - buf.length);
|
|
299
|
+
return padded;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Base64URL 编码 (RFC 4648)
|
|
303
|
+
*/
|
|
304
|
+
base64urlEncode(data) {
|
|
305
|
+
const base64 = Buffer.from(data).toString("base64");
|
|
306
|
+
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// src/utils/package-parser.ts
|
|
311
|
+
import path from "path";
|
|
312
|
+
import fs2 from "fs";
|
|
313
|
+
import crypto2 from "crypto";
|
|
314
|
+
async function parsePackageInfo(filePath, options) {
|
|
315
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
316
|
+
try {
|
|
317
|
+
switch (ext) {
|
|
318
|
+
case ".apk":
|
|
319
|
+
return await parseApk(filePath, options);
|
|
320
|
+
case ".ipa":
|
|
321
|
+
return await parseIpa(filePath);
|
|
322
|
+
case ".hap":
|
|
323
|
+
return await parseHap(filePath);
|
|
324
|
+
default:
|
|
325
|
+
logger.warn(`[PackageParser] \u4E0D\u652F\u6301\u7684\u6587\u4EF6\u683C\u5F0F: ${ext}`);
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
} catch (error) {
|
|
329
|
+
logger.warn(`[PackageParser] \u89E3\u6790\u5931\u8D25: ${error.message}`);
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function exec(cmd, args, timeoutMs = 15e3) {
|
|
334
|
+
const { execFile } = __require("child_process");
|
|
335
|
+
return new Promise((resolve, reject) => {
|
|
336
|
+
execFile(
|
|
337
|
+
cmd,
|
|
338
|
+
args,
|
|
339
|
+
{ timeout: timeoutMs, maxBuffer: 1024 * 1024 },
|
|
340
|
+
(error, stdout, stderr) => {
|
|
341
|
+
if (error) {
|
|
342
|
+
reject(error);
|
|
343
|
+
} else {
|
|
344
|
+
resolve(stdout);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
async function getAaptPath(options) {
|
|
351
|
+
if (options?.androidSdkPath && fs2.existsSync(options.androidSdkPath)) {
|
|
352
|
+
const buildToolsDir = path.join(options.androidSdkPath, "build-tools");
|
|
353
|
+
if (fs2.existsSync(buildToolsDir)) {
|
|
354
|
+
const tool = findInBuildTools(buildToolsDir);
|
|
355
|
+
if (tool) return tool;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (await commandExists("aapt2")) return "aapt2";
|
|
359
|
+
if (await commandExists("aapt")) return "aapt";
|
|
360
|
+
const sdkRoot = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || path.join(process.env.HOME || "", "Library/Android/sdk");
|
|
361
|
+
if (sdkRoot && fs2.existsSync(sdkRoot)) {
|
|
362
|
+
const buildToolsDir = path.join(sdkRoot, "build-tools");
|
|
363
|
+
if (fs2.existsSync(buildToolsDir)) {
|
|
364
|
+
const tool = findInBuildTools(buildToolsDir);
|
|
365
|
+
if (tool) return tool;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
function findInBuildTools(buildToolsDir) {
|
|
371
|
+
const versions = fs2.readdirSync(buildToolsDir).filter((dir) => fs2.lstatSync(path.join(buildToolsDir, dir)).isDirectory()).sort((a, b) => b.localeCompare(a, void 0, { numeric: true }));
|
|
372
|
+
for (const version of versions) {
|
|
373
|
+
const aapt2 = path.join(buildToolsDir, version, "aapt2");
|
|
374
|
+
if (fs2.existsSync(aapt2)) return aapt2;
|
|
375
|
+
const aapt = path.join(buildToolsDir, version, "aapt");
|
|
376
|
+
if (fs2.existsSync(aapt)) return aapt;
|
|
377
|
+
}
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
async function commandExists(cmd) {
|
|
381
|
+
try {
|
|
382
|
+
await exec("which", [cmd], 3e3);
|
|
383
|
+
return true;
|
|
384
|
+
} catch {
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
async function parseApk(filePath, options) {
|
|
389
|
+
const tool = await getAaptPath(options);
|
|
390
|
+
if (!tool) {
|
|
391
|
+
logger.warn(
|
|
392
|
+
"[PackageParser] \u26A0\uFE0F aapt2/aapt \u4E0D\u53EF\u7528 (\u8BF7\u68C0\u67E5 ANDROID_HOME \u6216\u52A0\u5165 PATH)\uFF0C\u65E0\u6CD5\u89E3\u6790 APK \u7248\u672C\u4FE1\u606F"
|
|
393
|
+
);
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
const output = await exec(tool, ["dump", "badging", filePath]);
|
|
397
|
+
const packageMatch = output.match(
|
|
398
|
+
/package:\s+name='([^']*)'.*?versionCode='([^']*)'.*?versionName='([^']*)'/
|
|
399
|
+
);
|
|
400
|
+
if (!packageMatch) {
|
|
401
|
+
logger.warn("[PackageParser] APK badging \u8F93\u51FA\u4E2D\u672A\u627E\u5230\u7248\u672C\u4FE1\u606F");
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
const info = {
|
|
405
|
+
packageName: packageMatch[1] || void 0,
|
|
406
|
+
versionCode: packageMatch[2] || void 0,
|
|
407
|
+
versionName: packageMatch[3] || void 0
|
|
408
|
+
};
|
|
409
|
+
const nativeCodeMatch = output.match(/native-code:\s+((?:'[^']*'\s*)*)/);
|
|
410
|
+
if (nativeCodeMatch) {
|
|
411
|
+
info.nativeCode = nativeCodeMatch[1].split(/\s+/).filter((s) => s.startsWith("'") && s.endsWith("'")).map((s) => s.slice(1, -1));
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
const fileBuffer = fs2.readFileSync(filePath);
|
|
415
|
+
info.md5 = crypto2.createHash("md5").update(fileBuffer).digest("hex").toLowerCase();
|
|
416
|
+
} catch (e) {
|
|
417
|
+
logger.warn(`[PackageParser] \u8BA1\u7B97 MD5 \u5931\u8D25: ${e.message}`);
|
|
418
|
+
}
|
|
419
|
+
logger.info(
|
|
420
|
+
`[PackageParser] APK: ${info.packageName} v${info.versionName}+${info.versionCode} arch:${info.nativeCode?.join(",") || "none"}`
|
|
421
|
+
);
|
|
422
|
+
return info;
|
|
423
|
+
}
|
|
424
|
+
async function parseIpa(filePath) {
|
|
425
|
+
if (!await commandExists("plutil")) {
|
|
426
|
+
logger.warn("[PackageParser] plutil \u4E0D\u53EF\u7528\uFF08\u975E macOS\uFF1F\uFF09\uFF0C\u65E0\u6CD5\u89E3\u6790 IPA");
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
const listOutput = await exec("unzip", ["-Z1", filePath]);
|
|
430
|
+
const plistPath = listOutput.split("\n").find((line) => /^Payload\/[^/]+\.app\/Info\.plist$/.test(line.trim()));
|
|
431
|
+
if (!plistPath) {
|
|
432
|
+
logger.warn("[PackageParser] IPA \u4E2D\u672A\u627E\u5230 Info.plist");
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
const plistData = await exec("unzip", ["-p", filePath, plistPath.trim()]);
|
|
436
|
+
const jsonStr = await new Promise((resolve, reject) => {
|
|
437
|
+
const child = __require("child_process").spawn("plutil", ["-convert", "json", "-o", "-", "-"], {
|
|
438
|
+
timeout: 1e4
|
|
439
|
+
});
|
|
440
|
+
let stdout = "";
|
|
441
|
+
let stderr = "";
|
|
442
|
+
child.stdout.on("data", (d) => stdout += d.toString());
|
|
443
|
+
child.stderr.on("data", (d) => stderr += d.toString());
|
|
444
|
+
child.on("close", (code) => {
|
|
445
|
+
if (code === 0) resolve(stdout);
|
|
446
|
+
else reject(new Error(`plutil \u5931\u8D25: ${stderr}`));
|
|
447
|
+
});
|
|
448
|
+
child.stdin.write(plistData);
|
|
449
|
+
child.stdin.end();
|
|
450
|
+
});
|
|
451
|
+
const plist = JSON.parse(jsonStr);
|
|
452
|
+
const info = {
|
|
453
|
+
versionName: plist.CFBundleShortVersionString || void 0,
|
|
454
|
+
versionCode: plist.CFBundleVersion || void 0,
|
|
455
|
+
packageName: plist.CFBundleIdentifier || void 0
|
|
456
|
+
};
|
|
457
|
+
logger.info(`[PackageParser] IPA: ${info.packageName} v${info.versionName}+${info.versionCode}`);
|
|
458
|
+
return info;
|
|
459
|
+
}
|
|
460
|
+
async function parseHap(filePath) {
|
|
461
|
+
let jsonStr = null;
|
|
462
|
+
try {
|
|
463
|
+
jsonStr = await exec("unzip", ["-p", filePath, "module.json"]);
|
|
464
|
+
} catch {
|
|
465
|
+
try {
|
|
466
|
+
jsonStr = await exec("unzip", ["-p", filePath, "config.json"]);
|
|
467
|
+
} catch {
|
|
468
|
+
logger.warn("[PackageParser] HAP \u4E2D\u672A\u627E\u5230 module.json \u6216 config.json");
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const config = JSON.parse(jsonStr);
|
|
473
|
+
const app = config.app || {};
|
|
474
|
+
const info = {
|
|
475
|
+
versionName: app.versionName || app.version?.name || void 0,
|
|
476
|
+
versionCode: String(app.versionCode ?? app.version?.code ?? ""),
|
|
477
|
+
packageName: app.bundleName || void 0
|
|
478
|
+
};
|
|
479
|
+
if (!info.versionCode) info.versionCode = void 0;
|
|
480
|
+
logger.info(`[PackageParser] HAP: ${info.packageName} v${info.versionName}+${info.versionCode}`);
|
|
481
|
+
return info;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// src/adapters/pgyer_uploader.ts
|
|
485
|
+
var PgyerUploader = class _PgyerUploader {
|
|
486
|
+
name = "Pgyer";
|
|
487
|
+
static UPLOAD_API = "https://www.pgyer.com/apiv2/app/upload";
|
|
488
|
+
/**
|
|
489
|
+
* 验证配置
|
|
490
|
+
* @param config 配置对象
|
|
491
|
+
*/
|
|
492
|
+
validateConfig(config) {
|
|
493
|
+
if (!config?.publish?.pgyer?.enable) {
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
if (!config.publish.pgyer.apiKey) {
|
|
497
|
+
logger.error("Pgyer apiKey is missing in config");
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* 判断是否支持该文件
|
|
504
|
+
* @param filePath 文件路径
|
|
505
|
+
*/
|
|
506
|
+
canHandle(filePath) {
|
|
507
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
508
|
+
return ext === "apk" || ext === "ipa" || ext === "aab";
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* 上传文件到蒲公英
|
|
512
|
+
* @param filePath 文件路径
|
|
513
|
+
* @param config 配置
|
|
514
|
+
* @param metadata 元数据
|
|
515
|
+
*/
|
|
516
|
+
async upload(filePath, config, metadata) {
|
|
517
|
+
const pgyerConfig = config.publish?.pgyer;
|
|
518
|
+
const apiKey = pgyerConfig?.apiKey;
|
|
519
|
+
const desc = metadata.changelog || pgyerConfig?.description || "";
|
|
520
|
+
const retryConfig = config.retry;
|
|
521
|
+
if (!fs3.existsSync(filePath)) {
|
|
522
|
+
return {
|
|
523
|
+
platform: "pgyer",
|
|
524
|
+
success: false,
|
|
525
|
+
message: `File not found: ${filePath}`
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
logger.info(`Uploading to Pgyer...`);
|
|
529
|
+
logger.info(`File: ${filePath}`);
|
|
530
|
+
logger.info(`Changelog: ${desc.replace(/\n/g, " ")}`);
|
|
531
|
+
const retryStrategy = createUploadRetryStrategy(retryConfig);
|
|
532
|
+
const result = await retryStrategy.execute(
|
|
533
|
+
() => this.performUpload(filePath, apiKey, desc),
|
|
534
|
+
"Pgyer upload"
|
|
535
|
+
);
|
|
536
|
+
if (result.success) {
|
|
537
|
+
const pgyerData = result.data;
|
|
538
|
+
return {
|
|
539
|
+
platform: "pgyer",
|
|
540
|
+
success: true,
|
|
541
|
+
message: "Upload success",
|
|
542
|
+
url: pgyerData.fullUrl,
|
|
543
|
+
link: pgyerData.fullUrl,
|
|
544
|
+
qrCode: pgyerData.qrCodeUrl,
|
|
545
|
+
data: pgyerData,
|
|
546
|
+
consoleUrl: "https://www.pgyer.com/my"
|
|
547
|
+
};
|
|
548
|
+
} else {
|
|
549
|
+
const lastError = result.lastError;
|
|
550
|
+
const errorMsg = lastError instanceof Error ? lastError.message : String(lastError || "Upload failed");
|
|
551
|
+
return {
|
|
552
|
+
platform: "pgyer",
|
|
553
|
+
success: false,
|
|
554
|
+
message: errorMsg,
|
|
555
|
+
// ✅ 必填且为字符串
|
|
556
|
+
error: result.error
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* 执行实际上传操作
|
|
562
|
+
* @param filePath 文件路径
|
|
563
|
+
* @param apiKey API Key
|
|
564
|
+
* @param desc 描述
|
|
565
|
+
*/
|
|
566
|
+
async performUpload(filePath, apiKey, desc) {
|
|
567
|
+
const form = new FormData();
|
|
568
|
+
form.append("_api_key", apiKey);
|
|
569
|
+
form.append("buildUpdateDescription", desc);
|
|
570
|
+
form.append("file", fs3.createReadStream(filePath));
|
|
571
|
+
const response = await axios.post(_PgyerUploader.UPLOAD_API, form, {
|
|
572
|
+
headers: {
|
|
573
|
+
...form.getHeaders()
|
|
574
|
+
},
|
|
575
|
+
maxContentLength: Infinity,
|
|
576
|
+
maxBodyLength: Infinity
|
|
577
|
+
});
|
|
578
|
+
const data = response.data;
|
|
579
|
+
if (data.code !== 0) {
|
|
580
|
+
logger.error(`Pgyer API Error: ${data.message}`);
|
|
581
|
+
throw new Error(`Pgyer API Error: ${data.message}`);
|
|
582
|
+
}
|
|
583
|
+
const shortcutUrl = data.data.buildShortcutUrl;
|
|
584
|
+
let qrCodeUrl = data.data.buildQRCodeURL;
|
|
585
|
+
const fullUrl = `https://www.pgyer.com/${shortcutUrl}`;
|
|
586
|
+
if (qrCodeUrl && qrCodeUrl.startsWith("//")) {
|
|
587
|
+
qrCodeUrl = `https:${qrCodeUrl}`;
|
|
588
|
+
} else if (qrCodeUrl && !qrCodeUrl.startsWith("http")) {
|
|
589
|
+
}
|
|
590
|
+
logger.success(`Upload to Pgyer success!`);
|
|
591
|
+
logger.info(`Download URL: ${fullUrl}`);
|
|
592
|
+
return {
|
|
593
|
+
fullUrl,
|
|
594
|
+
qrCodeUrl,
|
|
595
|
+
shortcutUrl
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
// src/adapters/huawei_uploader.ts
|
|
601
|
+
import fs4 from "fs-extra";
|
|
602
|
+
import path2 from "path";
|
|
603
|
+
import { promisify } from "util";
|
|
604
|
+
import axios2 from "axios";
|
|
605
|
+
import FormData2 from "form-data";
|
|
606
|
+
var HUAWEI_SUPPORTED_SUFFIXES = {
|
|
607
|
+
image: ["jpg", "jpeg", "png", "bmp"],
|
|
608
|
+
video: ["mp4", "mov"],
|
|
609
|
+
package: ["apk", "aab", "rpk"]
|
|
610
|
+
};
|
|
611
|
+
var HuaweiUploader = class _HuaweiUploader {
|
|
612
|
+
name = "Huawei";
|
|
613
|
+
// 华为上传器默认 API 基础地址
|
|
614
|
+
static DEFAULT_API_BASE = "https://connect-api.cloud.huawei.com";
|
|
615
|
+
// 华为上传器获取 Token 路径
|
|
616
|
+
static TOKEN_PATH = "/api/oauth2/v1/token";
|
|
617
|
+
// 华为上传器发布 API 根路径
|
|
618
|
+
static PUBLISH_API_PATH = "/api/publish/v2";
|
|
619
|
+
/** 获取 API 基础地址 */
|
|
620
|
+
getApiBase(config) {
|
|
621
|
+
return config?.publish?.huawei?.apiBaseUrl || _HuaweiUploader.DEFAULT_API_BASE;
|
|
622
|
+
}
|
|
623
|
+
/** 获取 Token URL */
|
|
624
|
+
getTokenUrl(config) {
|
|
625
|
+
return `${this.getApiBase(config)}${_HuaweiUploader.TOKEN_PATH}`;
|
|
626
|
+
}
|
|
627
|
+
/** 获取 Publish API 根路径 */
|
|
628
|
+
getApiRoot(config) {
|
|
629
|
+
return `${this.getApiBase(config)}${_HuaweiUploader.PUBLISH_API_PATH}`;
|
|
630
|
+
}
|
|
631
|
+
/** 创建 API 请求头(消除 7 处重复) */
|
|
632
|
+
headers(ctx) {
|
|
633
|
+
return {
|
|
634
|
+
Authorization: `Bearer ${ctx.token}`,
|
|
635
|
+
client_id: ctx.clientId
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
/** 检查 API 响应(消除 6 处重复) */
|
|
639
|
+
checkResponse(res, operation) {
|
|
640
|
+
const code = res.data.ret?.code ?? res.data.code;
|
|
641
|
+
if (code !== 0) {
|
|
642
|
+
throw new Error(`${operation} failed: ${res.data.ret?.msg || res.data.msg}`);
|
|
643
|
+
}
|
|
644
|
+
return res.data;
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* 验证配置
|
|
648
|
+
* @param config 配置对象
|
|
649
|
+
* @returns 是否有效
|
|
650
|
+
*/
|
|
651
|
+
validateConfig(config) {
|
|
652
|
+
if (!config?.publish?.huawei?.enable) return false;
|
|
653
|
+
const h = config.publish.huawei;
|
|
654
|
+
if (!h.auth?.clientId || !h.auth?.clientSecret || !h.appId) {
|
|
655
|
+
logger.error("Huawei config missing: clientId, clientSecret, or appId");
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
return true;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* 判断是否支持该文件
|
|
662
|
+
* @param filePath 文件路径
|
|
663
|
+
* @returns 是否支持
|
|
664
|
+
*/
|
|
665
|
+
canHandle(filePath) {
|
|
666
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
667
|
+
return ext === "apk" || ext === "aab";
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* 执行上传
|
|
671
|
+
* @param filePath 文件路径
|
|
672
|
+
* @param config 配置
|
|
673
|
+
* @param metadata 元数据
|
|
674
|
+
*/
|
|
675
|
+
async upload(filePath, config, metadata) {
|
|
676
|
+
const hwConfig = config.publish.huawei;
|
|
677
|
+
const clientId = hwConfig.auth.clientId;
|
|
678
|
+
const clientSecret = hwConfig.auth.clientSecret;
|
|
679
|
+
const appId = hwConfig.appId;
|
|
680
|
+
const releaseType = hwConfig.releaseType || "draft";
|
|
681
|
+
const suffix = path2.extname(filePath).substring(1);
|
|
682
|
+
const retryConfig = config.retry;
|
|
683
|
+
const tokenUrl = this.getTokenUrl(config);
|
|
684
|
+
const publishApiRoot = this.getApiRoot(config);
|
|
685
|
+
logger.info("Starting Huawei AppGallery upload...");
|
|
686
|
+
try {
|
|
687
|
+
const retryStrategy = createUploadRetryStrategy(retryConfig);
|
|
688
|
+
logger.info("Authenticating...");
|
|
689
|
+
const tokenResult = await retryStrategy.execute(
|
|
690
|
+
() => this.getAccessToken(tokenUrl, clientId, clientSecret),
|
|
691
|
+
"Huawei authentication"
|
|
692
|
+
);
|
|
693
|
+
if (!tokenResult.success || !tokenResult.data) {
|
|
694
|
+
throw tokenResult.error || new Error("Failed to get access token");
|
|
695
|
+
}
|
|
696
|
+
const ctx = {
|
|
697
|
+
apiRoot: publishApiRoot,
|
|
698
|
+
token: tokenResult.data,
|
|
699
|
+
clientId,
|
|
700
|
+
appId
|
|
701
|
+
};
|
|
702
|
+
logger.info("Requesting upload URL...");
|
|
703
|
+
const uploadInfoResult = await retryStrategy.execute(
|
|
704
|
+
() => this.getUploadUrl(ctx, suffix),
|
|
705
|
+
"Huawei get upload URL"
|
|
706
|
+
);
|
|
707
|
+
if (!uploadInfoResult.success || !uploadInfoResult.data) {
|
|
708
|
+
throw uploadInfoResult.error || new Error("Failed to get upload URL");
|
|
709
|
+
}
|
|
710
|
+
const uploadInfo = uploadInfoResult.data;
|
|
711
|
+
logger.info("Uploading file (this may take a while)...");
|
|
712
|
+
const uploadFileResult = await retryStrategy.execute(
|
|
713
|
+
() => this.uploadFile(uploadInfo.uploadUrl, uploadInfo.authCode, filePath),
|
|
714
|
+
"Huawei file upload"
|
|
715
|
+
);
|
|
716
|
+
if (!uploadFileResult.success || !uploadFileResult.data) {
|
|
717
|
+
throw uploadFileResult.error || new Error("File upload failed");
|
|
718
|
+
}
|
|
719
|
+
const fileDestUrl = uploadFileResult.data.fileDestUrl;
|
|
720
|
+
logger.info("Linking file to app...");
|
|
721
|
+
const updateInfoResult = await retryStrategy.execute(
|
|
722
|
+
() => this.updateAppFileInfo(ctx, fileDestUrl, filePath),
|
|
723
|
+
"Huawei update file info"
|
|
724
|
+
);
|
|
725
|
+
if (!updateInfoResult.success) {
|
|
726
|
+
throw updateInfoResult.error || new Error("Failed to update file info");
|
|
727
|
+
}
|
|
728
|
+
if (metadata.changelog) {
|
|
729
|
+
try {
|
|
730
|
+
logger.info("Updating release notes...");
|
|
731
|
+
await retryStrategy.execute(
|
|
732
|
+
() => this.updateReleaseNotes(ctx, metadata.changelog, metadata.version),
|
|
733
|
+
"Huawei update release notes"
|
|
734
|
+
);
|
|
735
|
+
} catch (error) {
|
|
736
|
+
logger.warn(`Failed to update release notes: ${error.message}. Continuing...`);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
const assets = hwConfig.assets;
|
|
740
|
+
if (assets) {
|
|
741
|
+
await this.uploadStoreAssets(ctx, assets, retryStrategy);
|
|
742
|
+
}
|
|
743
|
+
if (releaseType === "submit") {
|
|
744
|
+
try {
|
|
745
|
+
logger.info("Submitting app for review...");
|
|
746
|
+
const submitResult = await retryStrategy.execute(
|
|
747
|
+
() => this.submitForReview(ctx, metadata.version),
|
|
748
|
+
"Huawei submit for review"
|
|
749
|
+
);
|
|
750
|
+
if (!submitResult.success) {
|
|
751
|
+
throw submitResult.error || new Error("Failed to submit for review");
|
|
752
|
+
}
|
|
753
|
+
logger.success("Huawei upload successful and submitted for review!");
|
|
754
|
+
return {
|
|
755
|
+
platform: "huawei",
|
|
756
|
+
success: true,
|
|
757
|
+
message: "Upload and review submission successful",
|
|
758
|
+
url: "https://developer.huawei.com/consumer/cn/service/josp/agc/index.html",
|
|
759
|
+
consoleUrl: "https://developer.huawei.com/consumer/cn/service/josp/agc/index.html"
|
|
760
|
+
};
|
|
761
|
+
} catch (error) {
|
|
762
|
+
logger.warn(
|
|
763
|
+
`Failed to submit for review: ${error.message}. App uploaded but not submitted.`
|
|
764
|
+
);
|
|
765
|
+
return {
|
|
766
|
+
platform: "huawei",
|
|
767
|
+
success: true,
|
|
768
|
+
message: "Upload successful but review submission failed. Please submit manually.",
|
|
769
|
+
url: "https://developer.huawei.com/consumer/cn/service/josp/agc/index.html",
|
|
770
|
+
consoleUrl: "https://developer.huawei.com/consumer/cn/service/josp/agc/index.html"
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
logger.success("Huawei upload successful (Draft status).");
|
|
775
|
+
return {
|
|
776
|
+
platform: "huawei",
|
|
777
|
+
success: true,
|
|
778
|
+
message: "Upload success (Draft)",
|
|
779
|
+
url: "https://developer.huawei.com/consumer/cn/service/josp/agc/index.html",
|
|
780
|
+
consoleUrl: "https://developer.huawei.com/consumer/cn/service/josp/agc/index.html"
|
|
781
|
+
};
|
|
782
|
+
} catch (error) {
|
|
783
|
+
logger.error(`Huawei upload failed: ${error.message}`);
|
|
784
|
+
const safeError = {
|
|
785
|
+
message: error.message,
|
|
786
|
+
name: error.name,
|
|
787
|
+
code: error.code,
|
|
788
|
+
status: error.response?.status
|
|
789
|
+
};
|
|
790
|
+
if (error.response?.data) {
|
|
791
|
+
safeError.responseData = error.response.data;
|
|
792
|
+
logger.info(`Error Data: ${JSON.stringify(error.response.data)}`);
|
|
793
|
+
}
|
|
794
|
+
return {
|
|
795
|
+
platform: "huawei",
|
|
796
|
+
success: false,
|
|
797
|
+
message: error.message,
|
|
798
|
+
error: safeError
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* 查询审核状态
|
|
804
|
+
* @param clientId 客户端 ID
|
|
805
|
+
* @param clientSecret 客户端密钥
|
|
806
|
+
* @param appId 应用 ID
|
|
807
|
+
* @param apiBaseUrl API 基础地址(可选)
|
|
808
|
+
*/
|
|
809
|
+
async queryReviewStatus(clientId, clientSecret, appId, apiBaseUrl) {
|
|
810
|
+
const baseUrl = apiBaseUrl || _HuaweiUploader.DEFAULT_API_BASE;
|
|
811
|
+
const tokenUrl = `${baseUrl}${_HuaweiUploader.TOKEN_PATH}`;
|
|
812
|
+
const apiRoot = `${baseUrl}${_HuaweiUploader.PUBLISH_API_PATH}`;
|
|
813
|
+
try {
|
|
814
|
+
logger.info("Querying review status...");
|
|
815
|
+
const token = await this.getAccessToken(tokenUrl, clientId, clientSecret);
|
|
816
|
+
const ctx = { apiRoot, token, clientId, appId };
|
|
817
|
+
const res = await axios2.get(`${apiRoot}/app-information`, {
|
|
818
|
+
params: { appId },
|
|
819
|
+
headers: this.headers(ctx)
|
|
820
|
+
});
|
|
821
|
+
this.checkResponse(res, "Query review status");
|
|
822
|
+
const status = res.data.data?.app?.auditStatus || "unknown";
|
|
823
|
+
logger.info(`Current review status: ${status}`);
|
|
824
|
+
return status;
|
|
825
|
+
} catch (error) {
|
|
826
|
+
logger.error(`Failed to query review status: ${error.message}`);
|
|
827
|
+
throw error;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* 获取 Access Token
|
|
832
|
+
* @param tokenUrl Token URL
|
|
833
|
+
* @param clientId 客户端 ID
|
|
834
|
+
* @param clientSecret 客户端密钥
|
|
835
|
+
*/
|
|
836
|
+
async getAccessToken(tokenUrl, clientId, clientSecret) {
|
|
837
|
+
const data = {
|
|
838
|
+
client_id: clientId,
|
|
839
|
+
client_secret: clientSecret,
|
|
840
|
+
grant_type: "client_credentials"
|
|
841
|
+
};
|
|
842
|
+
logger.info(`[Huawei Auth] Requesting token with clientId: ${clientId}`);
|
|
843
|
+
try {
|
|
844
|
+
const res = await axios2.post(tokenUrl, data, {
|
|
845
|
+
headers: {
|
|
846
|
+
"Content-Type": "application/json"
|
|
847
|
+
}
|
|
848
|
+
});
|
|
849
|
+
logger.info(
|
|
850
|
+
`[Huawei Auth] Token response received, keys: ${Object.keys(res.data).join(", ")}`
|
|
851
|
+
);
|
|
852
|
+
const token = res.data.access_token || res.data.token;
|
|
853
|
+
if (!token) {
|
|
854
|
+
logger.error(
|
|
855
|
+
`[Huawei Auth] No token in response. Full response: ${JSON.stringify(res.data)}`
|
|
856
|
+
);
|
|
857
|
+
throw new Error(`Invalid token response: ${JSON.stringify(res.data)}`);
|
|
858
|
+
}
|
|
859
|
+
logger.info(`[Huawei Auth] Token obtained successfully`);
|
|
860
|
+
return token;
|
|
861
|
+
} catch (error) {
|
|
862
|
+
logger.error(`[Huawei Auth] Token request failed: ${error.message}`);
|
|
863
|
+
if (error.response?.data) {
|
|
864
|
+
logger.error(`[Huawei Auth] Error response: ${JSON.stringify(error.response.data)}`);
|
|
865
|
+
}
|
|
866
|
+
throw error;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
/** 获取上传 URL */
|
|
870
|
+
async getUploadUrl(ctx, suffix) {
|
|
871
|
+
logger.info(`[Huawei Upload] Requesting upload URL for appId: ${ctx.appId}, suffix: ${suffix}`);
|
|
872
|
+
const res = await axios2.get(`${ctx.apiRoot}/upload-url`, {
|
|
873
|
+
params: { appId: ctx.appId, suffix },
|
|
874
|
+
headers: this.headers(ctx)
|
|
875
|
+
});
|
|
876
|
+
this.checkResponse(res, "Get upload URL");
|
|
877
|
+
return {
|
|
878
|
+
uploadUrl: res.data.uploadUrl,
|
|
879
|
+
authCode: res.data.authCode
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
/** 通用文件上传(FormData 方式,无需 API 认证头) */
|
|
883
|
+
async executeUploadRequest(uploadUrl, authCode, filePath) {
|
|
884
|
+
const fileName = path2.basename(filePath);
|
|
885
|
+
const fileSize = fs4.statSync(filePath).size;
|
|
886
|
+
const form = new FormData2();
|
|
887
|
+
form.append("file", fs4.createReadStream(filePath), {
|
|
888
|
+
filename: fileName,
|
|
889
|
+
contentType: "application/octet-stream",
|
|
890
|
+
knownLength: fileSize
|
|
891
|
+
});
|
|
892
|
+
form.append("authCode", authCode);
|
|
893
|
+
form.append("fileCount", "1");
|
|
894
|
+
form.append("parseType", "1");
|
|
895
|
+
const length = await promisify(form.getLength).bind(form)();
|
|
896
|
+
logger.info(
|
|
897
|
+
`[Huawei Upload] Uploading: ${fileName} (${(fileSize / 1024 / 1024).toFixed(2)} MB)`
|
|
898
|
+
);
|
|
899
|
+
const res = await axios2.post(uploadUrl, form, {
|
|
900
|
+
headers: { ...form.getHeaders(), "Content-Length": length },
|
|
901
|
+
maxContentLength: Infinity,
|
|
902
|
+
maxBodyLength: Infinity
|
|
903
|
+
});
|
|
904
|
+
if (res.data.result?.resultCode !== "0") {
|
|
905
|
+
throw new Error(`File upload failed: ${JSON.stringify(res.data.result)}`);
|
|
906
|
+
}
|
|
907
|
+
const fileInfoList = res.data.result?.UploadFileRsp?.fileInfoList || res.data.result?.uploadFileRsp?.fileInfoList || [];
|
|
908
|
+
if (fileInfoList.length === 0) {
|
|
909
|
+
throw new Error("No file info returned from upload");
|
|
910
|
+
}
|
|
911
|
+
return fileInfoList.map((f) => ({
|
|
912
|
+
fileName: f.name || fileName,
|
|
913
|
+
fileDestUrl: f.fileDestUlr || f.fileDestUrl || authCode
|
|
914
|
+
}));
|
|
915
|
+
}
|
|
916
|
+
/** 上传文件 (APK/AAB) */
|
|
917
|
+
async uploadFile(uploadUrl, authCode, filePath) {
|
|
918
|
+
const files = await this.executeUploadRequest(uploadUrl, authCode, filePath);
|
|
919
|
+
if (files.length > 0 && files[0].fileDestUrl) {
|
|
920
|
+
logger.info(`[Huawei Upload] App package uploaded, fileDestUrl: ${files[0].fileDestUrl}`);
|
|
921
|
+
return { fileDestUrl: files[0].fileDestUrl };
|
|
922
|
+
}
|
|
923
|
+
throw new Error("Upload succeeded but fileDestUrl not found in response");
|
|
924
|
+
}
|
|
925
|
+
/** 更新应用文件信息 */
|
|
926
|
+
async updateAppFileInfo(ctx, fileDestUrl, filePath) {
|
|
927
|
+
const fileName = path2.basename(filePath);
|
|
928
|
+
const fileType = path2.extname(filePath).toLowerCase() === ".aab" ? 7 : 5;
|
|
929
|
+
const res = await axios2.put(
|
|
930
|
+
`${ctx.apiRoot}/app-file-info`,
|
|
931
|
+
{
|
|
932
|
+
fileType,
|
|
933
|
+
files: [{ fileName, fileDestUrl }]
|
|
934
|
+
},
|
|
935
|
+
{
|
|
936
|
+
params: { appId: ctx.appId },
|
|
937
|
+
headers: this.headers(ctx)
|
|
938
|
+
}
|
|
939
|
+
);
|
|
940
|
+
this.checkResponse(res, "Update file info");
|
|
941
|
+
}
|
|
942
|
+
/** 更新发布说明(支持多语言) */
|
|
943
|
+
async updateReleaseNotes(ctx, changelog, version) {
|
|
944
|
+
const notes = typeof changelog === "string" ? { zh_CN: changelog, en_US: changelog } : changelog;
|
|
945
|
+
const body = {
|
|
946
|
+
defaultLanguage: "zh_CN",
|
|
947
|
+
languages: [
|
|
948
|
+
{
|
|
949
|
+
lang: "zh_CN",
|
|
950
|
+
appName: "",
|
|
951
|
+
appDesc: "",
|
|
952
|
+
newFeatures: notes["zh_CN"] || ""
|
|
953
|
+
},
|
|
954
|
+
{
|
|
955
|
+
lang: "en_US",
|
|
956
|
+
appName: "",
|
|
957
|
+
appDesc: "",
|
|
958
|
+
newFeatures: notes["en_US"] || ""
|
|
959
|
+
}
|
|
960
|
+
]
|
|
961
|
+
};
|
|
962
|
+
const res = await axios2.put(`${ctx.apiRoot}/app-info`, body, {
|
|
963
|
+
params: { appId: ctx.appId },
|
|
964
|
+
headers: this.headers(ctx)
|
|
965
|
+
});
|
|
966
|
+
this.checkResponse(res, "Update release notes");
|
|
967
|
+
logger.info("Release notes updated successfully");
|
|
968
|
+
}
|
|
969
|
+
/** 提交审核 */
|
|
970
|
+
async submitForReview(ctx, version) {
|
|
971
|
+
const appInfo = await this.getAppInfo(ctx);
|
|
972
|
+
if (!appInfo?.releaseId) {
|
|
973
|
+
throw new Error("Could not get release ID for submission");
|
|
974
|
+
}
|
|
975
|
+
try {
|
|
976
|
+
const res = await axios2.post(
|
|
977
|
+
`${ctx.apiRoot}/app-submit`,
|
|
978
|
+
{
|
|
979
|
+
releaseId: appInfo.releaseId,
|
|
980
|
+
auditDesc: `Version ${version}`
|
|
981
|
+
},
|
|
982
|
+
{
|
|
983
|
+
params: { appId: ctx.appId },
|
|
984
|
+
headers: this.headers(ctx)
|
|
985
|
+
}
|
|
986
|
+
);
|
|
987
|
+
this.checkResponse(res, "Submit for review");
|
|
988
|
+
logger.success("App submitted for review successfully");
|
|
989
|
+
} catch (error) {
|
|
990
|
+
if (error.response?.status === 404) {
|
|
991
|
+
logger.warn("Huawei review submission API not yet available. Please submit manually.");
|
|
992
|
+
} else {
|
|
993
|
+
throw error;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
/** 获取应用信息 */
|
|
998
|
+
async getAppInfo(ctx) {
|
|
999
|
+
const res = await axios2.get(`${ctx.apiRoot}/app-information`, {
|
|
1000
|
+
params: { appId: ctx.appId },
|
|
1001
|
+
headers: this.headers(ctx)
|
|
1002
|
+
});
|
|
1003
|
+
this.checkResponse(res, "Get app info");
|
|
1004
|
+
return res.data.data;
|
|
1005
|
+
}
|
|
1006
|
+
/** 上传商店素材(图标、截图、视频) */
|
|
1007
|
+
async uploadStoreAssets(ctx, assets, retryStrategy) {
|
|
1008
|
+
const tasks = [];
|
|
1009
|
+
if (assets.icon) {
|
|
1010
|
+
tasks.push({
|
|
1011
|
+
label: "icon",
|
|
1012
|
+
files: [assets.icon],
|
|
1013
|
+
type: 0 /* ICON */
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
if (assets.screenshots?.length) {
|
|
1017
|
+
tasks.push({
|
|
1018
|
+
label: `${assets.screenshots.length} screenshot(s)`,
|
|
1019
|
+
files: assets.screenshots,
|
|
1020
|
+
type: 1 /* SCREENSHOT */
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
if (assets.video) {
|
|
1024
|
+
tasks.push({
|
|
1025
|
+
label: "preview video",
|
|
1026
|
+
files: [assets.video],
|
|
1027
|
+
type: 2 /* VIDEO */
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
for (const task of tasks) {
|
|
1031
|
+
try {
|
|
1032
|
+
logger.info(`Uploading ${task.label}...`);
|
|
1033
|
+
await this.uploadAssetFiles(ctx, task.files, task.type, retryStrategy);
|
|
1034
|
+
logger.success(`${task.label} uploaded successfully`);
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
logger.warn(`Failed to upload ${task.label}: ${error.message}`);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
/** 批量上传素材文件 */
|
|
1041
|
+
async uploadAssetFiles(ctx, filePaths, fileType, retryStrategy) {
|
|
1042
|
+
const allFileInfos = [];
|
|
1043
|
+
for (const filePath of filePaths) {
|
|
1044
|
+
if (!await fs4.pathExists(filePath)) {
|
|
1045
|
+
logger.warn(`Asset file not found, skipping: ${filePath}`);
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
const suffix = path2.extname(filePath).substring(1).toLowerCase();
|
|
1049
|
+
if (!HUAWEI_SUPPORTED_SUFFIXES.image.includes(suffix)) {
|
|
1050
|
+
logger.warn(`Unsupported file type ${suffix}, skipping: ${filePath}`);
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
const uploadInfoResult = await retryStrategy.execute(
|
|
1054
|
+
() => this.getUploadUrl(ctx, suffix),
|
|
1055
|
+
"Huawei get upload URL for asset"
|
|
1056
|
+
);
|
|
1057
|
+
if (!uploadInfoResult.success || !uploadInfoResult.data) {
|
|
1058
|
+
logger.warn(`Failed to get upload URL for: ${filePath}`);
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
const uploadInfo = uploadInfoResult.data;
|
|
1062
|
+
const uploadResult = await retryStrategy.execute(
|
|
1063
|
+
() => this.executeUploadRequest(uploadInfo.uploadUrl, uploadInfo.authCode, filePath),
|
|
1064
|
+
"Huawei upload asset"
|
|
1065
|
+
);
|
|
1066
|
+
if (!uploadResult.success || !uploadResult.data) {
|
|
1067
|
+
logger.warn(`Failed to upload: ${filePath}`);
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
allFileInfos.push(...uploadResult.data);
|
|
1071
|
+
}
|
|
1072
|
+
if (allFileInfos.length === 0) {
|
|
1073
|
+
throw new Error("No assets were uploaded successfully");
|
|
1074
|
+
}
|
|
1075
|
+
await retryStrategy.execute(
|
|
1076
|
+
() => this.updateAssetFileInfo(ctx, allFileInfos, fileType),
|
|
1077
|
+
"Huawei update asset file info"
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
/** 更新素材文件信息(关联到应用) */
|
|
1081
|
+
async updateAssetFileInfo(ctx, files, fileType) {
|
|
1082
|
+
const res = await axios2.put(
|
|
1083
|
+
`${ctx.apiRoot}/app-file-info`,
|
|
1084
|
+
{
|
|
1085
|
+
fileType,
|
|
1086
|
+
lang: "zh-CN",
|
|
1087
|
+
files: files.map((f) => ({
|
|
1088
|
+
fileName: f.fileName,
|
|
1089
|
+
fileDestUrl: f.fileDestUrl
|
|
1090
|
+
}))
|
|
1091
|
+
},
|
|
1092
|
+
{
|
|
1093
|
+
params: { appId: ctx.appId },
|
|
1094
|
+
headers: this.headers(ctx)
|
|
1095
|
+
}
|
|
1096
|
+
);
|
|
1097
|
+
this.checkResponse(res, "Update asset file info");
|
|
1098
|
+
}
|
|
1099
|
+
};
|
|
1100
|
+
|
|
1101
|
+
// src/adapters/xiaomi_uploader.ts
|
|
1102
|
+
import fs5 from "fs-extra";
|
|
1103
|
+
import axios3 from "axios";
|
|
1104
|
+
import FormData3 from "form-data";
|
|
1105
|
+
import crypto3 from "crypto";
|
|
1106
|
+
var XiaomiUploader = class _XiaomiUploader {
|
|
1107
|
+
name = "Xiaomi";
|
|
1108
|
+
static API_BASE = "https://api.developer.xiaomi.com/devupload";
|
|
1109
|
+
static PUSH_API = `${_XiaomiUploader.API_BASE}/dev/push`;
|
|
1110
|
+
static QUERY_API = `${_XiaomiUploader.API_BASE}/dev/query`;
|
|
1111
|
+
static CATEGORY_API = `${_XiaomiUploader.API_BASE}/dev/category`;
|
|
1112
|
+
/**
|
|
1113
|
+
* RSA 公钥分段加密 (PKCS1Padding)
|
|
1114
|
+
*
|
|
1115
|
+
* 小米要求: 使用平台分配的 RSA 公钥加密 SIG JSON,
|
|
1116
|
+
* 输出小写十六进制字符串。RSA 1024-bit 密钥单次最多加密 117 字节,
|
|
1117
|
+
* 超出需分段加密后拼接。
|
|
1118
|
+
*/
|
|
1119
|
+
rsaEncryptWithPublicKey(data, publicKey) {
|
|
1120
|
+
const buffer = Buffer.from(data, "utf-8");
|
|
1121
|
+
const maxBlock = 117;
|
|
1122
|
+
const chunks = [];
|
|
1123
|
+
for (let offset = 0; offset < buffer.length; offset += maxBlock) {
|
|
1124
|
+
const chunk = buffer.subarray(offset, offset + maxBlock);
|
|
1125
|
+
const encrypted = crypto3.publicEncrypt(
|
|
1126
|
+
{
|
|
1127
|
+
key: publicKey,
|
|
1128
|
+
padding: crypto3.constants.RSA_PKCS1_PADDING
|
|
1129
|
+
},
|
|
1130
|
+
chunk
|
|
1131
|
+
);
|
|
1132
|
+
chunks.push(encrypted);
|
|
1133
|
+
}
|
|
1134
|
+
return Buffer.concat(chunks).toString("hex");
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* 从 X.509 证书或 PEM 公钥中提取可用于加密的公钥
|
|
1138
|
+
* 优先使用 crypto.X509Certificate,因为它能同时处理 DER 和 PEM 格式的证书
|
|
1139
|
+
*/
|
|
1140
|
+
extractPublicKey(buffer) {
|
|
1141
|
+
try {
|
|
1142
|
+
const x509 = new crypto3.X509Certificate(buffer);
|
|
1143
|
+
return x509.publicKey;
|
|
1144
|
+
} catch (e) {
|
|
1145
|
+
try {
|
|
1146
|
+
const content = buffer.toString();
|
|
1147
|
+
if (content.includes("-----BEGIN")) {
|
|
1148
|
+
return crypto3.createPublicKey(content);
|
|
1149
|
+
}
|
|
1150
|
+
return crypto3.createPublicKey(this.normalizePEM(content));
|
|
1151
|
+
} catch (e2) {
|
|
1152
|
+
throw new Error(`\u65E0\u6CD5\u63D0\u53D6\u516C\u94A5: ${e instanceof Error ? e.message : "\u672A\u77E5\u9519\u8BEF"}`);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* 计算文件 MD5 (小写十六进制)
|
|
1158
|
+
*/
|
|
1159
|
+
async calculateFileMD5(filePath) {
|
|
1160
|
+
return new Promise((resolve, reject) => {
|
|
1161
|
+
const hash = crypto3.createHash("md5");
|
|
1162
|
+
const stream = fs5.createReadStream(filePath);
|
|
1163
|
+
stream.on("data", (data) => hash.update(data));
|
|
1164
|
+
stream.on("end", () => resolve(hash.digest("hex")));
|
|
1165
|
+
stream.on("error", reject);
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* 计算字符串 MD5 (小写十六进制)
|
|
1170
|
+
*/
|
|
1171
|
+
calculateStringMD5(str) {
|
|
1172
|
+
return crypto3.createHash("md5").update(str, "utf-8").digest("hex");
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* 构建 RequestData JSON
|
|
1176
|
+
*/
|
|
1177
|
+
buildRequestData(params) {
|
|
1178
|
+
const { userName, packageName, appName, updateDesc, uploadType } = params;
|
|
1179
|
+
const synchroType = uploadType === "new" ? 0 : 1;
|
|
1180
|
+
const appInfo = {
|
|
1181
|
+
appName,
|
|
1182
|
+
packageName
|
|
1183
|
+
};
|
|
1184
|
+
if (uploadType === "new") {
|
|
1185
|
+
if (params.category != null) appInfo.category = params.category;
|
|
1186
|
+
if (params.keyWords) appInfo.keyWords = params.keyWords;
|
|
1187
|
+
if (params.desc) appInfo.desc = params.desc;
|
|
1188
|
+
if (params.brief) appInfo.brief = params.brief;
|
|
1189
|
+
if (params.privacyUrl) appInfo.privacyUrl = params.privacyUrl;
|
|
1190
|
+
}
|
|
1191
|
+
if (updateDesc) {
|
|
1192
|
+
appInfo.updateDesc = updateDesc;
|
|
1193
|
+
}
|
|
1194
|
+
const requestDataObj = {
|
|
1195
|
+
userName,
|
|
1196
|
+
appInfo,
|
|
1197
|
+
synchroType
|
|
1198
|
+
};
|
|
1199
|
+
const result = JSON.stringify(requestDataObj);
|
|
1200
|
+
logger.info(`[XIAOMI] \u8C03\u8BD5 - RequestData \u5B57\u7B26\u4E32: ${result}`);
|
|
1201
|
+
return result;
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* 生成只包含 RequestData 的 SIG(用于查询接口)
|
|
1205
|
+
*/
|
|
1206
|
+
generateQuerySIG(requestDataMD5, privateKey, publicKey) {
|
|
1207
|
+
const sigObj = {
|
|
1208
|
+
sig: [{ name: "RequestData", hash: requestDataMD5 }],
|
|
1209
|
+
password: privateKey.trim()
|
|
1210
|
+
};
|
|
1211
|
+
const sigJson = JSON.stringify(sigObj);
|
|
1212
|
+
logger.info(`[XIAOMI] \u8C03\u8BD5 - Query SIG \u5F85\u52A0\u5BC6\u5B57\u7B26\u4E32: ${sigJson}`);
|
|
1213
|
+
const encrypted = this.rsaEncryptWithPublicKey(sigJson, publicKey);
|
|
1214
|
+
logger.info(
|
|
1215
|
+
`[XIAOMI] \u8C03\u8BD5 - Query SIG \u52A0\u5BC6\u7ED3\u679C (Hex, \u524D 20\u4F4D): ${encrypted.substring(0, 20)}...`
|
|
1216
|
+
);
|
|
1217
|
+
return encrypted;
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* 生成包含 RequestData + apk 的加密 SIG 字符串(用于上传接口)
|
|
1221
|
+
*/
|
|
1222
|
+
generateUploadSIG(requestDataMD5, apkFileMD5, privateKey, publicKey, extraFiles) {
|
|
1223
|
+
const sigItems = [
|
|
1224
|
+
{ name: "RequestData", hash: requestDataMD5 },
|
|
1225
|
+
{ name: "apk", hash: apkFileMD5 }
|
|
1226
|
+
];
|
|
1227
|
+
if (extraFiles) {
|
|
1228
|
+
sigItems.push(...extraFiles);
|
|
1229
|
+
}
|
|
1230
|
+
const sigObj = {
|
|
1231
|
+
sig: sigItems,
|
|
1232
|
+
password: privateKey.trim()
|
|
1233
|
+
};
|
|
1234
|
+
const sigJson = JSON.stringify(sigObj);
|
|
1235
|
+
logger.info(`[XIAOMI] \u8C03\u8BD5 - Upload SIG \u5F85\u52A0\u5BC6\u5B57\u7B26\u4E32: ${sigJson}`);
|
|
1236
|
+
const encrypted = this.rsaEncryptWithPublicKey(sigJson, publicKey);
|
|
1237
|
+
logger.info(
|
|
1238
|
+
`[XIAOMI] \u8C03\u8BD5 - Upload SIG \u52A0\u5BC6\u7ED3\u679C (Hex, \u524D 20\u4F4D): ${encrypted.substring(0, 20)}...`
|
|
1239
|
+
);
|
|
1240
|
+
return encrypted;
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* 将用户输入的公钥字符串规范化为 PEM 格式
|
|
1244
|
+
*/
|
|
1245
|
+
normalizePEM(publicKey) {
|
|
1246
|
+
if (publicKey.includes("-----BEGIN")) {
|
|
1247
|
+
return publicKey.trim();
|
|
1248
|
+
}
|
|
1249
|
+
const clean = publicKey.replace(/[\s\n\r]+/g, "");
|
|
1250
|
+
const lines = clean.match(/.{1,64}/g) || [];
|
|
1251
|
+
return `-----BEGIN PUBLIC KEY-----
|
|
1252
|
+
${lines.join("\n")}
|
|
1253
|
+
-----END PUBLIC KEY-----`;
|
|
1254
|
+
}
|
|
1255
|
+
validateConfig(config) {
|
|
1256
|
+
if (!config?.publish?.xiaomi?.enable) return false;
|
|
1257
|
+
const x = config.publish.xiaomi;
|
|
1258
|
+
if (!x.auth?.userName || !x.auth?.privateKey) {
|
|
1259
|
+
logger.error("Xiaomi config missing: userName or privateKey");
|
|
1260
|
+
return false;
|
|
1261
|
+
}
|
|
1262
|
+
if (!x.auth?.publicKeyPath) {
|
|
1263
|
+
logger.error("Xiaomi config missing: publicKeyPath");
|
|
1264
|
+
return false;
|
|
1265
|
+
}
|
|
1266
|
+
return true;
|
|
1267
|
+
}
|
|
1268
|
+
canHandle(filePath) {
|
|
1269
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
1270
|
+
return ext === "apk";
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* 查询应用状态
|
|
1274
|
+
* 使用 POST /dev/query 接口,需要 SIG 签名认证
|
|
1275
|
+
*
|
|
1276
|
+
* 返回值说明:
|
|
1277
|
+
* - create: true → 需要通过新增方式上传 (synchroType=0)
|
|
1278
|
+
* - updateVersion: true → 可以更新包 (synchroType=1)
|
|
1279
|
+
* - updateInfo: true → 可以仅更新信息 (synchroType=2)
|
|
1280
|
+
*/
|
|
1281
|
+
async queryAppStatus(userName, packageName, privateKey, publicKeyObj) {
|
|
1282
|
+
try {
|
|
1283
|
+
const queryData = {
|
|
1284
|
+
packageName,
|
|
1285
|
+
userName
|
|
1286
|
+
};
|
|
1287
|
+
const requestData = JSON.stringify(queryData);
|
|
1288
|
+
logger.info(`Query RequestData: ${requestData}`);
|
|
1289
|
+
const requestDataMD5 = this.calculateStringMD5(requestData);
|
|
1290
|
+
logger.info(`Query RequestData MD5: ${requestDataMD5}`);
|
|
1291
|
+
const sig = this.generateQuerySIG(requestDataMD5, privateKey, publicKeyObj);
|
|
1292
|
+
const params = new URLSearchParams();
|
|
1293
|
+
params.append("RequestData", requestData);
|
|
1294
|
+
params.append("SIG", sig);
|
|
1295
|
+
const response = await axios3.post(_XiaomiUploader.QUERY_API, params.toString(), {
|
|
1296
|
+
headers: {
|
|
1297
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
1298
|
+
},
|
|
1299
|
+
timeout: 15e3
|
|
1300
|
+
});
|
|
1301
|
+
const data = response.data;
|
|
1302
|
+
logger.info(`\u67E5\u8BE2\u5E94\u7528\u54CD\u5E94: ${JSON.stringify(data)}`);
|
|
1303
|
+
if (data?.result === -20014) {
|
|
1304
|
+
throw new Error(
|
|
1305
|
+
`\u5C0F\u7C73\u8BA4\u8BC1\u5931\u8D25: \u5BC6\u7801\u9519\u8BEF (result=-20014)\u3002\u8BF7\u68C0\u67E5 publish.xiaomi.auth.privateKey \u662F\u5426\u4E3A\u6700\u65B0\u7684 API \u79C1\u94A5\u3002`
|
|
1306
|
+
);
|
|
1307
|
+
}
|
|
1308
|
+
if (data?.result !== 0) {
|
|
1309
|
+
logger.warn(`\u67E5\u8BE2\u8FD4\u56DE\u975E\u96F6: result=${data?.result}, ${data?.message}`);
|
|
1310
|
+
return { type: "new", queried: false };
|
|
1311
|
+
}
|
|
1312
|
+
const appInfo = data.appInfo || null;
|
|
1313
|
+
if (data.updateVersion === true) {
|
|
1314
|
+
return { type: "update", queried: true, appInfo };
|
|
1315
|
+
}
|
|
1316
|
+
if (data.create === true) {
|
|
1317
|
+
logger.info("\u5E94\u7528\u5DF2\u5728\u540E\u53F0\u521B\u5EFA\u4F46\u5C1A\u672A\u4E0A\u4F20\u8FC7\u5305\uFF0C\u5C06\u8FDB\u5165\u9996\u6B21\u63D0\u5BA1\u6A21\u5F0F");
|
|
1318
|
+
return { type: "new", queried: true, appInfo };
|
|
1319
|
+
}
|
|
1320
|
+
return { type: "update", queried: true, appInfo };
|
|
1321
|
+
} catch (error) {
|
|
1322
|
+
if (error.message.includes("\u5C0F\u7C73\u8BA4\u8BC1\u5931\u8D25")) {
|
|
1323
|
+
throw error;
|
|
1324
|
+
}
|
|
1325
|
+
logger.warn(`\u67E5\u8BE2\u5E94\u7528\u8FDE\u63A5\u5F02\u5E38: ${error.message}\uFF0C\u5C06\u63A8\u6D4B\u4E3A\u65B0\u589E\u5E94\u7528\u6A21\u5F0F`);
|
|
1326
|
+
return { type: "new", queried: false };
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* 查询小米应用商店分类列表
|
|
1331
|
+
* POST /dev/category, 无需参数
|
|
1332
|
+
*/
|
|
1333
|
+
async queryCategories() {
|
|
1334
|
+
try {
|
|
1335
|
+
const response = await axios3.post(_XiaomiUploader.CATEGORY_API, null, {
|
|
1336
|
+
timeout: 1e4
|
|
1337
|
+
});
|
|
1338
|
+
const data = response.data;
|
|
1339
|
+
if (data?.result === 0 && Array.isArray(data.categories)) {
|
|
1340
|
+
return data.categories;
|
|
1341
|
+
}
|
|
1342
|
+
logger.warn(`\u83B7\u53D6\u5206\u7C7B\u5217\u8868\u5931\u8D25: ${JSON.stringify(data)}`);
|
|
1343
|
+
return null;
|
|
1344
|
+
} catch (error) {
|
|
1345
|
+
logger.warn(`\u83B7\u53D6\u5206\u7C7B\u5217\u8868\u5F02\u5E38: ${error.message}`);
|
|
1346
|
+
return null;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
async upload(filePath, config, metadata) {
|
|
1350
|
+
const xiaomiConfig = config.publish?.xiaomi;
|
|
1351
|
+
const { userName, privateKey, publicKeyPath } = xiaomiConfig.auth;
|
|
1352
|
+
const updateDesc = metadata.changelog || xiaomiConfig?.description || "App Update";
|
|
1353
|
+
const retryConfig = config.retry;
|
|
1354
|
+
if (!fs5.existsSync(filePath)) {
|
|
1355
|
+
return {
|
|
1356
|
+
platform: "xiaomi",
|
|
1357
|
+
success: false,
|
|
1358
|
+
message: `\u6587\u4EF6\u4E0D\u5B58\u5728: ${filePath}`
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
const resolvedPublicKey = await this.resolvePublicKey(publicKeyPath);
|
|
1362
|
+
if (!resolvedPublicKey) {
|
|
1363
|
+
return {
|
|
1364
|
+
platform: "xiaomi",
|
|
1365
|
+
success: false,
|
|
1366
|
+
message: "\u65E0\u6CD5\u83B7\u53D6 RSA \u516C\u94A5: publicKeyPath \u672A\u63D0\u4F9B\u6216\u6587\u4EF6\u4E0D\u5B58\u5728"
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
const packageName = xiaomiConfig.packageName || this.inferPackageName(filePath);
|
|
1370
|
+
if (!packageName) {
|
|
1371
|
+
return {
|
|
1372
|
+
platform: "xiaomi",
|
|
1373
|
+
success: false,
|
|
1374
|
+
message: "\u7F3A\u5C11 packageName \u914D\u7F6E, \u4E14\u65E0\u6CD5\u4ECE\u6587\u4EF6\u540D\u63A8\u65AD\u5E94\u7528\u5305\u540D"
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
const appName = xiaomiConfig.appName || this.inferAppName(packageName);
|
|
1378
|
+
if (!appName) {
|
|
1379
|
+
return {
|
|
1380
|
+
platform: "xiaomi",
|
|
1381
|
+
success: false,
|
|
1382
|
+
message: "\u7F3A\u5C11 appName \u914D\u7F6E, \u4E14\u65E0\u6CD5\u4ECE\u5305\u540D\u63A8\u65AD\u5E94\u7528\u540D\u79F0"
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
logger.info("\u5F00\u59CB\u4E0A\u4F20\u5230\u5C0F\u7C73\u5E94\u7528\u5546\u5E97...");
|
|
1386
|
+
logger.info(`\u6587\u4EF6: ${filePath}`);
|
|
1387
|
+
logger.info(`\u5305\u540D: ${packageName}`);
|
|
1388
|
+
logger.info(`\u5E94\u7528\u540D: ${appName}`);
|
|
1389
|
+
const publicKeyObj = this.extractPublicKey(resolvedPublicKey);
|
|
1390
|
+
const configUploadType = xiaomiConfig.uploadType || "auto";
|
|
1391
|
+
let uploadType;
|
|
1392
|
+
let existingAppInfo = null;
|
|
1393
|
+
if (configUploadType === "auto") {
|
|
1394
|
+
logger.info("\u6B63\u5728\u67E5\u8BE2\u5E94\u7528\u4FE1\u606F...");
|
|
1395
|
+
const status = await this.queryAppStatus(userName, packageName, privateKey, publicKeyObj);
|
|
1396
|
+
uploadType = status.type;
|
|
1397
|
+
existingAppInfo = status.appInfo;
|
|
1398
|
+
} else {
|
|
1399
|
+
uploadType = configUploadType;
|
|
1400
|
+
}
|
|
1401
|
+
const uploadTypeDesc = uploadType === "update" ? "\u5E94\u7528\u66F4\u65B0 (synchroType=1)" : "\u65B0\u589E\u5E94\u7528 (synchroType=0)";
|
|
1402
|
+
logger.info(`\u4E0A\u4F20\u7C7B\u578B: ${uploadTypeDesc}`);
|
|
1403
|
+
if (uploadType === "new" && xiaomiConfig.uploadType === "new") {
|
|
1404
|
+
const missing = [];
|
|
1405
|
+
if (xiaomiConfig.category == null) missing.push("category (\u5206\u7C7BID)");
|
|
1406
|
+
if (!xiaomiConfig.desc) missing.push("desc (\u5E94\u7528\u4ECB\u7ECD)");
|
|
1407
|
+
if (missing.length > 0) {
|
|
1408
|
+
return {
|
|
1409
|
+
platform: "xiaomi",
|
|
1410
|
+
success: false,
|
|
1411
|
+
message: `\u9996\u6B21\u63D0\u5BA1\u6A21\u5F0F\u7F3A\u5C11\u5FC5\u586B\u5B57\u6BB5: ${missing.join(", ")}\u3002\u8BF7\u5728\u540E\u53F0\u8865\u5168\u6216\u5728\u914D\u7F6E\u4E2D\u6DFB\u52A0\u3002`
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
try {
|
|
1416
|
+
const retryStrategy = createUploadRetryStrategy(retryConfig);
|
|
1417
|
+
const result = await retryStrategy.execute(
|
|
1418
|
+
() => this.performUpload({
|
|
1419
|
+
filePath,
|
|
1420
|
+
userName,
|
|
1421
|
+
privateKey,
|
|
1422
|
+
publicKey: resolvedPublicKey,
|
|
1423
|
+
packageName,
|
|
1424
|
+
appName,
|
|
1425
|
+
updateDesc,
|
|
1426
|
+
uploadType,
|
|
1427
|
+
category: xiaomiConfig.category || existingAppInfo?.category,
|
|
1428
|
+
keyWords: xiaomiConfig.keyWords || existingAppInfo?.keyWords,
|
|
1429
|
+
desc: xiaomiConfig.desc || existingAppInfo?.desc,
|
|
1430
|
+
brief: xiaomiConfig.brief || existingAppInfo?.brief,
|
|
1431
|
+
privacyUrl: xiaomiConfig.privacyUrl || existingAppInfo?.privacyUrl,
|
|
1432
|
+
assets: xiaomiConfig.assets
|
|
1433
|
+
}),
|
|
1434
|
+
"Xiaomi push upload"
|
|
1435
|
+
);
|
|
1436
|
+
if (!result.success) {
|
|
1437
|
+
throw result.error || new Error("Upload failed");
|
|
1438
|
+
}
|
|
1439
|
+
logger.success("\u4E0A\u4F20\u5230\u5C0F\u7C73\u5E94\u7528\u5546\u5E97\u6210\u529F!");
|
|
1440
|
+
return {
|
|
1441
|
+
platform: "xiaomi",
|
|
1442
|
+
success: true,
|
|
1443
|
+
message: "\u4E0A\u4F20\u6210\u529F, \u7B49\u5F85\u5BA1\u6838",
|
|
1444
|
+
data: result.data,
|
|
1445
|
+
consoleUrl: "https://dev.mi.com/distribute/app/details"
|
|
1446
|
+
};
|
|
1447
|
+
} catch (error) {
|
|
1448
|
+
logger.error(`\u4E0A\u4F20\u5230\u5C0F\u7C73\u5931\u8D25: ${error.message}`);
|
|
1449
|
+
if (error.response?.data) {
|
|
1450
|
+
logger.info(`\u9519\u8BEF\u8BE6\u60C5: ${JSON.stringify(error.response.data)}`);
|
|
1451
|
+
}
|
|
1452
|
+
return {
|
|
1453
|
+
platform: "xiaomi",
|
|
1454
|
+
success: false,
|
|
1455
|
+
message: error.message,
|
|
1456
|
+
error
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* 执行实际的上传操作
|
|
1462
|
+
*/
|
|
1463
|
+
async performUpload(params) {
|
|
1464
|
+
const {
|
|
1465
|
+
filePath,
|
|
1466
|
+
userName,
|
|
1467
|
+
privateKey,
|
|
1468
|
+
publicKey,
|
|
1469
|
+
packageName,
|
|
1470
|
+
appName,
|
|
1471
|
+
updateDesc,
|
|
1472
|
+
uploadType,
|
|
1473
|
+
category,
|
|
1474
|
+
keyWords,
|
|
1475
|
+
desc,
|
|
1476
|
+
brief,
|
|
1477
|
+
privacyUrl,
|
|
1478
|
+
assets
|
|
1479
|
+
} = params;
|
|
1480
|
+
const publicKeyObj = this.extractPublicKey(publicKey);
|
|
1481
|
+
const requestData = this.buildRequestData({
|
|
1482
|
+
userName,
|
|
1483
|
+
packageName,
|
|
1484
|
+
appName,
|
|
1485
|
+
updateDesc,
|
|
1486
|
+
uploadType,
|
|
1487
|
+
category,
|
|
1488
|
+
keyWords,
|
|
1489
|
+
desc,
|
|
1490
|
+
brief,
|
|
1491
|
+
privacyUrl
|
|
1492
|
+
});
|
|
1493
|
+
logger.info("RequestData \u5DF2\u6784\u5EFA");
|
|
1494
|
+
logger.info(`RequestData: ${requestData}`);
|
|
1495
|
+
const requestDataMD5 = this.calculateStringMD5(requestData);
|
|
1496
|
+
const apkFileMD5 = await this.calculateFileMD5(filePath);
|
|
1497
|
+
logger.info(`APK MD5: ${apkFileMD5}`);
|
|
1498
|
+
const extraSigItems = [];
|
|
1499
|
+
let iconPath;
|
|
1500
|
+
if (assets?.icon && fs5.existsSync(assets.icon)) {
|
|
1501
|
+
iconPath = assets.icon;
|
|
1502
|
+
const iconMD5 = await this.calculateFileMD5(iconPath);
|
|
1503
|
+
extraSigItems.push({ name: "icon", hash: iconMD5 });
|
|
1504
|
+
}
|
|
1505
|
+
const screenshotPaths = [];
|
|
1506
|
+
if (assets?.screenshots) {
|
|
1507
|
+
for (let i = 0; i < assets.screenshots.length && i < 4; i++) {
|
|
1508
|
+
const ssPath = assets.screenshots[i];
|
|
1509
|
+
if (fs5.existsSync(ssPath)) {
|
|
1510
|
+
screenshotPaths.push(ssPath);
|
|
1511
|
+
const ssMD5 = await this.calculateFileMD5(ssPath);
|
|
1512
|
+
extraSigItems.push({ name: `screenshot_${i + 1}`, hash: ssMD5 });
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
const sig = this.generateUploadSIG(
|
|
1517
|
+
requestDataMD5,
|
|
1518
|
+
apkFileMD5,
|
|
1519
|
+
privateKey,
|
|
1520
|
+
publicKeyObj,
|
|
1521
|
+
extraSigItems.length > 0 ? extraSigItems : void 0
|
|
1522
|
+
);
|
|
1523
|
+
logger.info("SIG \u7B7E\u540D\u5DF2\u751F\u6210");
|
|
1524
|
+
const form = new FormData3();
|
|
1525
|
+
form.append("RequestData", requestData);
|
|
1526
|
+
form.append("SIG", sig);
|
|
1527
|
+
form.append("apk", fs5.createReadStream(filePath));
|
|
1528
|
+
if (iconPath) {
|
|
1529
|
+
form.append("icon", fs5.createReadStream(iconPath));
|
|
1530
|
+
logger.info(`\u9644\u52A0 icon: ${iconPath}`);
|
|
1531
|
+
}
|
|
1532
|
+
for (let i = 0; i < screenshotPaths.length; i++) {
|
|
1533
|
+
form.append(`screenshot_${i + 1}`, fs5.createReadStream(screenshotPaths[i]));
|
|
1534
|
+
logger.info(`\u9644\u52A0 screenshot_${i + 1}: ${screenshotPaths[i]}`);
|
|
1535
|
+
}
|
|
1536
|
+
logger.info(`POST ${_XiaomiUploader.PUSH_API}`);
|
|
1537
|
+
const response = await axios3.post(_XiaomiUploader.PUSH_API, form, {
|
|
1538
|
+
headers: {
|
|
1539
|
+
...form.getHeaders()
|
|
1540
|
+
},
|
|
1541
|
+
maxContentLength: Infinity,
|
|
1542
|
+
maxBodyLength: Infinity,
|
|
1543
|
+
timeout: 6e4
|
|
1544
|
+
// 60 秒请求超时
|
|
1545
|
+
});
|
|
1546
|
+
const data = response.data;
|
|
1547
|
+
logger.info(`\u5C0F\u7C73 API \u539F\u59CB\u54CD\u5E94: ${JSON.stringify(data)}`);
|
|
1548
|
+
if (data?.result !== 0) {
|
|
1549
|
+
const errMsg = data?.message || data?.msg || JSON.stringify(data);
|
|
1550
|
+
throw new Error(`\u5C0F\u7C73 API \u8FD4\u56DE\u9519\u8BEF (result=${data?.result}): ${errMsg}`);
|
|
1551
|
+
}
|
|
1552
|
+
logger.info(`\u5C0F\u7C73 API \u54CD\u5E94: ${JSON.stringify(data)}`);
|
|
1553
|
+
return data;
|
|
1554
|
+
}
|
|
1555
|
+
/**
|
|
1556
|
+
* 解析公钥: 优先使用内容字段, 其次从文件路径读取
|
|
1557
|
+
*/
|
|
1558
|
+
async resolvePublicKey(publicKeyPath) {
|
|
1559
|
+
if (publicKeyPath) {
|
|
1560
|
+
if (!fs5.existsSync(publicKeyPath)) {
|
|
1561
|
+
logger.error(`\u516C\u94A5\u6587\u4EF6\u4E0D\u5B58\u5728: ${publicKeyPath}`);
|
|
1562
|
+
return null;
|
|
1563
|
+
}
|
|
1564
|
+
return await fs5.readFile(publicKeyPath);
|
|
1565
|
+
}
|
|
1566
|
+
return null;
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* 尝试从文件名推断包名
|
|
1570
|
+
* 例如: com.example.app-release.apk → com.example.app
|
|
1571
|
+
*/
|
|
1572
|
+
inferPackageName(filePath) {
|
|
1573
|
+
const fileName = filePath.split("/").pop()?.split("\\").pop() || "";
|
|
1574
|
+
const match = fileName.match(/^([\w.]+?)(?:-(?:release|debug|unsigned))?\.apk$/i);
|
|
1575
|
+
if (match && match[1].includes(".")) {
|
|
1576
|
+
return match[1];
|
|
1577
|
+
}
|
|
1578
|
+
return null;
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* 从包名推断应用名称
|
|
1582
|
+
* 例如: com.example.app → app 或 com.app → app
|
|
1583
|
+
*/
|
|
1584
|
+
inferAppName(packageName) {
|
|
1585
|
+
const parts = packageName.split(".");
|
|
1586
|
+
if (parts.length === 0) return null;
|
|
1587
|
+
const lastPart = parts[parts.length - 1];
|
|
1588
|
+
const humanized = lastPart.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (c) => c.toUpperCase());
|
|
1589
|
+
return humanized || null;
|
|
1590
|
+
}
|
|
1591
|
+
};
|
|
1592
|
+
|
|
1593
|
+
// src/adapters/apple_store_uploader.ts
|
|
1594
|
+
import fs6 from "fs-extra";
|
|
1595
|
+
import path3 from "path";
|
|
1596
|
+
import { exec as exec2 } from "child_process";
|
|
1597
|
+
import { promisify as promisify2 } from "util";
|
|
1598
|
+
import axios4 from "axios";
|
|
1599
|
+
var execAsync = promisify2(exec2);
|
|
1600
|
+
var APP_STORE_CONNECT_API = "https://api.appstoreconnect.apple.com/v1";
|
|
1601
|
+
var TRANSPORTER_APP_PATH = "/Applications/Transporter.app/Contents/itms/bin/iTMSTransporter";
|
|
1602
|
+
var AppleStoreUploader = class {
|
|
1603
|
+
name = "AppStore";
|
|
1604
|
+
/**
|
|
1605
|
+
* 验证配置是否有效
|
|
1606
|
+
* 至少需要一种认证方式:
|
|
1607
|
+
* - appleId + appPassword(CLI 上传)
|
|
1608
|
+
* - apiKeyId + apiIssuerId + privateKey(API 调用,也可用于 CLI 上传)
|
|
1609
|
+
*/
|
|
1610
|
+
validateConfig(config) {
|
|
1611
|
+
if (!config?.publish?.app_store?.enable) return false;
|
|
1612
|
+
const auth = config.publish.app_store?.auth;
|
|
1613
|
+
const hasCliAuth = auth?.appleId && auth?.appPassword;
|
|
1614
|
+
const hasApiAuth = this.hasApiCredentials(auth);
|
|
1615
|
+
if (!hasCliAuth && !hasApiAuth) {
|
|
1616
|
+
logger.error(
|
|
1617
|
+
"Apple \u914D\u7F6E\u7F3A\u5931\uFF1A\u9700\u8981 (appleId + appPassword) \u6216 (apiKeyId + apiIssuerId + privateKey) \u4E2D\u7684\u4E00\u79CD\u3002"
|
|
1618
|
+
);
|
|
1619
|
+
return false;
|
|
1620
|
+
}
|
|
1621
|
+
return true;
|
|
1622
|
+
}
|
|
1623
|
+
/**
|
|
1624
|
+
* 判断是否支持处理该文件
|
|
1625
|
+
*/
|
|
1626
|
+
canHandle(filePath) {
|
|
1627
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
1628
|
+
return ext === "ipa";
|
|
1629
|
+
}
|
|
1630
|
+
/**
|
|
1631
|
+
* 执行上传流程
|
|
1632
|
+
*/
|
|
1633
|
+
async upload(filePath, config, metadata) {
|
|
1634
|
+
const appConfig = config.publish.app_store;
|
|
1635
|
+
const auth = appConfig.auth;
|
|
1636
|
+
const retryConfig = config.retry;
|
|
1637
|
+
logger.info("\u5F00\u59CB\u4E0A\u4F20\u5230 App Store...");
|
|
1638
|
+
try {
|
|
1639
|
+
const exists = await fs6.pathExists(filePath);
|
|
1640
|
+
if (!exists) {
|
|
1641
|
+
throw new Error(`IPA \u6587\u4EF6\u672A\u627E\u5230: ${filePath}`);
|
|
1642
|
+
}
|
|
1643
|
+
const fileSize = (await fs6.stat(filePath)).size;
|
|
1644
|
+
logger.info(`IPA \u6587\u4EF6\u5927\u5C0F: ${(fileSize / (1024 * 1024)).toFixed(2)} MB`);
|
|
1645
|
+
const retryStrategy = createUploadRetryStrategy(retryConfig);
|
|
1646
|
+
const uploadResult = await retryStrategy.execute(
|
|
1647
|
+
() => this.uploadWithAvailableTool(filePath, auth),
|
|
1648
|
+
"App Store \u4E0A\u4F20"
|
|
1649
|
+
);
|
|
1650
|
+
if (!uploadResult.success) {
|
|
1651
|
+
throw uploadResult.error || new Error("\u4E0A\u4F20\u5931\u8D25");
|
|
1652
|
+
}
|
|
1653
|
+
logger.success("App Store IPA \u4E0A\u4F20\u6210\u529F\uFF01");
|
|
1654
|
+
if (metadata.changelog && this.hasApiCredentials(auth)) {
|
|
1655
|
+
await this.tryUpdateReleaseNotes(auth, appConfig.appId, metadata.changelog, retryStrategy);
|
|
1656
|
+
} else if (metadata.changelog && !this.hasApiCredentials(auth)) {
|
|
1657
|
+
logger.info(
|
|
1658
|
+
"\u8DF3\u8FC7\u66F4\u65B0\u53D1\u884C\u8BF4\u660E\uFF1A\u672A\u914D\u7F6E API Key \u51ED\u636E\u3002\u5982\u9700\u542F\u7528\uFF0C\u8BF7\u5728\u914D\u7F6E\u4E2D\u6DFB\u52A0 apiKeyId + apiIssuerId + privateKeyPath\u3002"
|
|
1659
|
+
);
|
|
1660
|
+
}
|
|
1661
|
+
return {
|
|
1662
|
+
platform: "app_store",
|
|
1663
|
+
success: true,
|
|
1664
|
+
message: "\u4E0A\u4F20\u6210\u529F\u3002\u8BF7\u524D\u5F80 App Store Connect \u67E5\u770B\u3002",
|
|
1665
|
+
url: "https://appstoreconnect.apple.com/apps",
|
|
1666
|
+
consoleUrl: "https://appstoreconnect.apple.com/apps"
|
|
1667
|
+
};
|
|
1668
|
+
} catch (error) {
|
|
1669
|
+
return {
|
|
1670
|
+
platform: "app_store",
|
|
1671
|
+
success: false,
|
|
1672
|
+
message: error.message,
|
|
1673
|
+
error
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
// ───── CLI 上传 ─────
|
|
1678
|
+
/**
|
|
1679
|
+
* 使用可用的工具上传
|
|
1680
|
+
* 策略: 优先 Transporter → 降级 altool → 都失败
|
|
1681
|
+
*/
|
|
1682
|
+
async uploadWithAvailableTool(ipaPath, auth) {
|
|
1683
|
+
const appleId = auth.appleId;
|
|
1684
|
+
const appPassword = auth.appPassword;
|
|
1685
|
+
try {
|
|
1686
|
+
logger.info("\u6B63\u5728\u5C1D\u8BD5\u4F7F\u7528 Transporter CLI...");
|
|
1687
|
+
const transporterTool = await this.resolveToolPath("transporter");
|
|
1688
|
+
if (transporterTool) {
|
|
1689
|
+
await this.uploadViaTransporter(transporterTool, ipaPath, appleId, appPassword);
|
|
1690
|
+
logger.success("\u901A\u8FC7 Transporter \u4E0A\u4F20\u5B8C\u6210");
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
} catch (error) {
|
|
1694
|
+
logger.warn(`Transporter \u8C03\u7528\u5931\u8D25: ${error.message}\u3002\u5C1D\u8BD5\u964D\u7EA7\u5230 altool...`);
|
|
1695
|
+
}
|
|
1696
|
+
let altoolTool = null;
|
|
1697
|
+
try {
|
|
1698
|
+
logger.info("\u6B63\u5728\u5C1D\u8BD5\u4F7F\u7528 xcrun altool...");
|
|
1699
|
+
altoolTool = await this.resolveToolPath("altool");
|
|
1700
|
+
} catch (error) {
|
|
1701
|
+
logger.warn(`altool \u68C0\u6D4B\u5931\u8D25: ${error.message}`);
|
|
1702
|
+
}
|
|
1703
|
+
if (altoolTool) {
|
|
1704
|
+
await this.uploadViaAltool(altoolTool, ipaPath, appleId, appPassword);
|
|
1705
|
+
logger.success("\u901A\u8FC7 altool \u4E0A\u4F20\u5B8C\u6210");
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
throw new Error(this.getToolInstallationGuide());
|
|
1709
|
+
}
|
|
1710
|
+
/**
|
|
1711
|
+
* 解析工具路径结果
|
|
1712
|
+
*/
|
|
1713
|
+
async resolveToolPath(tool) {
|
|
1714
|
+
if (tool === "transporter") {
|
|
1715
|
+
try {
|
|
1716
|
+
await execAsync("transporter -version", { timeout: 5e3 });
|
|
1717
|
+
return { command: "transporter", useShell: false };
|
|
1718
|
+
} catch {
|
|
1719
|
+
}
|
|
1720
|
+
if (await fs6.pathExists(TRANSPORTER_APP_PATH)) {
|
|
1721
|
+
return { command: `"${TRANSPORTER_APP_PATH}"`, useShell: false };
|
|
1722
|
+
}
|
|
1723
|
+
} else {
|
|
1724
|
+
const xcrunPath = await fs6.pathExists("/usr/bin/xcrun") ? "/usr/bin/xcrun" : "xcrun";
|
|
1725
|
+
try {
|
|
1726
|
+
await execAsync(`${xcrunPath} altool --version`, {
|
|
1727
|
+
timeout: 1e4
|
|
1728
|
+
});
|
|
1729
|
+
return { command: `${xcrunPath} altool`, useShell: false };
|
|
1730
|
+
} catch (error) {
|
|
1731
|
+
logger.warn(
|
|
1732
|
+
`[AppleStore] Direct altool check failed: ${error.message}. Trying shell mode...`
|
|
1733
|
+
);
|
|
1734
|
+
}
|
|
1735
|
+
try {
|
|
1736
|
+
await execAsync(`bash -l -c "xcrun altool --version"`, {
|
|
1737
|
+
timeout: 1e4
|
|
1738
|
+
});
|
|
1739
|
+
return { command: "xcrun altool", useShell: true };
|
|
1740
|
+
} catch (error) {
|
|
1741
|
+
logger.warn(`[AppleStore] Shell altool check failed: ${error.message}`);
|
|
1742
|
+
}
|
|
1743
|
+
return null;
|
|
1744
|
+
}
|
|
1745
|
+
return null;
|
|
1746
|
+
}
|
|
1747
|
+
/**
|
|
1748
|
+
* 通过 Transporter CLI 上传
|
|
1749
|
+
*/
|
|
1750
|
+
async uploadViaTransporter(tool, ipaPath, appleId, appPassword) {
|
|
1751
|
+
const command = [
|
|
1752
|
+
tool.command,
|
|
1753
|
+
"-uploadFile",
|
|
1754
|
+
`"${ipaPath}"`,
|
|
1755
|
+
"-username",
|
|
1756
|
+
appleId,
|
|
1757
|
+
"-password",
|
|
1758
|
+
appPassword,
|
|
1759
|
+
"-showTransferRate"
|
|
1760
|
+
].join(" ");
|
|
1761
|
+
logger.info(`[Transporter] Uploading: ${path3.basename(ipaPath)}`);
|
|
1762
|
+
try {
|
|
1763
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
1764
|
+
timeout: 30 * 60 * 1e3,
|
|
1765
|
+
// 30 分钟
|
|
1766
|
+
maxBuffer: 10 * 1024 * 1024
|
|
1767
|
+
// 10MB
|
|
1768
|
+
});
|
|
1769
|
+
if (stdout) {
|
|
1770
|
+
logger.info(`[Transporter] ${stdout}`);
|
|
1771
|
+
}
|
|
1772
|
+
if (stderr && (stderr.includes("error") || stderr.includes("failed") || stderr.includes("ERROR"))) {
|
|
1773
|
+
const errorMessage = this.parseAltoolError(stderr);
|
|
1774
|
+
throw new Error(errorMessage);
|
|
1775
|
+
}
|
|
1776
|
+
} catch (error) {
|
|
1777
|
+
throw new Error(`Transporter upload failed: ${error.message}
|
|
1778
|
+
Stderr: ${error.stderr || ""}`);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
/**
|
|
1782
|
+
* 通过 altool 上传
|
|
1783
|
+
*/
|
|
1784
|
+
async uploadViaAltool(tool, ipaPath, appleId, appPassword) {
|
|
1785
|
+
const args = [
|
|
1786
|
+
"xcrun altool",
|
|
1787
|
+
// 占位符
|
|
1788
|
+
"--upload-app",
|
|
1789
|
+
"-f",
|
|
1790
|
+
"__IPA_PATH__",
|
|
1791
|
+
// 占位符
|
|
1792
|
+
"-t",
|
|
1793
|
+
"ios",
|
|
1794
|
+
"-u",
|
|
1795
|
+
appleId,
|
|
1796
|
+
"-p",
|
|
1797
|
+
appPassword
|
|
1798
|
+
// '--verbose', // 禁用 verbose
|
|
1799
|
+
];
|
|
1800
|
+
let command = "";
|
|
1801
|
+
if (tool.useShell) {
|
|
1802
|
+
const innerCommand = args.slice(1).join(" ").replace("__IPA_PATH__", `\\"${ipaPath}\\"`);
|
|
1803
|
+
command = `bash -l -c "${tool.command} ${innerCommand}"`;
|
|
1804
|
+
} else {
|
|
1805
|
+
const innerArgs = args.map((arg) => {
|
|
1806
|
+
if (arg === "xcrun altool") return tool.command;
|
|
1807
|
+
if (arg === "__IPA_PATH__") return `"${ipaPath}"`;
|
|
1808
|
+
return arg;
|
|
1809
|
+
});
|
|
1810
|
+
command = innerArgs.join(" ");
|
|
1811
|
+
}
|
|
1812
|
+
logger.info(`[altool] Uploading: ${path3.basename(ipaPath)}`);
|
|
1813
|
+
logger.info(`[altool] Command prefix: ${tool.command}`);
|
|
1814
|
+
try {
|
|
1815
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
1816
|
+
timeout: 30 * 60 * 1e3,
|
|
1817
|
+
maxBuffer: 10 * 1024 * 1024
|
|
1818
|
+
});
|
|
1819
|
+
if (stdout) {
|
|
1820
|
+
if (stdout.includes("No errors uploading") || stdout.includes("Request UUID")) {
|
|
1821
|
+
logger.info(`[altool] ${stdout}`);
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
if (stderr && (stderr.includes("error") || stderr.includes("failed") || stderr.includes("ERROR"))) {
|
|
1825
|
+
const errorMessage = this.parseAltoolError(stderr);
|
|
1826
|
+
throw new Error(errorMessage);
|
|
1827
|
+
}
|
|
1828
|
+
} catch (error) {
|
|
1829
|
+
const errorMsg = this.parseAltoolError(error.stderr || error.message);
|
|
1830
|
+
throw new Error(errorMsg);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* 解析 altool/Transporter 错误信息,翻译为用户友好的语言
|
|
1835
|
+
*/
|
|
1836
|
+
parseAltoolError(stderr) {
|
|
1837
|
+
if (!stderr) return "Unknown error";
|
|
1838
|
+
if (stderr.includes("previousBundleVersion") || stderr.includes("ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE") || stderr.includes("Redundant Binary Upload") || stderr.includes("already been used")) {
|
|
1839
|
+
const match = stderr.match(/previously uploaded version: ‘(.*?)’/);
|
|
1840
|
+
const prevVersion = match ? match[1] : "unknown";
|
|
1841
|
+
return `\u7248\u672C\u53F7\u51B2\u7A81 (Version Conflict): \u6784\u5EFA\u7248\u672C ${prevVersion} \u5DF2\u5B58\u5728\u4E8E App Store Connect\u3002
|
|
1842
|
+
\u89E3\u51B3\u65B9\u6848: \u8BF7\u5728 pubspec.yaml \u4E2D\u589E\u52A0 'version: x.y.z+N' \u4E2D\u7684 N (build number)\uFF0C\u7136\u540E\u91CD\u65B0\u6784\u5EFA IPA\u3002`;
|
|
1843
|
+
}
|
|
1844
|
+
if (stderr.includes("Authentication failed") || stderr.includes("password is incorrect") || stderr.includes("NOT_AUTHORIZED")) {
|
|
1845
|
+
return `\u8BA4\u8BC1\u5931\u8D25 (Auth Failed): Apple ID \u6216 App-specific Password \u9519\u8BEF\u3002
|
|
1846
|
+
\u89E3\u51B3\u65B9\u6848: \u8BF7\u68C0\u67E5\u914D\u7F6E\u3002\u6CE8\u610F\u5FC5\u987B\u4F7F\u7528 App \u4E13\u7528\u5BC6\u7801\uFF0C\u4E0D\u80FD\u4F7F\u7528 Apple ID \u767B\u5F55\u5BC6\u7801\u3002`;
|
|
1847
|
+
}
|
|
1848
|
+
if (stderr.includes("Asset validation failed")) {
|
|
1849
|
+
return `\u8D44\u4EA7\u9A8C\u8BC1\u5931\u8D25 (Validation Failed): IPA \u6587\u4EF6\u53EF\u80FD\u4E0D\u7B26\u5408 App Store \u8981\u6C42\u3002
|
|
1850
|
+
\u8BE6\u7EC6\u9519\u8BEF: ${stderr}`;
|
|
1851
|
+
}
|
|
1852
|
+
return `\u4E0A\u4F20\u5DE5\u5177\u62A5\u9519: ${stderr.substring(0, 800)}...`;
|
|
1853
|
+
}
|
|
1854
|
+
// ───── 发行说明 API ─────
|
|
1855
|
+
/**
|
|
1856
|
+
* 检查是否配置了 API Key 凭据
|
|
1857
|
+
*/
|
|
1858
|
+
hasApiCredentials(auth) {
|
|
1859
|
+
return !!(auth?.apiKeyId && auth?.apiIssuerId && (auth?.privateKeyPath || auth?.privateKeyContent));
|
|
1860
|
+
}
|
|
1861
|
+
/**
|
|
1862
|
+
* 创建 JWT 生成器
|
|
1863
|
+
*/
|
|
1864
|
+
async createJWTGenerator(auth) {
|
|
1865
|
+
if (auth.privateKeyContent) {
|
|
1866
|
+
return AppleJWTGenerator.fromContent(auth.apiIssuerId, auth.apiKeyId, auth.privateKeyContent);
|
|
1867
|
+
}
|
|
1868
|
+
return AppleJWTGenerator.fromFile(auth.apiIssuerId, auth.apiKeyId, auth.privateKeyPath);
|
|
1869
|
+
}
|
|
1870
|
+
/**
|
|
1871
|
+
* 获取 API 请求头
|
|
1872
|
+
*/
|
|
1873
|
+
apiHeaders(token) {
|
|
1874
|
+
return {
|
|
1875
|
+
Authorization: `Bearer ${token}`,
|
|
1876
|
+
"Content-Type": "application/json"
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1879
|
+
/**
|
|
1880
|
+
* 尝试更新发行说明(失败不影响上传结果)
|
|
1881
|
+
*/
|
|
1882
|
+
async tryUpdateReleaseNotes(auth, appId, changelog, retryStrategy) {
|
|
1883
|
+
if (!appId) {
|
|
1884
|
+
logger.info(
|
|
1885
|
+
"\u8DF3\u8FC7\u66F4\u65B0\u53D1\u884C\u8BF4\u660E\uFF1A\u672A\u914D\u7F6E App ID\u3002\u8BF7\u6DFB\u52A0 app_store.appId \u4EE5\u542F\u7528\u81EA\u52A8\u66F4\u65B0\u53D1\u884C\u8BF4\u660E\u3002"
|
|
1886
|
+
);
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
try {
|
|
1890
|
+
logger.info("\u6B63\u5728\u901A\u8FC7 App Store Connect API \u66F4\u65B0\u53D1\u884C\u8BF4\u660E...");
|
|
1891
|
+
const jwtGenerator = await this.createJWTGenerator(auth);
|
|
1892
|
+
const token = jwtGenerator.generate();
|
|
1893
|
+
const version = await this.getPrepareForSubmissionVersion(token, appId);
|
|
1894
|
+
if (!version) {
|
|
1895
|
+
logger.warn(
|
|
1896
|
+
'\u672A\u627E\u5230\u5904\u4E8E"\u51C6\u5907\u63D0\u4EA4 (PREPARE_FOR_SUBMISSION)"\u72B6\u6001\u7684\u7248\u672C\u3002\u5DF2\u8DF3\u8FC7\u66F4\u65B0\u53D1\u884C\u8BF4\u660E\u3002\u8BF7\u5728 App Store Connect \u624B\u52A8\u66F4\u65B0\u3002'
|
|
1897
|
+
);
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
const localizations = await this.getVersionLocalizations(token, version.id);
|
|
1901
|
+
if (localizations.length === 0) {
|
|
1902
|
+
logger.warn("\u8BE5\u7248\u672C\u6CA1\u6709\u627E\u5230\u672C\u5730\u5316\u4FE1\u606F\u3002\u8DF3\u8FC7\u66F4\u65B0\u53D1\u884C\u8BF4\u660E\u3002");
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
for (const loc of localizations) {
|
|
1906
|
+
const locale = loc.attributes?.locale || "unknown";
|
|
1907
|
+
await retryStrategy.execute(
|
|
1908
|
+
() => this.updateLocalizationWhatsNew(token, loc.id, changelog),
|
|
1909
|
+
`\u66F4\u65B0\u53D1\u884C\u8BF4\u660E (${locale})`
|
|
1910
|
+
);
|
|
1911
|
+
logger.info(`\u5DF2\u66F4\u65B0\u53D1\u884C\u8BF4\u660E: ${locale}`);
|
|
1912
|
+
}
|
|
1913
|
+
logger.success("\u6240\u6709\u53D1\u884C\u8BF4\u660E\u66F4\u65B0\u6210\u529F\uFF01");
|
|
1914
|
+
} catch (error) {
|
|
1915
|
+
logger.warn(`\u66F4\u65B0\u53D1\u884C\u8BF4\u660E\u5931\u8D25: ${error.message}\u3002IPA \u4E0A\u4F20\u5DF2\u6210\u529F\u3002\u8BF7\u624B\u52A8\u66F4\u65B0\u53D1\u884C\u8BF4\u660E\u3002`);
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
/**
|
|
1919
|
+
* 获取处于"准备提交"状态的版本
|
|
1920
|
+
* 只有此状态下的版本才能修改发行说明
|
|
1921
|
+
*/
|
|
1922
|
+
async getPrepareForSubmissionVersion(token, appId) {
|
|
1923
|
+
const res = await axios4.get(`${APP_STORE_CONNECT_API}/apps/${appId}/appStoreVersions`, {
|
|
1924
|
+
params: {
|
|
1925
|
+
"filter[appStoreState]": "PREPARE_FOR_SUBMISSION",
|
|
1926
|
+
limit: 1
|
|
1927
|
+
},
|
|
1928
|
+
headers: this.apiHeaders(token)
|
|
1929
|
+
});
|
|
1930
|
+
const versions = res.data?.data;
|
|
1931
|
+
if (versions && versions.length > 0) {
|
|
1932
|
+
logger.info(
|
|
1933
|
+
`Found version: ${versions[0].attributes?.versionString} (PREPARE_FOR_SUBMISSION)`
|
|
1934
|
+
);
|
|
1935
|
+
return versions[0];
|
|
1936
|
+
}
|
|
1937
|
+
return null;
|
|
1938
|
+
}
|
|
1939
|
+
/**
|
|
1940
|
+
* 获取版本的本地化信息列表
|
|
1941
|
+
*/
|
|
1942
|
+
async getVersionLocalizations(token, versionId) {
|
|
1943
|
+
const res = await axios4.get(
|
|
1944
|
+
`${APP_STORE_CONNECT_API}/appStoreVersions/${versionId}/appStoreVersionLocalizations`,
|
|
1945
|
+
{
|
|
1946
|
+
headers: this.apiHeaders(token)
|
|
1947
|
+
}
|
|
1948
|
+
);
|
|
1949
|
+
return res.data?.data || [];
|
|
1950
|
+
}
|
|
1951
|
+
/**
|
|
1952
|
+
* 更新本地化版本的 What's New 内容
|
|
1953
|
+
*/
|
|
1954
|
+
async updateLocalizationWhatsNew(token, localizationId, whatsNew) {
|
|
1955
|
+
await axios4.patch(
|
|
1956
|
+
`${APP_STORE_CONNECT_API}/appStoreVersionLocalizations/${localizationId}`,
|
|
1957
|
+
{
|
|
1958
|
+
data: {
|
|
1959
|
+
type: "appStoreVersionLocalizations",
|
|
1960
|
+
id: localizationId,
|
|
1961
|
+
attributes: {
|
|
1962
|
+
whatsNew
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
},
|
|
1966
|
+
{
|
|
1967
|
+
headers: this.apiHeaders(token)
|
|
1968
|
+
}
|
|
1969
|
+
);
|
|
1970
|
+
}
|
|
1971
|
+
// ───── 工具安装指引 ─────
|
|
1972
|
+
/**
|
|
1973
|
+
* 获取工具安装指引
|
|
1974
|
+
*/
|
|
1975
|
+
getToolInstallationGuide() {
|
|
1976
|
+
return `
|
|
1977
|
+
Neither Transporter nor altool CLI found. Please install one:
|
|
1978
|
+
|
|
1979
|
+
\u3010\u63A8\u8350\u3011 Transporter CLI (\u5B98\u65B9\u63A8\u8350):
|
|
1980
|
+
Mac: https://apps.apple.com/app/transporter/id1450874784
|
|
1981
|
+
Windows: https://www.microsoft.com/store/apps/9NBLGGH52RM5
|
|
1982
|
+
|
|
1983
|
+
\u3010\u5907\u9009\u3011 xcrun altool (Xcode \u81EA\u5E26):
|
|
1984
|
+
Install Xcode Command Line Tools:
|
|
1985
|
+
xcode-select --install
|
|
1986
|
+
|
|
1987
|
+
Note:
|
|
1988
|
+
- \u9700\u8981\u4F7F\u7528 Apple ID \u548C App-specific Password (\u5728 appleid.apple.com \u751F\u6210)
|
|
1989
|
+
- \u4E0D\u80FD\u4F7F\u7528\u666E\u901A Apple ID \u5BC6\u7801
|
|
1990
|
+
`.trim();
|
|
1991
|
+
}
|
|
1992
|
+
};
|
|
1993
|
+
|
|
1994
|
+
// src/adapters/testflight_uploader.ts
|
|
1995
|
+
import fs7 from "fs-extra";
|
|
1996
|
+
import axios5 from "axios";
|
|
1997
|
+
import FormData4 from "form-data";
|
|
1998
|
+
var TestFlightUploader = class _TestFlightUploader {
|
|
1999
|
+
name = "TestFlight";
|
|
2000
|
+
static API_ROOT = "https://api.appstoreconnect.apple.com/v1";
|
|
2001
|
+
static API_TIMEOUT = 3e4;
|
|
2002
|
+
static BUILD_PROCESSING_TIMEOUT = 10 * 60 * 1e3;
|
|
2003
|
+
// 10 minutes
|
|
2004
|
+
static BUILD_POLLING_INTERVAL = 10 * 1e3;
|
|
2005
|
+
// 10 seconds
|
|
2006
|
+
/**
|
|
2007
|
+
* 验证配置
|
|
2008
|
+
* @param config 配置对象
|
|
2009
|
+
*/
|
|
2010
|
+
validateConfig(config) {
|
|
2011
|
+
if (!config?.publish?.app_store?.enable) return false;
|
|
2012
|
+
if (config.publish.app_store.mode !== "testflight") return false;
|
|
2013
|
+
const auth = config.publish.app_store.auth;
|
|
2014
|
+
if (!auth?.apiKeyId || !auth?.apiIssuerId) {
|
|
2015
|
+
logger.error("TestFlight config missing: apiKeyId or apiIssuerId");
|
|
2016
|
+
return false;
|
|
2017
|
+
}
|
|
2018
|
+
if (!auth.privateKeyPath && !auth.privateKeyContent) {
|
|
2019
|
+
logger.error("TestFlight config missing: privateKeyPath or privateKeyContent");
|
|
2020
|
+
return false;
|
|
2021
|
+
}
|
|
2022
|
+
return true;
|
|
2023
|
+
}
|
|
2024
|
+
/**
|
|
2025
|
+
* 判断是否支持该文件
|
|
2026
|
+
* @param filePath 文件路径
|
|
2027
|
+
*/
|
|
2028
|
+
canHandle(filePath) {
|
|
2029
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
2030
|
+
return ext === "ipa";
|
|
2031
|
+
}
|
|
2032
|
+
/**
|
|
2033
|
+
* 执行上传到 TestFlight
|
|
2034
|
+
* @param filePath 文件路径
|
|
2035
|
+
* @param config 配置
|
|
2036
|
+
* @param metadata 元数据
|
|
2037
|
+
*/
|
|
2038
|
+
async upload(filePath, config, metadata) {
|
|
2039
|
+
const appConfig = config.publish.app_store;
|
|
2040
|
+
const auth = appConfig.auth;
|
|
2041
|
+
const testflightConfig = appConfig.testflight;
|
|
2042
|
+
const retryConfig = config.retry;
|
|
2043
|
+
logger.info("Starting TestFlight upload...");
|
|
2044
|
+
try {
|
|
2045
|
+
const exists = await fs7.pathExists(filePath);
|
|
2046
|
+
if (!exists) {
|
|
2047
|
+
throw new Error(`IPA file not found: ${filePath}`);
|
|
2048
|
+
}
|
|
2049
|
+
const fileSize = (await fs7.stat(filePath)).size;
|
|
2050
|
+
logger.info(`IPA file size: ${(fileSize / (1024 * 1024)).toFixed(2)} MB`);
|
|
2051
|
+
const jwtGenerator = await this.loadJWTGenerator(auth);
|
|
2052
|
+
const retryStrategy = createUploadRetryStrategy(retryConfig);
|
|
2053
|
+
logger.info("Generating JWT token...");
|
|
2054
|
+
const jwt = jwtGenerator.generate();
|
|
2055
|
+
const bundleId = metadata.platform?.includes("ios") ? this.extractBundleId(filePath) : null;
|
|
2056
|
+
if (!bundleId) {
|
|
2057
|
+
throw new Error("Could not determine app bundle ID from IPA");
|
|
2058
|
+
}
|
|
2059
|
+
logger.info(`App bundle ID: ${bundleId}`);
|
|
2060
|
+
logger.info("Creating build record for TestFlight...");
|
|
2061
|
+
const buildResult = await retryStrategy.execute(
|
|
2062
|
+
() => this.createBuild(jwt, bundleId, metadata.version),
|
|
2063
|
+
"TestFlight create build"
|
|
2064
|
+
);
|
|
2065
|
+
if (!buildResult.success || !buildResult.data) {
|
|
2066
|
+
throw buildResult.error || new Error("Failed to create build record");
|
|
2067
|
+
}
|
|
2068
|
+
const buildId = buildResult.data;
|
|
2069
|
+
logger.info(`Build created: ${buildId}`);
|
|
2070
|
+
logger.info("Uploading IPA file to TestFlight (this may take a while)...");
|
|
2071
|
+
const uploadResult = await retryStrategy.execute(
|
|
2072
|
+
() => this.uploadIPAFile(jwt, buildId, filePath),
|
|
2073
|
+
"TestFlight upload file"
|
|
2074
|
+
);
|
|
2075
|
+
if (!uploadResult.success) {
|
|
2076
|
+
throw uploadResult.error || new Error("IPA upload failed");
|
|
2077
|
+
}
|
|
2078
|
+
logger.info("Waiting for build processing (this may take 5-10 minutes)...");
|
|
2079
|
+
await this.waitForBuildProcessing(jwt, buildId, _TestFlightUploader.BUILD_PROCESSING_TIMEOUT);
|
|
2080
|
+
logger.success("Build processing completed");
|
|
2081
|
+
logger.info("Creating Beta Build Version...");
|
|
2082
|
+
const betaBuildResult = await retryStrategy.execute(
|
|
2083
|
+
() => this.createBetaBuildVersion(jwt, buildId),
|
|
2084
|
+
"TestFlight create beta version"
|
|
2085
|
+
);
|
|
2086
|
+
if (!betaBuildResult.success) {
|
|
2087
|
+
logger.warn("Could not create Beta Build Version automatically");
|
|
2088
|
+
}
|
|
2089
|
+
if (testflightConfig?.betaTesters && testflightConfig.betaTesters.length > 0) {
|
|
2090
|
+
logger.info("Adding beta testers...");
|
|
2091
|
+
await this.addBetaTesters(jwt, buildId, testflightConfig.betaTesters, retryStrategy);
|
|
2092
|
+
logger.success("Beta testers added");
|
|
2093
|
+
}
|
|
2094
|
+
if (testflightConfig?.betaBuildLocalization && testflightConfig.betaBuildLocalization.length > 0) {
|
|
2095
|
+
logger.info("Adding beta build localization...");
|
|
2096
|
+
await this.addBetaBuildLocalization(
|
|
2097
|
+
jwt,
|
|
2098
|
+
buildId,
|
|
2099
|
+
testflightConfig.betaBuildLocalization,
|
|
2100
|
+
retryStrategy
|
|
2101
|
+
);
|
|
2102
|
+
logger.success("Beta build localization added");
|
|
2103
|
+
}
|
|
2104
|
+
if (testflightConfig?.autoNotify !== false && testflightConfig?.betaTesters?.length) {
|
|
2105
|
+
logger.info("Notifying beta testers...");
|
|
2106
|
+
await this.notifyBetaTesters(jwt, buildId, metadata.changelog || "New TestFlight build");
|
|
2107
|
+
logger.success("Beta testers notified");
|
|
2108
|
+
}
|
|
2109
|
+
logger.success("TestFlight upload successful!");
|
|
2110
|
+
return {
|
|
2111
|
+
platform: "testflight",
|
|
2112
|
+
success: true,
|
|
2113
|
+
message: "Build uploaded to TestFlight and configured for beta testing",
|
|
2114
|
+
url: `https://appstoreconnect.apple.com/apps/${bundleId}/testflight/builds`
|
|
2115
|
+
};
|
|
2116
|
+
} catch (error) {
|
|
2117
|
+
logger.error(`TestFlight upload failed: ${error.message}`);
|
|
2118
|
+
if (error.response?.data) {
|
|
2119
|
+
logger.info(`Error Data: ${JSON.stringify(error.response.data)}`);
|
|
2120
|
+
}
|
|
2121
|
+
return {
|
|
2122
|
+
platform: "testflight",
|
|
2123
|
+
success: false,
|
|
2124
|
+
message: error.message,
|
|
2125
|
+
error
|
|
2126
|
+
};
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
/**
|
|
2130
|
+
* 加载 JWT 生成器
|
|
2131
|
+
* @param auth 认证信息
|
|
2132
|
+
*/
|
|
2133
|
+
async loadJWTGenerator(auth) {
|
|
2134
|
+
if (auth.privateKeyContent) {
|
|
2135
|
+
return AppleJWTGenerator.fromContent(auth.apiIssuerId, auth.apiKeyId, auth.privateKeyContent);
|
|
2136
|
+
} else if (auth.privateKeyPath) {
|
|
2137
|
+
return await AppleJWTGenerator.fromFile(auth.apiIssuerId, auth.apiKeyId, auth.privateKeyPath);
|
|
2138
|
+
} else {
|
|
2139
|
+
throw new Error("No Apple private key provided");
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
/**
|
|
2143
|
+
* 提取 Bundle ID
|
|
2144
|
+
* @param ipaPath IPA 路径
|
|
2145
|
+
*/
|
|
2146
|
+
extractBundleId(ipaPath) {
|
|
2147
|
+
return null;
|
|
2148
|
+
}
|
|
2149
|
+
/**
|
|
2150
|
+
* 创建构建记录
|
|
2151
|
+
* @param jwt JWT
|
|
2152
|
+
* @param bundleId Bundle ID
|
|
2153
|
+
* @param version 版本号
|
|
2154
|
+
*/
|
|
2155
|
+
async createBuild(jwt, bundleId, version) {
|
|
2156
|
+
try {
|
|
2157
|
+
const response = await axios5.post(
|
|
2158
|
+
`${_TestFlightUploader.API_ROOT}/builds`,
|
|
2159
|
+
{
|
|
2160
|
+
data: {
|
|
2161
|
+
type: "builds",
|
|
2162
|
+
attributes: {
|
|
2163
|
+
version,
|
|
2164
|
+
uploadedDate: (/* @__PURE__ */ new Date()).toISOString()
|
|
2165
|
+
},
|
|
2166
|
+
relationships: {
|
|
2167
|
+
app: {
|
|
2168
|
+
data: {
|
|
2169
|
+
type: "apps",
|
|
2170
|
+
id: bundleId
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
},
|
|
2176
|
+
{
|
|
2177
|
+
headers: {
|
|
2178
|
+
Authorization: `Bearer ${jwt}`,
|
|
2179
|
+
"Content-Type": "application/json"
|
|
2180
|
+
},
|
|
2181
|
+
timeout: _TestFlightUploader.API_TIMEOUT
|
|
2182
|
+
}
|
|
2183
|
+
);
|
|
2184
|
+
return response.data.data.id;
|
|
2185
|
+
} catch (error) {
|
|
2186
|
+
if (error.response?.status === 401) {
|
|
2187
|
+
throw new Error("Authentication failed: Invalid JWT or API credentials");
|
|
2188
|
+
}
|
|
2189
|
+
throw error;
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
/**
|
|
2193
|
+
* 上传 IPA 文件
|
|
2194
|
+
* @param jwt JWT
|
|
2195
|
+
* @param buildId 构建 ID
|
|
2196
|
+
* @param filePath 文件路径
|
|
2197
|
+
*/
|
|
2198
|
+
async uploadIPAFile(jwt, buildId, filePath) {
|
|
2199
|
+
const form = new FormData4();
|
|
2200
|
+
form.append("file", fs7.createReadStream(filePath));
|
|
2201
|
+
try {
|
|
2202
|
+
await axios5.post(
|
|
2203
|
+
`${_TestFlightUploader.API_ROOT}/builds/${buildId}/appStoreVersionExternalTestingBuilds`,
|
|
2204
|
+
form,
|
|
2205
|
+
{
|
|
2206
|
+
headers: {
|
|
2207
|
+
Authorization: `Bearer ${jwt}`,
|
|
2208
|
+
...form.getHeaders()
|
|
2209
|
+
},
|
|
2210
|
+
timeout: 5 * 60 * 1e3,
|
|
2211
|
+
maxContentLength: Infinity,
|
|
2212
|
+
maxBodyLength: Infinity
|
|
2213
|
+
}
|
|
2214
|
+
);
|
|
2215
|
+
} catch (error) {
|
|
2216
|
+
throw new Error(`Failed to upload IPA: ${error.message}`);
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
/**
|
|
2220
|
+
* 等待构建处理
|
|
2221
|
+
* @param jwt JWT
|
|
2222
|
+
* @param buildId 构建 ID
|
|
2223
|
+
* @param timeoutMs 超时时间
|
|
2224
|
+
*/
|
|
2225
|
+
async waitForBuildProcessing(jwt, buildId, timeoutMs) {
|
|
2226
|
+
const startTime = Date.now();
|
|
2227
|
+
let lastStatus = "PROCESSING";
|
|
2228
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
2229
|
+
try {
|
|
2230
|
+
const response = await axios5.get(`${_TestFlightUploader.API_ROOT}/builds/${buildId}`, {
|
|
2231
|
+
headers: {
|
|
2232
|
+
Authorization: `Bearer ${jwt}`,
|
|
2233
|
+
"Content-Type": "application/json"
|
|
2234
|
+
},
|
|
2235
|
+
timeout: _TestFlightUploader.API_TIMEOUT
|
|
2236
|
+
});
|
|
2237
|
+
const processingState = response.data.data.attributes.processingState;
|
|
2238
|
+
if (processingState === "READY_FOR_DISTRIBUTION") {
|
|
2239
|
+
logger.success("Build ready for distribution");
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
if (processingState === "FAILED" || processingState === "INVALID") {
|
|
2243
|
+
throw new Error(`Build processing failed with status: ${processingState}`);
|
|
2244
|
+
}
|
|
2245
|
+
if (processingState !== lastStatus) {
|
|
2246
|
+
logger.info(`Build status: ${processingState}`);
|
|
2247
|
+
lastStatus = processingState;
|
|
2248
|
+
}
|
|
2249
|
+
await new Promise(
|
|
2250
|
+
(resolve) => setTimeout(resolve, _TestFlightUploader.BUILD_POLLING_INTERVAL)
|
|
2251
|
+
);
|
|
2252
|
+
} catch (error) {
|
|
2253
|
+
logger.warn(`Error checking build status: ${error.message}`);
|
|
2254
|
+
await new Promise(
|
|
2255
|
+
(resolve) => setTimeout(resolve, _TestFlightUploader.BUILD_POLLING_INTERVAL)
|
|
2256
|
+
);
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
throw new Error(`Build processing timeout after ${(timeoutMs / 1e3 / 60).toFixed(1)} minutes.`);
|
|
2260
|
+
}
|
|
2261
|
+
/**
|
|
2262
|
+
* 创建 Beta 版本(使其可测试)
|
|
2263
|
+
* @param jwt JWT
|
|
2264
|
+
* @param buildId 构建 ID
|
|
2265
|
+
*/
|
|
2266
|
+
async createBetaBuildVersion(jwt, buildId) {
|
|
2267
|
+
try {
|
|
2268
|
+
const response = await axios5.post(
|
|
2269
|
+
`${_TestFlightUploader.API_ROOT}/betaBuildVersions`,
|
|
2270
|
+
{
|
|
2271
|
+
data: {
|
|
2272
|
+
type: "betaBuildVersions",
|
|
2273
|
+
attributes: {},
|
|
2274
|
+
relationships: {
|
|
2275
|
+
build: {
|
|
2276
|
+
data: {
|
|
2277
|
+
type: "builds",
|
|
2278
|
+
id: buildId
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
},
|
|
2284
|
+
{
|
|
2285
|
+
headers: {
|
|
2286
|
+
Authorization: `Bearer ${jwt}`,
|
|
2287
|
+
"Content-Type": "application/json"
|
|
2288
|
+
},
|
|
2289
|
+
timeout: _TestFlightUploader.API_TIMEOUT
|
|
2290
|
+
}
|
|
2291
|
+
);
|
|
2292
|
+
return response.data.data.id;
|
|
2293
|
+
} catch (error) {
|
|
2294
|
+
throw new Error(`Failed to create Beta Build Version: ${error.message}`);
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
/**
|
|
2298
|
+
* Add beta testers to the build
|
|
2299
|
+
*/
|
|
2300
|
+
async addBetaTesters(jwt, buildId, testers, retryStrategy) {
|
|
2301
|
+
for (const testerEmail of testers) {
|
|
2302
|
+
try {
|
|
2303
|
+
await retryStrategy.execute(
|
|
2304
|
+
() => this.addBetaTesterToGroup(jwt, testerEmail, buildId),
|
|
2305
|
+
`TestFlight add tester ${testerEmail}`
|
|
2306
|
+
);
|
|
2307
|
+
logger.info(`\u2713 Added tester: ${testerEmail}`);
|
|
2308
|
+
} catch (error) {
|
|
2309
|
+
logger.warn(`Failed to add tester ${testerEmail}: ${error.message}`);
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
/**
|
|
2314
|
+
* 添加单个测试员到组
|
|
2315
|
+
* @param jwt JWT
|
|
2316
|
+
* @param testerEmail 测试员邮箱
|
|
2317
|
+
* @param buildId 构建 ID
|
|
2318
|
+
*/
|
|
2319
|
+
async addBetaTesterToGroup(jwt, testerEmail, buildId) {
|
|
2320
|
+
try {
|
|
2321
|
+
logger.info(`Attempting to add ${testerEmail} to beta testers...`);
|
|
2322
|
+
} catch (error) {
|
|
2323
|
+
throw new Error(`Failed to add beta tester: ${error.message}`);
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
/**
|
|
2327
|
+
* Add localization for beta build
|
|
2328
|
+
*/
|
|
2329
|
+
async addBetaBuildLocalization(jwt, buildId, localizations, retryStrategy) {
|
|
2330
|
+
for (const localization of localizations) {
|
|
2331
|
+
try {
|
|
2332
|
+
await retryStrategy.execute(
|
|
2333
|
+
() => this.addLocalizationToBuilder(jwt, buildId, localization),
|
|
2334
|
+
`TestFlight add localization ${localization.locale}`
|
|
2335
|
+
);
|
|
2336
|
+
logger.info(`\u2713 Added localization: ${localization.locale}`);
|
|
2337
|
+
} catch (error) {
|
|
2338
|
+
logger.warn(`Failed to add localization ${localization.locale}: ${error.message}`);
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
/**
|
|
2343
|
+
* Add single localization
|
|
2344
|
+
*/
|
|
2345
|
+
async addLocalizationToBuilder(jwt, buildId, localization) {
|
|
2346
|
+
try {
|
|
2347
|
+
await axios5.post(
|
|
2348
|
+
`${_TestFlightUploader.API_ROOT}/betaBuildLocalizations`,
|
|
2349
|
+
{
|
|
2350
|
+
data: {
|
|
2351
|
+
type: "betaBuildLocalizations",
|
|
2352
|
+
attributes: {
|
|
2353
|
+
locale: localization.locale,
|
|
2354
|
+
description: localization.description,
|
|
2355
|
+
keywords: localization.keywords?.join(",") || ""
|
|
2356
|
+
},
|
|
2357
|
+
relationships: {
|
|
2358
|
+
build: {
|
|
2359
|
+
data: {
|
|
2360
|
+
type: "betaBuildVersions",
|
|
2361
|
+
id: buildId
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
},
|
|
2367
|
+
{
|
|
2368
|
+
headers: {
|
|
2369
|
+
Authorization: `Bearer ${jwt}`,
|
|
2370
|
+
"Content-Type": "application/json"
|
|
2371
|
+
},
|
|
2372
|
+
timeout: _TestFlightUploader.API_TIMEOUT
|
|
2373
|
+
}
|
|
2374
|
+
);
|
|
2375
|
+
} catch (error) {
|
|
2376
|
+
throw new Error(`Failed to add localization: ${error.message}`);
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
/**
|
|
2380
|
+
* 通知测试员
|
|
2381
|
+
* @param jwt JWT
|
|
2382
|
+
* @param buildId 构建 ID
|
|
2383
|
+
* @param message 消息
|
|
2384
|
+
*/
|
|
2385
|
+
async notifyBetaTesters(jwt, buildId, message) {
|
|
2386
|
+
try {
|
|
2387
|
+
logger.info("Sending notification to beta testers...");
|
|
2388
|
+
} catch (error) {
|
|
2389
|
+
logger.warn(`Failed to notify testers: ${error.message}`);
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
};
|
|
2393
|
+
|
|
2394
|
+
// src/adapters/adhoc_uploader.ts
|
|
2395
|
+
import fs8 from "fs-extra";
|
|
2396
|
+
import path4 from "path";
|
|
2397
|
+
import { execSync } from "child_process";
|
|
2398
|
+
var AdHocUploader = class {
|
|
2399
|
+
name = "AdHoc";
|
|
2400
|
+
/**
|
|
2401
|
+
* 验证配置
|
|
2402
|
+
* @param config 配置对象
|
|
2403
|
+
* @returns 是否有效
|
|
2404
|
+
*/
|
|
2405
|
+
validateConfig(config) {
|
|
2406
|
+
if (!config?.publish?.app_store?.enable) return false;
|
|
2407
|
+
const appConfig = config.publish.app_store;
|
|
2408
|
+
const mode = appConfig?.mode;
|
|
2409
|
+
if (mode !== "adhoc") return false;
|
|
2410
|
+
const adhocConfig = appConfig?.adhoc;
|
|
2411
|
+
if (!adhocConfig) {
|
|
2412
|
+
logger.error("Ad Hoc config missing: adhoc configuration");
|
|
2413
|
+
return false;
|
|
2414
|
+
}
|
|
2415
|
+
return true;
|
|
2416
|
+
}
|
|
2417
|
+
/**
|
|
2418
|
+
* 判断是否支持该文件
|
|
2419
|
+
* @param filePath 文件路径
|
|
2420
|
+
* @returns 是否支持
|
|
2421
|
+
*/
|
|
2422
|
+
canHandle(filePath) {
|
|
2423
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
2424
|
+
return ext === "ipa";
|
|
2425
|
+
}
|
|
2426
|
+
/**
|
|
2427
|
+
* 执行上传(生成 Ad Hoc 分发包)
|
|
2428
|
+
* @param filePath 文件路径
|
|
2429
|
+
* @param config 配置
|
|
2430
|
+
* @param metadata 元数据
|
|
2431
|
+
*/
|
|
2432
|
+
async upload(filePath, config, metadata) {
|
|
2433
|
+
const appConfig = config.publish.app_store;
|
|
2434
|
+
const adhocConfig = appConfig.adhoc;
|
|
2435
|
+
const retryConfig = config.retry;
|
|
2436
|
+
logger.info("Starting Ad Hoc distribution setup...");
|
|
2437
|
+
try {
|
|
2438
|
+
const exists = await fs8.pathExists(filePath);
|
|
2439
|
+
if (!exists) {
|
|
2440
|
+
throw new Error(`IPA file not found: ${filePath}`);
|
|
2441
|
+
}
|
|
2442
|
+
const fileSize = (await fs8.stat(filePath)).size;
|
|
2443
|
+
logger.info(`IPA file size: ${(fileSize / (1024 * 1024)).toFixed(2)} MB`);
|
|
2444
|
+
const retryStrategy = createUploadRetryStrategy(retryConfig);
|
|
2445
|
+
const bundleId = this.extractBundleId(filePath);
|
|
2446
|
+
if (!bundleId) {
|
|
2447
|
+
logger.warn("Could not determine app bundle ID from IPA");
|
|
2448
|
+
} else {
|
|
2449
|
+
logger.info(`App bundle ID: ${bundleId}`);
|
|
2450
|
+
}
|
|
2451
|
+
logger.info("Verifying Ad Hoc provisioning profile...");
|
|
2452
|
+
const profileInfo = this.verifyProvisioningProfile(
|
|
2453
|
+
adhocConfig.provisioningProfileSpecifier,
|
|
2454
|
+
adhocConfig.teamId
|
|
2455
|
+
);
|
|
2456
|
+
logger.info("Creating Ad Hoc distribution manifest...");
|
|
2457
|
+
const manifestPath = await this.createManifest(
|
|
2458
|
+
filePath,
|
|
2459
|
+
metadata.version,
|
|
2460
|
+
bundleId,
|
|
2461
|
+
metadata.changelog
|
|
2462
|
+
);
|
|
2463
|
+
logger.info("Setting up distribution directory...");
|
|
2464
|
+
const distributionPath = await this.setupDistributionDirectory(filePath, manifestPath);
|
|
2465
|
+
logger.info("Generating installation links...");
|
|
2466
|
+
const installUrl = this.generateInstallUrl(distributionPath, bundleId);
|
|
2467
|
+
const qrCodePath = await this.generateQRCode(installUrl, distributionPath);
|
|
2468
|
+
this.outputDistributionInfo(installUrl, qrCodePath, distributionPath, profileInfo);
|
|
2469
|
+
logger.success("Ad Hoc distribution setup completed!");
|
|
2470
|
+
return {
|
|
2471
|
+
platform: "adhoc",
|
|
2472
|
+
success: true,
|
|
2473
|
+
message: `Ad Hoc package ready for distribution. Install link and QR code generated.`,
|
|
2474
|
+
url: installUrl
|
|
2475
|
+
};
|
|
2476
|
+
} catch (error) {
|
|
2477
|
+
logger.error(`Ad Hoc setup failed: ${error.message}`);
|
|
2478
|
+
return {
|
|
2479
|
+
platform: "adhoc",
|
|
2480
|
+
success: false,
|
|
2481
|
+
message: error.message,
|
|
2482
|
+
error
|
|
2483
|
+
};
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
/**
|
|
2487
|
+
* 从 IPA 提取 Bundle ID
|
|
2488
|
+
* @param ipaPath IPA 路径
|
|
2489
|
+
*/
|
|
2490
|
+
extractBundleId(ipaPath) {
|
|
2491
|
+
try {
|
|
2492
|
+
return null;
|
|
2493
|
+
} catch (error) {
|
|
2494
|
+
logger.warn(`Failed to extract bundle ID: ${error.message}`);
|
|
2495
|
+
return null;
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
/**
|
|
2499
|
+
* 验证 Provisioning Profile
|
|
2500
|
+
* @param profileSpecifier Profile 名称
|
|
2501
|
+
* @param teamId 团队 ID
|
|
2502
|
+
*/
|
|
2503
|
+
verifyProvisioningProfile(profileSpecifier, teamId) {
|
|
2504
|
+
if (!profileSpecifier) {
|
|
2505
|
+
logger.warn("No provisioning profile specified, will use automatic signing");
|
|
2506
|
+
return {
|
|
2507
|
+
name: "Automatic",
|
|
2508
|
+
teamId: teamId || "Unknown",
|
|
2509
|
+
capacity: 100,
|
|
2510
|
+
usedSlots: 0
|
|
2511
|
+
};
|
|
2512
|
+
}
|
|
2513
|
+
logger.info(`Using provisioning profile: ${profileSpecifier}`);
|
|
2514
|
+
return {
|
|
2515
|
+
name: profileSpecifier,
|
|
2516
|
+
teamId: teamId || "Unknown",
|
|
2517
|
+
capacity: 100,
|
|
2518
|
+
usedSlots: 0
|
|
2519
|
+
};
|
|
2520
|
+
}
|
|
2521
|
+
/**
|
|
2522
|
+
* 创建 manifest.plist 文件
|
|
2523
|
+
* @param ipaPath IPA 路径
|
|
2524
|
+
* @param version 版本号
|
|
2525
|
+
* @param bundleId Bundle ID
|
|
2526
|
+
* @param changelog 更新日志
|
|
2527
|
+
*/
|
|
2528
|
+
async createManifest(ipaPath, version, bundleId, changelog) {
|
|
2529
|
+
const ipaFileName = path4.basename(ipaPath);
|
|
2530
|
+
const appName = bundleId || path4.parse(ipaFileName).name;
|
|
2531
|
+
const ipaUrl = `itms-services://?action=download-manifest&url=https://your-distribution-server.com/manifests/${ipaFileName}.plist`;
|
|
2532
|
+
const manifestContent = `<?xml version="1.0" encoding="UTF-8"?>
|
|
2533
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2534
|
+
<plist version="1.0">
|
|
2535
|
+
<dict>
|
|
2536
|
+
<key>items</key>
|
|
2537
|
+
<array>
|
|
2538
|
+
<dict>
|
|
2539
|
+
<key>assets</key>
|
|
2540
|
+
<array>
|
|
2541
|
+
<dict>
|
|
2542
|
+
<key>kind</key>
|
|
2543
|
+
<string>software-package</string>
|
|
2544
|
+
<key>url</key>
|
|
2545
|
+
<string>https://your-distribution-server.com/apps/${ipaFileName}</string>
|
|
2546
|
+
</dict>
|
|
2547
|
+
<dict>
|
|
2548
|
+
<key>kind</key>
|
|
2549
|
+
<string>display-image</string>
|
|
2550
|
+
<key>url</key>
|
|
2551
|
+
<string>https://your-distribution-server.com/icons/app-icon.png</string>
|
|
2552
|
+
</dict>
|
|
2553
|
+
<dict>
|
|
2554
|
+
<key>kind</key>
|
|
2555
|
+
<string>full-size-image</string>
|
|
2556
|
+
<key>url</key>
|
|
2557
|
+
<string>https://your-distribution-server.com/icons/app-icon-large.png</string>
|
|
2558
|
+
</dict>
|
|
2559
|
+
</array>
|
|
2560
|
+
<key>metadata</key>
|
|
2561
|
+
<dict>
|
|
2562
|
+
<key>bundle-identifier</key>
|
|
2563
|
+
<string>${bundleId || "com.example.app"}</string>
|
|
2564
|
+
<key>bundle-version</key>
|
|
2565
|
+
<string>${version}</string>
|
|
2566
|
+
<key>kind</key>
|
|
2567
|
+
<string>software</string>
|
|
2568
|
+
<key>title</key>
|
|
2569
|
+
<string>${appName}</string>
|
|
2570
|
+
<key>subtitle</key>
|
|
2571
|
+
<string>${changelog || "New release"}</string>
|
|
2572
|
+
</dict>
|
|
2573
|
+
</dict>
|
|
2574
|
+
</array>
|
|
2575
|
+
</dict>
|
|
2576
|
+
</plist>`;
|
|
2577
|
+
const manifestPath = `${ipaPath}.plist`;
|
|
2578
|
+
await fs8.writeFile(manifestPath, manifestContent);
|
|
2579
|
+
logger.info(`Manifest created: ${manifestPath}`);
|
|
2580
|
+
return manifestPath;
|
|
2581
|
+
}
|
|
2582
|
+
/**
|
|
2583
|
+
* 设置分发目录结构
|
|
2584
|
+
* @param ipaPath IPA 路径
|
|
2585
|
+
* @param manifestPath Manifest 路径
|
|
2586
|
+
*/
|
|
2587
|
+
async setupDistributionDirectory(ipaPath, manifestPath) {
|
|
2588
|
+
const projectRoot = process.cwd();
|
|
2589
|
+
const distributionDir = path4.join(projectRoot, "build", "adhoc-distribution");
|
|
2590
|
+
await fs8.ensureDir(distributionDir);
|
|
2591
|
+
await fs8.ensureDir(path4.join(distributionDir, "apps"));
|
|
2592
|
+
await fs8.ensureDir(path4.join(distributionDir, "manifests"));
|
|
2593
|
+
await fs8.ensureDir(path4.join(distributionDir, "icons"));
|
|
2594
|
+
const ipaFileName = path4.basename(ipaPath);
|
|
2595
|
+
await fs8.copy(ipaPath, path4.join(distributionDir, "apps", ipaFileName));
|
|
2596
|
+
await fs8.copy(manifestPath, path4.join(distributionDir, "manifests", `${ipaFileName}.plist`));
|
|
2597
|
+
logger.info(`Distribution directory: ${distributionDir}`);
|
|
2598
|
+
return distributionDir;
|
|
2599
|
+
}
|
|
2600
|
+
/**
|
|
2601
|
+
* 生成安装链接 (itms-services)
|
|
2602
|
+
* @param distributionPath 分发目录
|
|
2603
|
+
* @param bundleId Bundle ID
|
|
2604
|
+
*/
|
|
2605
|
+
generateInstallUrl(distributionPath, bundleId) {
|
|
2606
|
+
const manifestPath = path4.join(distributionPath, "manifests", `*.plist`);
|
|
2607
|
+
return `itms-services://?action=download-manifest&url=file://${manifestPath}`;
|
|
2608
|
+
}
|
|
2609
|
+
/**
|
|
2610
|
+
* 生成二维码
|
|
2611
|
+
* @param url 安装链接
|
|
2612
|
+
* @param distributionPath 分发目录
|
|
2613
|
+
*/
|
|
2614
|
+
async generateQRCode(url, distributionPath) {
|
|
2615
|
+
const qrCodePath = path4.join(distributionPath, "install-qrcode.png");
|
|
2616
|
+
try {
|
|
2617
|
+
logger.info(`QR code would be saved to: ${qrCodePath}`);
|
|
2618
|
+
logger.info(`Install URL: ${url}`);
|
|
2619
|
+
return qrCodePath;
|
|
2620
|
+
} catch (error) {
|
|
2621
|
+
logger.warn(`Failed to generate QR code: ${error.message}`);
|
|
2622
|
+
return "";
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
/**
|
|
2626
|
+
* 输出分发信息
|
|
2627
|
+
* @param installUrl 安装链接
|
|
2628
|
+
* @param qrCodePath 二维码路径
|
|
2629
|
+
* @param distributionPath 分发目录
|
|
2630
|
+
* @param profileInfo Profile 信息
|
|
2631
|
+
*/
|
|
2632
|
+
outputDistributionInfo(installUrl, qrCodePath, distributionPath, profileInfo) {
|
|
2633
|
+
logger.newLine();
|
|
2634
|
+
logger.title("\u{1F4F1} Ad Hoc Distribution Setup Complete");
|
|
2635
|
+
logger.info(`App Name: ${profileInfo.name}`);
|
|
2636
|
+
logger.info(`Team ID: ${profileInfo.teamId}`);
|
|
2637
|
+
logger.info(`Device Capacity: ${profileInfo.capacity}`);
|
|
2638
|
+
logger.info(`Used Slots: ${profileInfo.usedSlots}/${profileInfo.capacity}`);
|
|
2639
|
+
logger.newLine();
|
|
2640
|
+
logger.info("\u{1F4E6} Distribution Files:");
|
|
2641
|
+
logger.info(` Directory: ${distributionPath}`);
|
|
2642
|
+
logger.info(` IPA: ${path4.join(distributionPath, "apps", "*.ipa")}`);
|
|
2643
|
+
logger.info(` Manifest: ${path4.join(distributionPath, "manifests", "*.plist")}`);
|
|
2644
|
+
logger.info(` QR Code: ${qrCodePath}`);
|
|
2645
|
+
logger.newLine();
|
|
2646
|
+
logger.info("\u{1F517} Installation Link:");
|
|
2647
|
+
logger.info(` ${installUrl}`);
|
|
2648
|
+
logger.newLine();
|
|
2649
|
+
logger.info("\u{1F4E4} Distribution Options:");
|
|
2650
|
+
logger.info(" 1. Share via\u84B2\u516C\u82F1: Upload to Pgyer for easy distribution");
|
|
2651
|
+
logger.info(" 2. Share QR code with testers");
|
|
2652
|
+
logger.info(" 3. Host manifest and IPA on your server");
|
|
2653
|
+
logger.info(" 4. Use enterprise distribution service");
|
|
2654
|
+
logger.newLine();
|
|
2655
|
+
logger.info("\u26A0\uFE0F Important:");
|
|
2656
|
+
logger.info(" \u2022 Devices must be registered in your Apple Developer account");
|
|
2657
|
+
logger.info(" \u2022 Maximum 100 devices per app, per year");
|
|
2658
|
+
logger.info(" \u2022 App must be signed with Ad Hoc provisioning profile");
|
|
2659
|
+
logger.info(" \u2022 Cannot be distributed via App Store");
|
|
2660
|
+
}
|
|
2661
|
+
/**
|
|
2662
|
+
* 检查是否安装了 Xcode
|
|
2663
|
+
* @returns 是否安装
|
|
2664
|
+
*/
|
|
2665
|
+
checkXcodeInstalled() {
|
|
2666
|
+
try {
|
|
2667
|
+
execSync("xcode-select -p", { stdio: "pipe" });
|
|
2668
|
+
return true;
|
|
2669
|
+
} catch {
|
|
2670
|
+
return false;
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
/**
|
|
2674
|
+
* 生成 Ad Hoc 导出选项 (ExportOptions.plist)
|
|
2675
|
+
* @param teamId 团队 ID
|
|
2676
|
+
* @param signingIdentity 签名证书
|
|
2677
|
+
* @param provisioningProfile Profile 名称
|
|
2678
|
+
*/
|
|
2679
|
+
generateExportOptions(teamId, signingIdentity, provisioningProfile) {
|
|
2680
|
+
const options = `<?xml version="1.0" encoding="UTF-8"?>
|
|
2681
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2682
|
+
<plist version="1.0">
|
|
2683
|
+
<dict>
|
|
2684
|
+
<key>method</key>
|
|
2685
|
+
<string>ad-hoc</string>
|
|
2686
|
+
<key>signingStyle</key>
|
|
2687
|
+
<string>automatic</string>
|
|
2688
|
+
<key>stripSwiftSymbols</key>
|
|
2689
|
+
<true/>
|
|
2690
|
+
<key>teamID</key>
|
|
2691
|
+
<string>${teamId || ""}</string>
|
|
2692
|
+
${signingIdentity ? `<key>signingCertificate</key><string>${signingIdentity}</string>` : ""}
|
|
2693
|
+
${provisioningProfile ? `<key>provisioningProfileSpecifier</key><string>${provisioningProfile}</string>` : ""}
|
|
2694
|
+
</dict>
|
|
2695
|
+
</plist>`;
|
|
2696
|
+
return options;
|
|
2697
|
+
}
|
|
2698
|
+
};
|
|
2699
|
+
|
|
2700
|
+
// src/adapters/harmony_uploader.ts
|
|
2701
|
+
import fs9 from "fs-extra";
|
|
2702
|
+
import path5 from "path";
|
|
2703
|
+
import axios6 from "axios";
|
|
2704
|
+
import FormData5 from "form-data";
|
|
2705
|
+
var HarmonyUploader = class _HarmonyUploader {
|
|
2706
|
+
name = "HarmonyOS";
|
|
2707
|
+
static TOKEN_URL = "https://connect-api.cloud.huawei.com/api/oauth2/v1/token";
|
|
2708
|
+
static PUBLISH_API_ROOT = "https://connect-api.cloud.huawei.com/api/publish/v2";
|
|
2709
|
+
validateConfig(config) {
|
|
2710
|
+
if (!config?.publish?.harmony?.enable) return false;
|
|
2711
|
+
const h = config.publish.harmony;
|
|
2712
|
+
if (!h.auth?.clientId || !h.auth?.clientSecret || !h.appId) {
|
|
2713
|
+
logger.error("Harmony config missing: clientId, clientSecret, or appId");
|
|
2714
|
+
return false;
|
|
2715
|
+
}
|
|
2716
|
+
return true;
|
|
2717
|
+
}
|
|
2718
|
+
/**
|
|
2719
|
+
* 判断是否支持该文件
|
|
2720
|
+
* @param filePath 文件路径
|
|
2721
|
+
*/
|
|
2722
|
+
canHandle(filePath) {
|
|
2723
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
2724
|
+
return ext === "hap" || ext === "app";
|
|
2725
|
+
}
|
|
2726
|
+
async upload(filePath, config, metadata) {
|
|
2727
|
+
const harmonyConfig = config.publish.harmony;
|
|
2728
|
+
const clientId = harmonyConfig.auth.clientId;
|
|
2729
|
+
const clientSecret = harmonyConfig.auth.clientSecret;
|
|
2730
|
+
const appId = harmonyConfig.appId;
|
|
2731
|
+
const releaseType = harmonyConfig.releaseType || "draft";
|
|
2732
|
+
const suffix = path5.extname(filePath).substring(1);
|
|
2733
|
+
const retryConfig = config.retry;
|
|
2734
|
+
logger.info("Starting HarmonyOS AppGallery upload...");
|
|
2735
|
+
try {
|
|
2736
|
+
const retryStrategy = createUploadRetryStrategy(retryConfig);
|
|
2737
|
+
logger.info("Authenticating...");
|
|
2738
|
+
const tokenResult = await retryStrategy.execute(
|
|
2739
|
+
() => this.getAccessToken(clientId, clientSecret),
|
|
2740
|
+
"Harmony authentication"
|
|
2741
|
+
);
|
|
2742
|
+
if (!tokenResult.success || !tokenResult.data) {
|
|
2743
|
+
throw tokenResult.error || new Error("Failed to get access token");
|
|
2744
|
+
}
|
|
2745
|
+
const token = tokenResult.data;
|
|
2746
|
+
logger.info("Requesting upload URL...");
|
|
2747
|
+
const uploadInfoResult = await retryStrategy.execute(
|
|
2748
|
+
() => this.getUploadUrl(token, appId, suffix),
|
|
2749
|
+
"Harmony get upload URL"
|
|
2750
|
+
);
|
|
2751
|
+
if (!uploadInfoResult.success || !uploadInfoResult.data) {
|
|
2752
|
+
throw uploadInfoResult.error || new Error("Failed to get upload URL");
|
|
2753
|
+
}
|
|
2754
|
+
const uploadInfo = uploadInfoResult.data;
|
|
2755
|
+
logger.info("Uploading HarmonyOS package...");
|
|
2756
|
+
const uploadFileResult = await retryStrategy.execute(
|
|
2757
|
+
() => this.uploadFile(uploadInfo.uploadUrl, uploadInfo.authCode, filePath),
|
|
2758
|
+
"Harmony file upload"
|
|
2759
|
+
);
|
|
2760
|
+
if (!uploadFileResult.success) {
|
|
2761
|
+
throw uploadFileResult.error || new Error("File upload failed");
|
|
2762
|
+
}
|
|
2763
|
+
logger.info("Linking file to app...");
|
|
2764
|
+
const updateInfoResult = await retryStrategy.execute(
|
|
2765
|
+
() => this.updateAppFileInfo(token, appId, uploadInfo.authCode, filePath),
|
|
2766
|
+
"Harmony update file info"
|
|
2767
|
+
);
|
|
2768
|
+
if (!updateInfoResult.success) {
|
|
2769
|
+
throw updateInfoResult.error || new Error("Failed to update file info");
|
|
2770
|
+
}
|
|
2771
|
+
if (releaseType === "submit") {
|
|
2772
|
+
logger.info("Submitting to review...");
|
|
2773
|
+
}
|
|
2774
|
+
logger.success("HarmonyOS upload successful!");
|
|
2775
|
+
return {
|
|
2776
|
+
platform: "harmony",
|
|
2777
|
+
success: true,
|
|
2778
|
+
message: "Upload success",
|
|
2779
|
+
url: "https://developer.huawei.com/consumer/cn/service/josp/agc/index.html"
|
|
2780
|
+
};
|
|
2781
|
+
} catch (error) {
|
|
2782
|
+
logger.error(`HarmonyOS upload failed: ${error.message}`);
|
|
2783
|
+
return {
|
|
2784
|
+
platform: "harmony",
|
|
2785
|
+
success: false,
|
|
2786
|
+
message: error.message,
|
|
2787
|
+
error
|
|
2788
|
+
};
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
/**
|
|
2792
|
+
* 获取 Access Token
|
|
2793
|
+
* @param clientId 客户端 ID
|
|
2794
|
+
* @param clientSecret 客户端密钥
|
|
2795
|
+
*/
|
|
2796
|
+
async getAccessToken(clientId, clientSecret) {
|
|
2797
|
+
const data = {
|
|
2798
|
+
client_id: clientId,
|
|
2799
|
+
client_secret: clientSecret,
|
|
2800
|
+
grant_type: "client_credentials"
|
|
2801
|
+
};
|
|
2802
|
+
const res = await axios6.post(_HarmonyUploader.TOKEN_URL, data);
|
|
2803
|
+
if (!res.data.access_token) {
|
|
2804
|
+
throw new Error("Failed to get access token");
|
|
2805
|
+
}
|
|
2806
|
+
return res.data.access_token;
|
|
2807
|
+
}
|
|
2808
|
+
/**
|
|
2809
|
+
* 获取上传 URL
|
|
2810
|
+
* @param token Token
|
|
2811
|
+
* @param appId 应用 ID
|
|
2812
|
+
* @param suffix 后缀
|
|
2813
|
+
*/
|
|
2814
|
+
async getUploadUrl(token, appId, suffix) {
|
|
2815
|
+
const res = await axios6.get(`${_HarmonyUploader.PUBLISH_API_ROOT}/upload-url`, {
|
|
2816
|
+
params: { appId, suffix },
|
|
2817
|
+
headers: { Authorization: `Bearer ${token}`, client_id: appId }
|
|
2818
|
+
});
|
|
2819
|
+
if (res.data.code != "0") {
|
|
2820
|
+
throw new Error(`Get upload url failed: ${res.data.msg}`);
|
|
2821
|
+
}
|
|
2822
|
+
return {
|
|
2823
|
+
uploadUrl: res.data.uploadUrl,
|
|
2824
|
+
authCode: res.data.authCode
|
|
2825
|
+
};
|
|
2826
|
+
}
|
|
2827
|
+
async uploadFile(uploadUrl, authCode, filePath) {
|
|
2828
|
+
const form = new FormData5();
|
|
2829
|
+
form.append("file", fs9.createReadStream(filePath));
|
|
2830
|
+
form.append("authCode", authCode);
|
|
2831
|
+
form.append("fileCount", "1");
|
|
2832
|
+
const res = await axios6.post(uploadUrl, form, {
|
|
2833
|
+
headers: { ...form.getHeaders() },
|
|
2834
|
+
maxContentLength: Infinity,
|
|
2835
|
+
maxBodyLength: Infinity
|
|
2836
|
+
});
|
|
2837
|
+
if (res.data.result.resultCode != "0") {
|
|
2838
|
+
throw new Error(`File upload failed: ${JSON.stringify(res.data.result)}`);
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
/**
|
|
2842
|
+
* 更新应用文件信息
|
|
2843
|
+
* @param token Token
|
|
2844
|
+
* @param appId 应用 ID
|
|
2845
|
+
* @param authCode 授权码
|
|
2846
|
+
* @param filePath 文件路径
|
|
2847
|
+
*/
|
|
2848
|
+
async updateAppFileInfo(token, appId, authCode, filePath) {
|
|
2849
|
+
const fileName = path5.basename(filePath);
|
|
2850
|
+
const body = {
|
|
2851
|
+
fileType: 7,
|
|
2852
|
+
// 鸿蒙包的 fileType(需要根据华为最新文档确认)
|
|
2853
|
+
files: [
|
|
2854
|
+
{
|
|
2855
|
+
fileName,
|
|
2856
|
+
fileDestUrl: authCode
|
|
2857
|
+
}
|
|
2858
|
+
]
|
|
2859
|
+
};
|
|
2860
|
+
const res = await axios6.put(`${_HarmonyUploader.PUBLISH_API_ROOT}/app-file-info`, body, {
|
|
2861
|
+
params: { appId },
|
|
2862
|
+
headers: { Authorization: `Bearer ${token}`, client_id: appId }
|
|
2863
|
+
});
|
|
2864
|
+
if (res.data.ret.code != 0) {
|
|
2865
|
+
throw new Error(`Update file info failed: ${res.data.ret.msg}`);
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
};
|
|
2869
|
+
|
|
2870
|
+
// src/adapters/oppo_uploader.ts
|
|
2871
|
+
import fs10 from "fs-extra";
|
|
2872
|
+
import path6 from "path";
|
|
2873
|
+
import axios7 from "axios";
|
|
2874
|
+
import FormData6 from "form-data";
|
|
2875
|
+
import crypto4 from "crypto";
|
|
2876
|
+
var OppoUploader = class _OppoUploader {
|
|
2877
|
+
name = "OPPO";
|
|
2878
|
+
/** OPPO 开放 API 基础地址 */
|
|
2879
|
+
static API_BASE = "https://oop-openapi-cn.heytapmobi.com";
|
|
2880
|
+
/** 获取 token 接口 */
|
|
2881
|
+
static TOKEN_API = `${_OppoUploader.API_BASE}/developer/v1/token`;
|
|
2882
|
+
/** 获取上传 URL 接口 */
|
|
2883
|
+
static UPLOAD_URL_API = `${_OppoUploader.API_BASE}/resource/v1/upload/get-upload-url`;
|
|
2884
|
+
/** 上传完成后提交版本 */
|
|
2885
|
+
static SUBMIT_API = `${_OppoUploader.API_BASE}/resource/v1/app/upd`;
|
|
2886
|
+
/** 查询应详情接口 */
|
|
2887
|
+
static APP_INFO_API = `${_OppoUploader.API_BASE}/resource/v1/app/info`;
|
|
2888
|
+
/**
|
|
2889
|
+
* OPPO API 签名生成
|
|
2890
|
+
* OPPO 使用 HMAC-SHA256 签名机制
|
|
2891
|
+
*/
|
|
2892
|
+
generateSign(params, clientSecret) {
|
|
2893
|
+
const sorted = Object.keys(params).sort().filter((key) => params[key] !== void 0 && params[key] !== null).map((key) => `${key}=${params[key]}`).join("&");
|
|
2894
|
+
return crypto4.createHmac("sha256", clientSecret).update(sorted).digest("hex");
|
|
2895
|
+
}
|
|
2896
|
+
validateConfig(config) {
|
|
2897
|
+
if (!config?.publish?.oppo?.enable) return false;
|
|
2898
|
+
const oppo = config.publish.oppo;
|
|
2899
|
+
if (!oppo.auth?.clientId || !oppo.auth?.clientSecret) {
|
|
2900
|
+
logger.error("OPPO config missing: clientId or clientSecret");
|
|
2901
|
+
return false;
|
|
2902
|
+
}
|
|
2903
|
+
return true;
|
|
2904
|
+
}
|
|
2905
|
+
canHandle(filePath) {
|
|
2906
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
2907
|
+
return ext === "apk";
|
|
2908
|
+
}
|
|
2909
|
+
/**
|
|
2910
|
+
* 尝试从文件名推断包名
|
|
2911
|
+
*/
|
|
2912
|
+
inferPackageName(filePath) {
|
|
2913
|
+
const fileName = path6.basename(filePath);
|
|
2914
|
+
const match = fileName.match(/^([\w.]+?)(?:-(?:release|debug|unsigned))?\.apk$/i);
|
|
2915
|
+
if (match && match[1].includes(".")) {
|
|
2916
|
+
return match[1];
|
|
2917
|
+
}
|
|
2918
|
+
return null;
|
|
2919
|
+
}
|
|
2920
|
+
/**
|
|
2921
|
+
* 从包名推断应用名称
|
|
2922
|
+
*/
|
|
2923
|
+
inferAppName(packageName) {
|
|
2924
|
+
const parts = packageName.split(".");
|
|
2925
|
+
if (parts.length === 0) return null;
|
|
2926
|
+
const lastPart = parts[parts.length - 1];
|
|
2927
|
+
return lastPart.charAt(0).toUpperCase() + lastPart.slice(1);
|
|
2928
|
+
}
|
|
2929
|
+
async upload(filePath, config, metadata) {
|
|
2930
|
+
const oppoConfig = config.publish.oppo;
|
|
2931
|
+
const clientId = oppoConfig.auth.clientId;
|
|
2932
|
+
const clientSecret = oppoConfig.auth.clientSecret;
|
|
2933
|
+
const packageName = oppoConfig.packageName || this.inferPackageName(filePath);
|
|
2934
|
+
if (!packageName) {
|
|
2935
|
+
return {
|
|
2936
|
+
platform: "oppo",
|
|
2937
|
+
success: false,
|
|
2938
|
+
message: "\u65E0\u6CD5\u83B7\u53D6\u5305\u540D (packageName)\uFF0C\u8BF7\u5728\u914D\u7F6E\u4E2D\u6307\u5B9A\u6216\u786E\u4FDD\u6587\u4EF6\u540D\u5305\u542B\u5305\u540D",
|
|
2939
|
+
consoleUrl: "https://open.oppomobile.com/new/ecology/app"
|
|
2940
|
+
};
|
|
2941
|
+
}
|
|
2942
|
+
const retryConfig = config.retry;
|
|
2943
|
+
const consoleUrl = "https://open.oppomobile.com/new/ecology/app";
|
|
2944
|
+
if (!fs10.existsSync(filePath)) {
|
|
2945
|
+
return {
|
|
2946
|
+
platform: "oppo",
|
|
2947
|
+
success: false,
|
|
2948
|
+
message: `\u6587\u4EF6\u4E0D\u5B58\u5728: ${filePath}`,
|
|
2949
|
+
consoleUrl
|
|
2950
|
+
};
|
|
2951
|
+
}
|
|
2952
|
+
logger.info("\u5F00\u59CB\u4E0A\u4F20\u5230 OPPO \u5E94\u7528\u5546\u5E97...");
|
|
2953
|
+
logger.info(`\u6587\u4EF6: ${filePath}`);
|
|
2954
|
+
try {
|
|
2955
|
+
const retryStrategy = createUploadRetryStrategy(retryConfig);
|
|
2956
|
+
logger.info("\u6B63\u5728\u83B7\u53D6 access_token...");
|
|
2957
|
+
const tokenResult = await retryStrategy.execute(
|
|
2958
|
+
() => this.getAccessToken(clientId, clientSecret),
|
|
2959
|
+
"OPPO authentication"
|
|
2960
|
+
);
|
|
2961
|
+
if (!tokenResult.success || !tokenResult.data) {
|
|
2962
|
+
throw tokenResult.error || new Error("Failed to get access token");
|
|
2963
|
+
}
|
|
2964
|
+
const accessToken = tokenResult.data;
|
|
2965
|
+
logger.info("\u6B63\u5728\u83B7\u53D6\u73B0\u6709\u5E94\u7528\u8BE6\u60C5...");
|
|
2966
|
+
const appInfoResult = await retryStrategy.execute(
|
|
2967
|
+
() => this.getAppInfo(accessToken, clientSecret, packageName),
|
|
2968
|
+
"OPPO get app info"
|
|
2969
|
+
);
|
|
2970
|
+
const onlineAppInfo = appInfoResult.data || {};
|
|
2971
|
+
if (onlineAppInfo) {
|
|
2972
|
+
logger.info(
|
|
2973
|
+
`\u83B7\u53D6\u5230\u7EBF\u4E0A\u4FE1\u606F: ${onlineAppInfo.app_name} (Category: ${onlineAppInfo.second_category_id})`
|
|
2974
|
+
);
|
|
2975
|
+
logger.info(`[OPPO AppInfo Full] ${JSON.stringify(onlineAppInfo)}`);
|
|
2976
|
+
}
|
|
2977
|
+
logger.info("\u6B63\u5728\u83B7\u53D6\u4E0A\u4F20\u5730\u5740...");
|
|
2978
|
+
const uploadUrlResult = await retryStrategy.execute(
|
|
2979
|
+
() => this.getUploadUrl(accessToken, clientSecret),
|
|
2980
|
+
"OPPO get upload URL"
|
|
2981
|
+
);
|
|
2982
|
+
if (!uploadUrlResult.success || !uploadUrlResult.data) {
|
|
2983
|
+
throw uploadUrlResult.error || new Error("Failed to get upload URL");
|
|
2984
|
+
}
|
|
2985
|
+
const { uploadUrl, sign: uploadSign } = uploadUrlResult.data;
|
|
2986
|
+
logger.info("\u6B63\u5728\u4E0A\u4F20 APK \u6587\u4EF6...");
|
|
2987
|
+
const uploadFileResult = await retryStrategy.execute(
|
|
2988
|
+
() => this.uploadFile(uploadUrl, uploadSign, filePath),
|
|
2989
|
+
"OPPO file upload"
|
|
2990
|
+
);
|
|
2991
|
+
if (!uploadFileResult.success || !uploadFileResult.data) {
|
|
2992
|
+
throw uploadFileResult.error || new Error("File upload failed");
|
|
2993
|
+
}
|
|
2994
|
+
const rawUrl = uploadFileResult.data.url;
|
|
2995
|
+
const fileUrl = rawUrl.replace(/^http:\/\//i, "https://").replace("storedl1.nearme.com.cn", "storedl.nearme.com.cn");
|
|
2996
|
+
const md5 = uploadFileResult.data.md5;
|
|
2997
|
+
logger.info("\u6B63\u5728\u63D0\u4EA4\u7248\u672C\u4FE1\u606F...");
|
|
2998
|
+
const submitResult = await retryStrategy.execute(
|
|
2999
|
+
() => this.submitVersion({
|
|
3000
|
+
accessToken,
|
|
3001
|
+
clientSecret,
|
|
3002
|
+
packageName,
|
|
3003
|
+
appName: oppoConfig.appName || metadata.appName || onlineAppInfo.app_name || this.inferAppName(packageName),
|
|
3004
|
+
fileUrl,
|
|
3005
|
+
fileMd5: md5,
|
|
3006
|
+
versionName: metadata.version,
|
|
3007
|
+
versionCode: metadata.buildNumber,
|
|
3008
|
+
updateDesc: metadata.changelog || "App Update",
|
|
3009
|
+
onlineType: oppoConfig.onlineType,
|
|
3010
|
+
// 从配置读取
|
|
3011
|
+
onlineAppInfo
|
|
3012
|
+
// 传入线上信息
|
|
3013
|
+
}),
|
|
3014
|
+
"OPPO submit version"
|
|
3015
|
+
);
|
|
3016
|
+
if (!submitResult.success) {
|
|
3017
|
+
throw submitResult.error || new Error("Submit version failed");
|
|
3018
|
+
}
|
|
3019
|
+
logger.success("\u4E0A\u4F20\u5230 OPPO \u5E94\u7528\u5546\u5E97\u6210\u529F!");
|
|
3020
|
+
return {
|
|
3021
|
+
platform: "oppo",
|
|
3022
|
+
success: true,
|
|
3023
|
+
message: "\u4E0A\u4F20\u6210\u529F, \u8BF7\u524D\u5F80\u540E\u53F0\u786E\u8BA4\u5E76\u63D0\u4EA4\u5BA1\u6838",
|
|
3024
|
+
consoleUrl
|
|
3025
|
+
};
|
|
3026
|
+
} catch (error) {
|
|
3027
|
+
logger.error(`\u4E0A\u4F20\u5230 OPPO \u5931\u8D25: ${error.message}`);
|
|
3028
|
+
return {
|
|
3029
|
+
platform: "oppo",
|
|
3030
|
+
success: false,
|
|
3031
|
+
message: this.formatError(error),
|
|
3032
|
+
error,
|
|
3033
|
+
consoleUrl
|
|
3034
|
+
};
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
/**
|
|
3038
|
+
* 获取 access_token
|
|
3039
|
+
* 官方文档: https://open.oppomobile.com/documentation/page/info?id=10998
|
|
3040
|
+
*/
|
|
3041
|
+
async getAccessToken(clientId, clientSecret) {
|
|
3042
|
+
const params = {
|
|
3043
|
+
client_id: clientId,
|
|
3044
|
+
client_secret: clientSecret
|
|
3045
|
+
};
|
|
3046
|
+
logger.info(`[OPPO Auth Debug] clientId: ${clientId}`);
|
|
3047
|
+
logger.info(`[OPPO Auth Debug] clientSecret length: ${clientSecret?.length || 0}`);
|
|
3048
|
+
logger.info(`[OPPO Auth Debug] params: ${JSON.stringify(params)}`);
|
|
3049
|
+
const res = await axios7.get(_OppoUploader.TOKEN_API, {
|
|
3050
|
+
params,
|
|
3051
|
+
timeout: 15e3
|
|
3052
|
+
});
|
|
3053
|
+
logger.info(`[OPPO Auth Response] status: ${res.status}, data: ${JSON.stringify(res.data)}`);
|
|
3054
|
+
if (res.data.errno !== 0) {
|
|
3055
|
+
throw new Error(`\u83B7\u53D6 token \u5931\u8D25: ${res.data.data?.message || JSON.stringify(res.data)}`);
|
|
3056
|
+
}
|
|
3057
|
+
return res.data.data.access_token;
|
|
3058
|
+
}
|
|
3059
|
+
/**
|
|
3060
|
+
* 获取应用详情
|
|
3061
|
+
*/
|
|
3062
|
+
async getAppInfo(accessToken, clientSecret, packageName) {
|
|
3063
|
+
const timestamp = Math.floor(Date.now() / 1e3);
|
|
3064
|
+
const params = {
|
|
3065
|
+
access_token: accessToken,
|
|
3066
|
+
pkg_name: packageName,
|
|
3067
|
+
timestamp
|
|
3068
|
+
};
|
|
3069
|
+
const apiSign = this.generateSign(params, clientSecret);
|
|
3070
|
+
try {
|
|
3071
|
+
const res = await axios7.get(_OppoUploader.APP_INFO_API, {
|
|
3072
|
+
params: {
|
|
3073
|
+
...params,
|
|
3074
|
+
api_sign: apiSign
|
|
3075
|
+
},
|
|
3076
|
+
timeout: 15e3
|
|
3077
|
+
});
|
|
3078
|
+
if (res.data.errno !== 0) {
|
|
3079
|
+
logger.warn(`\u83B7\u53D6\u5E94\u7528\u8BE6\u60C5\u5931\u8D25 (\u975E\u81F4\u547D): ${JSON.stringify(res.data)}`);
|
|
3080
|
+
return null;
|
|
3081
|
+
}
|
|
3082
|
+
return res.data.data;
|
|
3083
|
+
} catch (error) {
|
|
3084
|
+
logger.warn(`\u83B7\u53D6\u5E94\u7528\u8BE6\u60C5\u8BF7\u6C42\u5F02\u5E38 (\u975E\u81F4\u547D): ${error.message}`);
|
|
3085
|
+
return null;
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
/**
|
|
3089
|
+
* 获取上传地址
|
|
3090
|
+
*/
|
|
3091
|
+
async getUploadUrl(accessToken, clientSecret) {
|
|
3092
|
+
const timestamp = Math.floor(Date.now() / 1e3);
|
|
3093
|
+
const params = {
|
|
3094
|
+
access_token: accessToken,
|
|
3095
|
+
timestamp
|
|
3096
|
+
};
|
|
3097
|
+
const apiSign = this.generateSign(params, clientSecret);
|
|
3098
|
+
const res = await axios7.get(_OppoUploader.UPLOAD_URL_API, {
|
|
3099
|
+
params: {
|
|
3100
|
+
...params,
|
|
3101
|
+
api_sign: apiSign
|
|
3102
|
+
},
|
|
3103
|
+
timeout: 15e3
|
|
3104
|
+
});
|
|
3105
|
+
if (res.data.errno !== 0) {
|
|
3106
|
+
throw new Error(`\u83B7\u53D6\u4E0A\u4F20\u5730\u5740\u5931\u8D25: ${res.data.data?.message || JSON.stringify(res.data)}`);
|
|
3107
|
+
}
|
|
3108
|
+
return {
|
|
3109
|
+
uploadUrl: res.data.data.upload_url,
|
|
3110
|
+
sign: res.data.data.sign
|
|
3111
|
+
};
|
|
3112
|
+
}
|
|
3113
|
+
/**
|
|
3114
|
+
* 上传 APK 文件
|
|
3115
|
+
*/
|
|
3116
|
+
async uploadFile(uploadUrl, sign, filePath) {
|
|
3117
|
+
const form = new FormData6();
|
|
3118
|
+
form.append("file", fs10.createReadStream(filePath));
|
|
3119
|
+
form.append("sign", sign);
|
|
3120
|
+
form.append("type", "apk");
|
|
3121
|
+
const res = await axios7.post(uploadUrl, form, {
|
|
3122
|
+
headers: form.getHeaders(),
|
|
3123
|
+
maxContentLength: Infinity,
|
|
3124
|
+
maxBodyLength: Infinity,
|
|
3125
|
+
timeout: 3e5
|
|
3126
|
+
// 5 分钟超时
|
|
3127
|
+
});
|
|
3128
|
+
if (res.data.errno !== 0) {
|
|
3129
|
+
throw new Error(`\u6587\u4EF6\u4E0A\u4F20\u5931\u8D25: ${res.data.data?.message || JSON.stringify(res.data)}`);
|
|
3130
|
+
}
|
|
3131
|
+
return {
|
|
3132
|
+
url: res.data.data.url,
|
|
3133
|
+
md5: res.data.data.md5
|
|
3134
|
+
};
|
|
3135
|
+
}
|
|
3136
|
+
/**
|
|
3137
|
+
* 提交版本信息
|
|
3138
|
+
*/
|
|
3139
|
+
async submitVersion(params) {
|
|
3140
|
+
logger.info(`[OPPO Submit Params] ${JSON.stringify(params)}`);
|
|
3141
|
+
const timestamp = Math.floor(Date.now() / 1e3);
|
|
3142
|
+
const online = params.onlineAppInfo || {};
|
|
3143
|
+
let vCode = params.versionCode;
|
|
3144
|
+
if (!vCode || vCode === "0" || vCode === "") {
|
|
3145
|
+
const parts = params.versionName.split("+");
|
|
3146
|
+
if (parts.length > 1) {
|
|
3147
|
+
vCode = parts[1];
|
|
3148
|
+
} else {
|
|
3149
|
+
vCode = "1";
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
3152
|
+
const signParams = {
|
|
3153
|
+
access_token: params.accessToken,
|
|
3154
|
+
timestamp,
|
|
3155
|
+
// OPPO 官方要求 apk_url 为 JSON 数组格式:[{url, md5, cpu_code}]
|
|
3156
|
+
// cpu_code: 0=通用包, 3=arm64-v8a, 4=armeabi-v7a, 5=x86_64
|
|
3157
|
+
apk_url: JSON.stringify([
|
|
3158
|
+
{
|
|
3159
|
+
url: params.fileUrl,
|
|
3160
|
+
md5: params.fileMd5,
|
|
3161
|
+
cpu_code: 0
|
|
3162
|
+
// 通用包
|
|
3163
|
+
}
|
|
3164
|
+
]),
|
|
3165
|
+
version_name: params.versionName.split("+")[0],
|
|
3166
|
+
// 仅保留版本名部分
|
|
3167
|
+
version_code: vCode,
|
|
3168
|
+
update_desc: params.updateDesc,
|
|
3169
|
+
pkg_name: params.packageName,
|
|
3170
|
+
app_name: params.appName,
|
|
3171
|
+
// 补充 OPPO 提交审核的必填项 (优先复用线上数据)
|
|
3172
|
+
summary: online.summary || params.updateDesc.slice(0, 15),
|
|
3173
|
+
detail_desc: online.detail_desc || (params.updateDesc.length >= 20 ? params.updateDesc : `${params.updateDesc}, \u6B64\u7248\u672C\u4F18\u5316\u4E86\u7528\u6237\u4F53\u9A8C\u5E76\u4FEE\u590D\u4E86\u5DF2\u77E5\u95EE\u9898\u3002`),
|
|
3174
|
+
privacy_source_url: online.privacy_source_url || `https://open.oppomobile.com/privacy/${params.packageName}`
|
|
3175
|
+
};
|
|
3176
|
+
const mapField = (key, onlineKey) => {
|
|
3177
|
+
const val = online[onlineKey || key];
|
|
3178
|
+
if (val !== void 0 && val !== null && val !== "") {
|
|
3179
|
+
signParams[key] = val;
|
|
3180
|
+
}
|
|
3181
|
+
};
|
|
3182
|
+
mapField("first_category_id");
|
|
3183
|
+
mapField("second_category_id");
|
|
3184
|
+
mapField("third_category_id");
|
|
3185
|
+
if (params.onlineType) {
|
|
3186
|
+
signParams.online_type = params.onlineType;
|
|
3187
|
+
} else {
|
|
3188
|
+
mapField("online_type");
|
|
3189
|
+
}
|
|
3190
|
+
if (!signParams.online_type) {
|
|
3191
|
+
signParams.online_type = "1";
|
|
3192
|
+
}
|
|
3193
|
+
mapField("version_type");
|
|
3194
|
+
mapField("adaptive_equipment");
|
|
3195
|
+
{
|
|
3196
|
+
const val = online["adaptive_type"];
|
|
3197
|
+
if (val !== void 0 && val !== null && val !== "" && String(val) !== "0") {
|
|
3198
|
+
signParams.adaptive_type = val;
|
|
3199
|
+
}
|
|
3200
|
+
}
|
|
3201
|
+
mapField("test_desc");
|
|
3202
|
+
if (!signParams.test_desc) {
|
|
3203
|
+
signParams.test_desc = "\u65E0\u7279\u6B8A\u6D4B\u8BD5\u8BF4\u660E";
|
|
3204
|
+
}
|
|
3205
|
+
mapField("business_username");
|
|
3206
|
+
mapField("business_email");
|
|
3207
|
+
mapField("business_mobile");
|
|
3208
|
+
mapField("customer_contact");
|
|
3209
|
+
if (!signParams.business_username) signParams.business_username = "\u5F00\u53D1\u8D1F\u8D23\u4EBA";
|
|
3210
|
+
if (!signParams.business_email) signParams.business_email = "developer@example.com";
|
|
3211
|
+
if (!signParams.business_mobile) signParams.business_mobile = "13800138000";
|
|
3212
|
+
mapField("copyright_url");
|
|
3213
|
+
mapField("age_level");
|
|
3214
|
+
if (!signParams.age_level) {
|
|
3215
|
+
signParams.age_level = "18";
|
|
3216
|
+
}
|
|
3217
|
+
mapField("icon_url");
|
|
3218
|
+
if (online.pic_url_material && Array.isArray(online.pic_url_material)) {
|
|
3219
|
+
const MAX_SIZE = 1 * 1024 * 1024;
|
|
3220
|
+
const validPicUrls = online.pic_url_material.filter((item) => {
|
|
3221
|
+
return !item.size || Number(item.size) <= MAX_SIZE;
|
|
3222
|
+
}).map((item) => item.url).filter(Boolean);
|
|
3223
|
+
if (validPicUrls.length > 0) {
|
|
3224
|
+
signParams.pic_url = validPicUrls.join(",");
|
|
3225
|
+
} else {
|
|
3226
|
+
mapField("pic_url");
|
|
3227
|
+
}
|
|
3228
|
+
} else {
|
|
3229
|
+
mapField("pic_url");
|
|
3230
|
+
}
|
|
3231
|
+
mapField("is_safe_check");
|
|
3232
|
+
const apiSign = this.generateSign(signParams, params.clientSecret);
|
|
3233
|
+
const data = new URLSearchParams();
|
|
3234
|
+
Object.entries(signParams).forEach(([key, value]) => {
|
|
3235
|
+
data.append(key, String(value));
|
|
3236
|
+
});
|
|
3237
|
+
data.append("api_sign", apiSign);
|
|
3238
|
+
logger.info(`[OPPO Submit Debug] URL: ${_OppoUploader.SUBMIT_API}`);
|
|
3239
|
+
logger.info(`[OPPO Submit Debug] Body: ${data.toString()}`);
|
|
3240
|
+
const res = await axios7.post(_OppoUploader.SUBMIT_API, data.toString(), {
|
|
3241
|
+
headers: {
|
|
3242
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
3243
|
+
},
|
|
3244
|
+
timeout: 3e4
|
|
3245
|
+
});
|
|
3246
|
+
logger.info(`[OPPO Submit Response] status: ${res.status}, data: ${JSON.stringify(res.data)}`);
|
|
3247
|
+
if (res.data.errno !== 0) {
|
|
3248
|
+
throw new Error(`\u63D0\u4EA4\u7248\u672C\u5931\u8D25: ${res.data.data?.message || JSON.stringify(res.data)}`);
|
|
3249
|
+
}
|
|
3250
|
+
logger.info("\u7248\u672C\u4FE1\u606F\u5DF2\u63D0\u4EA4");
|
|
3251
|
+
}
|
|
3252
|
+
/**
|
|
3253
|
+
* 格式化错误信息
|
|
3254
|
+
*/
|
|
3255
|
+
formatError(error) {
|
|
3256
|
+
if (error.response?.data) {
|
|
3257
|
+
const data = error.response.data;
|
|
3258
|
+
return data.data?.message || data.message || error.message;
|
|
3259
|
+
}
|
|
3260
|
+
return error.message;
|
|
3261
|
+
}
|
|
3262
|
+
};
|
|
3263
|
+
|
|
3264
|
+
// src/adapters/vivo_uploader.ts
|
|
3265
|
+
import fs11 from "fs-extra";
|
|
3266
|
+
import path7 from "path";
|
|
3267
|
+
import axios8 from "axios";
|
|
3268
|
+
import FormData7 from "form-data";
|
|
3269
|
+
import crypto5 from "crypto";
|
|
3270
|
+
var VivoUploader = class _VivoUploader {
|
|
3271
|
+
name = "vivo";
|
|
3272
|
+
/** vivo 开放 API 基础地址 */
|
|
3273
|
+
static API_BASE = "https://developer-api.vivo.com.cn/router/rest";
|
|
3274
|
+
/**
|
|
3275
|
+
* vivo API 签名生成
|
|
3276
|
+
* 规则: 所有参数按 ASCII 码排序后拼接为 key=val&... 字符串,
|
|
3277
|
+
* 以 accessSecret 为密钥进行 HMAC-SHA256 加密,结果转为小写 16 进制字符串
|
|
3278
|
+
*/
|
|
3279
|
+
generateSign(params, accessSecret) {
|
|
3280
|
+
const sorted = Object.keys(params).sort().filter((key) => params[key] !== void 0 && params[key] !== null).map((key) => `${key}=${params[key]}`).join("&");
|
|
3281
|
+
const sign = crypto5.createHmac("sha256", accessSecret).update(sorted).digest("hex");
|
|
3282
|
+
logger.info(`[VIVO DEBUG] \u5F85\u7B7E\u540D\u4E32: ${sorted}
|
|
3283
|
+
-> \u7B7E\u540D\u7ED3\u679C: ${sign}`);
|
|
3284
|
+
return sign;
|
|
3285
|
+
}
|
|
3286
|
+
validateConfig(config) {
|
|
3287
|
+
if (!config?.publish?.vivo?.enable) return false;
|
|
3288
|
+
const vivo = config.publish.vivo;
|
|
3289
|
+
if (!vivo.auth?.accessKey || !vivo.auth?.accessSecret) {
|
|
3290
|
+
logger.error("vivo config missing: accessKey or accessSecret");
|
|
3291
|
+
return false;
|
|
3292
|
+
}
|
|
3293
|
+
return true;
|
|
3294
|
+
}
|
|
3295
|
+
canHandle(filePath) {
|
|
3296
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
3297
|
+
return ext === "apk";
|
|
3298
|
+
}
|
|
3299
|
+
async upload(filePath, config, metadata) {
|
|
3300
|
+
const vivoConfig = config.publish.vivo;
|
|
3301
|
+
const accessKey = vivoConfig.auth.accessKey;
|
|
3302
|
+
const accessSecret = vivoConfig.auth.accessSecret;
|
|
3303
|
+
const retryConfig = config.retry;
|
|
3304
|
+
const consoleUrl = "https://dev.vivo.com.cn/manage/app";
|
|
3305
|
+
if (!fs11.existsSync(filePath)) {
|
|
3306
|
+
return {
|
|
3307
|
+
platform: "vivo",
|
|
3308
|
+
success: false,
|
|
3309
|
+
message: `\u6587\u4EF6\u4E0D\u5B58\u5728: ${filePath}`,
|
|
3310
|
+
consoleUrl
|
|
3311
|
+
};
|
|
3312
|
+
}
|
|
3313
|
+
const fileSize = fs11.statSync(filePath).size;
|
|
3314
|
+
logger.info("\u5F00\u59CB\u4E0A\u4F20\u5230 vivo \u5E94\u7528\u5546\u5E97...");
|
|
3315
|
+
logger.info(`\u6587\u4EF6: ${filePath} (${(fileSize / 1024 / 1024).toFixed(2)} MB)`);
|
|
3316
|
+
try {
|
|
3317
|
+
const retryStrategy = createUploadRetryStrategy(retryConfig);
|
|
3318
|
+
const fileMd5 = await this.calculateFileMD5(filePath);
|
|
3319
|
+
logger.info("\u6B63\u5728\u4E0A\u4F20 APK \u6587\u4EF6...");
|
|
3320
|
+
const packageName = vivoConfig.packageName || "";
|
|
3321
|
+
const uploadFileResult = await retryStrategy.execute(
|
|
3322
|
+
() => this.uploadApk(accessKey, accessSecret, filePath, packageName, fileMd5),
|
|
3323
|
+
"vivo file upload"
|
|
3324
|
+
);
|
|
3325
|
+
if (!uploadFileResult.success || !uploadFileResult.data) {
|
|
3326
|
+
throw uploadFileResult.error || new Error("File upload failed or no serialnumber returned");
|
|
3327
|
+
}
|
|
3328
|
+
const serialnumber = uploadFileResult.data;
|
|
3329
|
+
logger.info("\u6B63\u5728\u63D0\u4EA4\u7248\u672C\u4FE1\u606F...");
|
|
3330
|
+
const submitResult = await retryStrategy.execute(
|
|
3331
|
+
() => this.submitVersion(accessKey, accessSecret, {
|
|
3332
|
+
serialnumber,
|
|
3333
|
+
packageName: vivoConfig.packageName,
|
|
3334
|
+
versionCode: parseInt(metadata.buildNumber) || 1,
|
|
3335
|
+
fileMd5,
|
|
3336
|
+
updateDesc: metadata.changelog || "App Update"
|
|
3337
|
+
}),
|
|
3338
|
+
"vivo submit version"
|
|
3339
|
+
);
|
|
3340
|
+
if (!submitResult.success) {
|
|
3341
|
+
throw submitResult.error || new Error("Submit version failed");
|
|
3342
|
+
}
|
|
3343
|
+
logger.success("\u4E0A\u4F20\u5230 vivo \u5E94\u7528\u5546\u5E97\u6210\u529F!");
|
|
3344
|
+
return {
|
|
3345
|
+
platform: "vivo",
|
|
3346
|
+
success: true,
|
|
3347
|
+
message: "\u4E0A\u4F20\u6210\u529F, \u8BF7\u524D\u5F80\u540E\u53F0\u786E\u8BA4\u5E76\u63D0\u4EA4\u5BA1\u6838",
|
|
3348
|
+
consoleUrl
|
|
3349
|
+
};
|
|
3350
|
+
} catch (error) {
|
|
3351
|
+
logger.error(`\u4E0A\u4F20\u5230 vivo \u5931\u8D25: ${error.message}`);
|
|
3352
|
+
return {
|
|
3353
|
+
platform: "vivo",
|
|
3354
|
+
success: false,
|
|
3355
|
+
message: this.formatError(error),
|
|
3356
|
+
error,
|
|
3357
|
+
consoleUrl
|
|
3358
|
+
};
|
|
3359
|
+
}
|
|
3360
|
+
}
|
|
3361
|
+
/**
|
|
3362
|
+
* 计算文件 MD5
|
|
3363
|
+
*/
|
|
3364
|
+
calculateFileMD5(filePath) {
|
|
3365
|
+
return new Promise((resolve, reject) => {
|
|
3366
|
+
const hash = crypto5.createHash("md5");
|
|
3367
|
+
const stream = fs11.createReadStream(filePath);
|
|
3368
|
+
stream.on("data", (data) => hash.update(data));
|
|
3369
|
+
stream.on("end", () => resolve(hash.digest("hex")));
|
|
3370
|
+
stream.on("error", reject);
|
|
3371
|
+
});
|
|
3372
|
+
}
|
|
3373
|
+
/**
|
|
3374
|
+
* 上传 APK 文件并获取流水号
|
|
3375
|
+
* 将公共参数与业务参数放入一个 multipart 请求中直接上传至 API_BASE
|
|
3376
|
+
*/
|
|
3377
|
+
async uploadApk(accessKey, accessSecret, filePath, packageName, fileMd5) {
|
|
3378
|
+
const timestamp = String(Date.now());
|
|
3379
|
+
const paramsForSign = {
|
|
3380
|
+
// 公共参数
|
|
3381
|
+
access_key: accessKey,
|
|
3382
|
+
timestamp,
|
|
3383
|
+
format: "json",
|
|
3384
|
+
v: "1.0",
|
|
3385
|
+
sign_method: "hmac",
|
|
3386
|
+
target_app_key: "developer",
|
|
3387
|
+
method: "app.upload.apk.app",
|
|
3388
|
+
// 业务参数
|
|
3389
|
+
packageName,
|
|
3390
|
+
fileMd5
|
|
3391
|
+
};
|
|
3392
|
+
const sign = this.generateSign(paramsForSign, accessSecret);
|
|
3393
|
+
const form = new FormData7();
|
|
3394
|
+
for (const [key, value] of Object.entries(paramsForSign)) {
|
|
3395
|
+
form.append(key, value);
|
|
3396
|
+
}
|
|
3397
|
+
form.append("sign", sign);
|
|
3398
|
+
form.append("file", fs11.createReadStream(filePath), {
|
|
3399
|
+
filename: path7.basename(filePath),
|
|
3400
|
+
contentType: "application/vnd.android.package-archive"
|
|
3401
|
+
});
|
|
3402
|
+
logger.info(
|
|
3403
|
+
`[VIVO DEBUG] \u5F00\u59CB\u4E0A\u4F20 APK (API\u8BF7\u6C42) \u53C2\u6570(\u4E0D\u542B\u6587\u4EF6): ${JSON.stringify({ ...paramsForSign, sign })}`
|
|
3404
|
+
);
|
|
3405
|
+
const res = await axios8.post(_VivoUploader.API_BASE, form, {
|
|
3406
|
+
headers: form.getHeaders(),
|
|
3407
|
+
maxContentLength: Infinity,
|
|
3408
|
+
maxBodyLength: Infinity,
|
|
3409
|
+
timeout: 3e5
|
|
3410
|
+
});
|
|
3411
|
+
logger.info(`[VIVO DEBUG] \u4E0A\u4F20\u54CD\u5E94: ${JSON.stringify(res.data)}`);
|
|
3412
|
+
if (res.data.code !== 0) {
|
|
3413
|
+
throw new Error(`\u6587\u4EF6\u4E0A\u4F20\u5931\u8D25: ${res.data.msg || JSON.stringify(res.data)}`);
|
|
3414
|
+
}
|
|
3415
|
+
const serialnumber = res.data.data?.serialnumber || res.data.data;
|
|
3416
|
+
logger.info(`APK \u6587\u4EF6\u4E0A\u4F20\u5B8C\u6210\uFF0C\u6D41\u6C34\u53F7: ${serialnumber}`);
|
|
3417
|
+
return serialnumber;
|
|
3418
|
+
}
|
|
3419
|
+
/**
|
|
3420
|
+
* 提交版本更新信息
|
|
3421
|
+
*/
|
|
3422
|
+
async submitVersion(accessKey, accessSecret, params) {
|
|
3423
|
+
const timestamp = String(Date.now());
|
|
3424
|
+
const reqParams = {
|
|
3425
|
+
access_key: accessKey,
|
|
3426
|
+
timestamp,
|
|
3427
|
+
format: "json",
|
|
3428
|
+
v: "1.0",
|
|
3429
|
+
sign_method: "hmac",
|
|
3430
|
+
target_app_key: "developer",
|
|
3431
|
+
method: "app.sync.update.app",
|
|
3432
|
+
packageName: params.packageName || "",
|
|
3433
|
+
// 文档必填
|
|
3434
|
+
versionCode: String(params.versionCode),
|
|
3435
|
+
// 文档必填 (Integer)
|
|
3436
|
+
apk: params.serialnumber,
|
|
3437
|
+
// 文档中的 apk 字段即为上传接口返回的 serialnumber
|
|
3438
|
+
fileMd5: params.fileMd5,
|
|
3439
|
+
// 文档必填
|
|
3440
|
+
onlineType: "1",
|
|
3441
|
+
// 1:立即上架 2:定时上架 (必填)
|
|
3442
|
+
compatibleDevice: "1",
|
|
3443
|
+
// 1:手机 2:手机和平板 3:平板 (必填)
|
|
3444
|
+
updateDesc: params.updateDesc
|
|
3445
|
+
};
|
|
3446
|
+
reqParams.sign = this.generateSign(reqParams, accessSecret);
|
|
3447
|
+
const formData = new URLSearchParams();
|
|
3448
|
+
for (const [key, value] of Object.entries(reqParams)) {
|
|
3449
|
+
formData.append(key, value);
|
|
3450
|
+
}
|
|
3451
|
+
const res = await axios8.post(_VivoUploader.API_BASE, formData.toString(), {
|
|
3452
|
+
headers: {
|
|
3453
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
3454
|
+
},
|
|
3455
|
+
timeout: 3e4
|
|
3456
|
+
});
|
|
3457
|
+
if (res.data.code !== 0) {
|
|
3458
|
+
throw new Error(`\u63D0\u4EA4\u7248\u672C\u5931\u8D25: ${res.data.msg || JSON.stringify(res.data)}`);
|
|
3459
|
+
}
|
|
3460
|
+
logger.info("\u7248\u672C\u4FE1\u606F\u5DF2\u63D0\u4EA4");
|
|
3461
|
+
}
|
|
3462
|
+
/**
|
|
3463
|
+
* 格式化错误信息
|
|
3464
|
+
*/
|
|
3465
|
+
formatError(error) {
|
|
3466
|
+
if (error.response?.data) {
|
|
3467
|
+
const data = error.response.data;
|
|
3468
|
+
return data.msg || data.message || error.message;
|
|
3469
|
+
}
|
|
3470
|
+
return error.message;
|
|
3471
|
+
}
|
|
3472
|
+
};
|
|
3473
|
+
|
|
3474
|
+
// src/adapters/tencent_uploader.ts
|
|
3475
|
+
import fs12 from "fs-extra";
|
|
3476
|
+
import path8 from "path";
|
|
3477
|
+
import axios9 from "axios";
|
|
3478
|
+
import crypto6 from "crypto";
|
|
3479
|
+
var TencentUploader = class _TencentUploader {
|
|
3480
|
+
name = "Tencent";
|
|
3481
|
+
/** 腾讯开放平台 API 基础地址 */
|
|
3482
|
+
static API_BASE = "https://p.open.qq.com/open_file/developer_api";
|
|
3483
|
+
/**
|
|
3484
|
+
* 接口签名生成 (HmacSHA256)
|
|
3485
|
+
* 规则: 所有参数按字典序排序拼接为 k1=v1&k2=v2... (不要进行 URL 编码)
|
|
3486
|
+
* 使用 accessSecret 计算 HmacSHA256,转小写 16 进制
|
|
3487
|
+
*/
|
|
3488
|
+
generateSign(params, accessSecret) {
|
|
3489
|
+
const sortedKeys = Object.keys(params).sort();
|
|
3490
|
+
const signStr = sortedKeys.filter((key) => params[key] !== void 0 && params[key] !== null).map((key) => `${key}=${params[key]}`).join("&");
|
|
3491
|
+
return crypto6.createHmac("sha256", accessSecret).update(signStr).digest("hex").toLowerCase();
|
|
3492
|
+
}
|
|
3493
|
+
validateConfig(config) {
|
|
3494
|
+
if (!config?.publish?.tencent?.enable) return false;
|
|
3495
|
+
const auth = config.publish.tencent.auth;
|
|
3496
|
+
if (!auth?.userId || !auth?.appId || !auth?.accessSecret) {
|
|
3497
|
+
logger.error("Tencent config missing: userId, appId or accessSecret");
|
|
3498
|
+
return false;
|
|
3499
|
+
}
|
|
3500
|
+
return true;
|
|
3501
|
+
}
|
|
3502
|
+
canHandle(filePath) {
|
|
3503
|
+
return filePath.toLowerCase().endsWith(".apk");
|
|
3504
|
+
}
|
|
3505
|
+
async upload(filePath, config, metadata) {
|
|
3506
|
+
const tencentConfig = config.publish.tencent;
|
|
3507
|
+
const auth = tencentConfig.auth;
|
|
3508
|
+
logger.info(`Tencent \u8FD0\u884C\u65F6\u914D\u7F6E: ${JSON.stringify(tencentConfig)}`);
|
|
3509
|
+
const consoleUrl = "https://app.open.qq.com";
|
|
3510
|
+
if (!fs12.existsSync(filePath)) {
|
|
3511
|
+
return { platform: "tencent", success: false, message: `\u6587\u4EF6\u4E0D\u5B58\u5728: ${filePath}`, consoleUrl };
|
|
3512
|
+
}
|
|
3513
|
+
try {
|
|
3514
|
+
const stats = fs12.statSync(filePath);
|
|
3515
|
+
const fileName = path8.basename(filePath);
|
|
3516
|
+
logger.info("\u6B63\u5728\u83B7\u53D6\u9884\u7B7E\u540D\u4E0A\u4F20\u5730\u5740...");
|
|
3517
|
+
const pkgName = tencentConfig.packageName || "";
|
|
3518
|
+
const uploadInfo = await this.getFileUploadInfo(auth, stats.size, fileName, pkgName);
|
|
3519
|
+
logger.info(`\u83B7\u53D6\u6587\u4EF6\u4E0A\u4F20\u4FE1\u606F\u7ED3\u679C ${JSON.stringify(uploadInfo)}`);
|
|
3520
|
+
logger.info("\u6B63\u5728\u4E0A\u4F20\u6587\u4EF6\u5230\u817E\u8BAF\u4E91 COS (\u7ED5\u8FC7 API \u7F51\u5173\u9650\u5236)...");
|
|
3521
|
+
await this.uploadToCos(uploadInfo.pre_sign_url, filePath, stats.size);
|
|
3522
|
+
logger.info("\u6B63\u5728\u83B7\u53D6\u7EBF\u4E0A\u5E94\u7528\u8BE6\u60C5\u4EE5\u4FDD\u7559\u73B0\u6709\u914D\u7F6E...");
|
|
3523
|
+
const pkgInfo = await parsePackageInfo(filePath, {
|
|
3524
|
+
androidSdkPath: config.globalSettings?.androidSdkPath
|
|
3525
|
+
});
|
|
3526
|
+
const nativeCodes = pkgInfo?.nativeCode || [];
|
|
3527
|
+
const has64Bit = nativeCodes.includes("arm64-v8a") || nativeCodes.includes("x86_64");
|
|
3528
|
+
const has32Bit = nativeCodes.includes("armeabi-v7a") || nativeCodes.includes("armeabi") || nativeCodes.includes("x86");
|
|
3529
|
+
const is64Bit = has64Bit && !has32Bit;
|
|
3530
|
+
const md5 = pkgInfo?.md5;
|
|
3531
|
+
const onlineDetail = await this.queryAppDetail(
|
|
3532
|
+
auth,
|
|
3533
|
+
tencentConfig.packageName || pkgInfo?.packageName || ""
|
|
3534
|
+
);
|
|
3535
|
+
logger.info(`\u83B7\u53D6\u7EBF\u4E0A\u8BE6\u60C5\u6210\u529F: ${onlineDetail.app_name || "\u672A\u547D\u540D"}`);
|
|
3536
|
+
logger.info("\u6B63\u5728\u63D0\u4EA4\u6D41\u6C34\u53F7\u5B8C\u6210\u5BA1\u6838\u7533\u62A5...");
|
|
3537
|
+
const submitResult = await this.submitModifyAppInfo(
|
|
3538
|
+
auth,
|
|
3539
|
+
{
|
|
3540
|
+
...onlineDetail,
|
|
3541
|
+
// 以前序详情为蓝本
|
|
3542
|
+
pkg_name: tencentConfig.packageName || pkgInfo?.packageName || onlineDetail.pkg_name || "",
|
|
3543
|
+
serial_number: uploadInfo.serial_number,
|
|
3544
|
+
update_desc: metadata.changelog || "\u7248\u672C\u66F4\u65B0",
|
|
3545
|
+
is64Bit,
|
|
3546
|
+
md5
|
|
3547
|
+
},
|
|
3548
|
+
{ timeout: tencentConfig.timeout || 6e4 }
|
|
3549
|
+
);
|
|
3550
|
+
logger.info(`\u63D0\u4EA4\u6D41\u6C34\u53F7\u5B8C\u6210\u5BA1\u6838\u7533\u62A5\u7ED3\u679C: ${JSON.stringify(submitResult)}`);
|
|
3551
|
+
logger.success("\u817E\u8BAF\u5E94\u7528\u5B9D\u4E0A\u4F20\u5E76\u63D0\u4EA4\u6210\u529F!");
|
|
3552
|
+
return { platform: "tencent", success: true, message: "\u4E0A\u4F20\u5E76\u63D0\u4EA4\u5BA1\u6838\u6210\u529F", consoleUrl };
|
|
3553
|
+
} catch (error) {
|
|
3554
|
+
logger.error(`\u817E\u8BAF\u4E0A\u4F20\u5931\u8D25: ${JSON.stringify(error)}`);
|
|
3555
|
+
logger.error(`\u817E\u8BAF\u4E0A\u4F20\u5931\u8D25: ${error.stack}`);
|
|
3556
|
+
return { platform: "tencent", success: false, message: error.message, error, consoleUrl };
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
/**
|
|
3560
|
+
* 阶段 1: 获取文件上传信息
|
|
3561
|
+
*/
|
|
3562
|
+
async getFileUploadInfo(auth, fileSize, fileName, pkgName) {
|
|
3563
|
+
logger.info(`Tencent getFileUploadInfo: ${JSON.stringify(auth)}`);
|
|
3564
|
+
const userId = String(auth.userId || "");
|
|
3565
|
+
const accessSecret = String(auth.accessSecret || "");
|
|
3566
|
+
const signParams = {
|
|
3567
|
+
user_id: userId,
|
|
3568
|
+
timestamp: String(Math.floor(Date.now() / 1e3)),
|
|
3569
|
+
pkg_name: pkgName,
|
|
3570
|
+
app_id: String(auth.appId || ""),
|
|
3571
|
+
file_size: String(fileSize),
|
|
3572
|
+
file_name: fileName,
|
|
3573
|
+
file_type: "apk"
|
|
3574
|
+
};
|
|
3575
|
+
const sign = this.generateSign(signParams, accessSecret);
|
|
3576
|
+
const formParams = {
|
|
3577
|
+
...signParams,
|
|
3578
|
+
sign
|
|
3579
|
+
};
|
|
3580
|
+
const url = `${_TencentUploader.API_BASE}/get_file_upload_info`;
|
|
3581
|
+
logger.info(`\u6B63\u5728\u8BF7\u6C42 Tencent API: ${url}`);
|
|
3582
|
+
logger.info(`formParams: ${JSON.stringify(formParams)}`);
|
|
3583
|
+
const res = await axios9.post(url, new URLSearchParams(formParams).toString(), {
|
|
3584
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" }
|
|
3585
|
+
});
|
|
3586
|
+
if (res.data.ret !== 0) {
|
|
3587
|
+
throw new Error(`\u83B7\u53D6\u4E0A\u4F20\u4FE1\u606F\u5931\u8D25: ${res.data.msg} (ret:${res.data.ret})`);
|
|
3588
|
+
}
|
|
3589
|
+
return res.data;
|
|
3590
|
+
}
|
|
3591
|
+
/**
|
|
3592
|
+
* 获取应用详情
|
|
3593
|
+
*/
|
|
3594
|
+
async queryAppDetail(auth, pkgName) {
|
|
3595
|
+
const userId = auth.userId || "";
|
|
3596
|
+
const accessSecret = auth.accessSecret || "";
|
|
3597
|
+
const signParams = {
|
|
3598
|
+
user_id: userId,
|
|
3599
|
+
timestamp: String(Math.floor(Date.now() / 1e3)),
|
|
3600
|
+
app_id: String(auth.appId || ""),
|
|
3601
|
+
pkg_name: pkgName
|
|
3602
|
+
};
|
|
3603
|
+
const sign = this.generateSign(signParams, accessSecret);
|
|
3604
|
+
const formParams = { ...signParams, sign };
|
|
3605
|
+
const url = `${_TencentUploader.API_BASE}/query_app_detail`;
|
|
3606
|
+
const res = await axios9.post(url, new URLSearchParams(formParams).toString(), {
|
|
3607
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" }
|
|
3608
|
+
});
|
|
3609
|
+
if (res.data.ret !== 0) {
|
|
3610
|
+
logger.warn(`\u67E5\u8BE2\u5E94\u7528\u8BE6\u60C5\u5931\u8D25: ${res.data.msg}\uFF0C\u5C06\u4F7F\u7528\u7A7A\u914D\u7F6E\u7EE7\u7EED`);
|
|
3611
|
+
return {};
|
|
3612
|
+
}
|
|
3613
|
+
return res.data;
|
|
3614
|
+
}
|
|
3615
|
+
/**
|
|
3616
|
+
* 阶段 2: 直接上传到 COS (PUT)
|
|
3617
|
+
*/
|
|
3618
|
+
async uploadToCos(url, filePath, size) {
|
|
3619
|
+
await axios9.put(url, fs12.createReadStream(filePath), {
|
|
3620
|
+
headers: {
|
|
3621
|
+
"Content-Type": "application/octet-stream",
|
|
3622
|
+
"Content-Length": size
|
|
3623
|
+
},
|
|
3624
|
+
maxContentLength: Infinity,
|
|
3625
|
+
maxBodyLength: Infinity,
|
|
3626
|
+
timeout: 9e5,
|
|
3627
|
+
// 15 分钟
|
|
3628
|
+
onUploadProgress: (p) => {
|
|
3629
|
+
if (p.total) {
|
|
3630
|
+
const percent = Math.round(p.loaded * 100 / p.total);
|
|
3631
|
+
if (percent % 20 === 0) logger.info(`\u4E0A\u4F20\u8FDB\u5EA6: ${percent}%`);
|
|
3632
|
+
}
|
|
3633
|
+
}
|
|
3634
|
+
});
|
|
3635
|
+
}
|
|
3636
|
+
/**
|
|
3637
|
+
* 阶段 3: 提交流水号信息
|
|
3638
|
+
*/
|
|
3639
|
+
async submitModifyAppInfo(auth, data, options) {
|
|
3640
|
+
const userId = auth.userId || "";
|
|
3641
|
+
const accessSecret = auth.accessSecret || "";
|
|
3642
|
+
const signParams = {
|
|
3643
|
+
user_id: userId,
|
|
3644
|
+
timestamp: String(Math.floor(Date.now() / 1e3)),
|
|
3645
|
+
app_id: String(auth.appId || ""),
|
|
3646
|
+
pkg_name: String(data.pkg_name || ""),
|
|
3647
|
+
feature: String(data.update_desc || ""),
|
|
3648
|
+
deploy_type: String(data.deploy_type || "1")
|
|
3649
|
+
// 默认审核后立即发布
|
|
3650
|
+
};
|
|
3651
|
+
const skipFields = [
|
|
3652
|
+
"ret",
|
|
3653
|
+
"msg",
|
|
3654
|
+
"user_id",
|
|
3655
|
+
"timestamp",
|
|
3656
|
+
"sign",
|
|
3657
|
+
"app_id",
|
|
3658
|
+
"pkg_name",
|
|
3659
|
+
"feature",
|
|
3660
|
+
"is64Bit",
|
|
3661
|
+
"md5",
|
|
3662
|
+
"serial_number"
|
|
3663
|
+
];
|
|
3664
|
+
Object.keys(data).forEach((key) => {
|
|
3665
|
+
if (!skipFields.includes(key) && data[key] !== void 0 && data[key] !== null) {
|
|
3666
|
+
signParams[key] = String(data[key]);
|
|
3667
|
+
}
|
|
3668
|
+
});
|
|
3669
|
+
if (data.is64Bit) {
|
|
3670
|
+
signParams.apk64_file_serial_number = String(data.serial_number || "");
|
|
3671
|
+
if (data.md5) signParams.apk64_file_md5 = data.md5;
|
|
3672
|
+
delete signParams.apk32_file_serial_number;
|
|
3673
|
+
delete signParams.apk32_file_md5;
|
|
3674
|
+
} else {
|
|
3675
|
+
signParams.apk32_file_serial_number = String(data.serial_number || "");
|
|
3676
|
+
if (data.md5) signParams.apk32_file_md5 = data.md5;
|
|
3677
|
+
delete signParams.apk64_file_serial_number;
|
|
3678
|
+
delete signParams.apk64_file_md5;
|
|
3679
|
+
}
|
|
3680
|
+
const sign = this.generateSign(signParams, accessSecret);
|
|
3681
|
+
const formParams = {
|
|
3682
|
+
...signParams,
|
|
3683
|
+
sign
|
|
3684
|
+
};
|
|
3685
|
+
logger.info(`formParams: ${JSON.stringify(formParams)}`);
|
|
3686
|
+
const url = `${_TencentUploader.API_BASE}/update_app`;
|
|
3687
|
+
const res = await axios9.post(url, new URLSearchParams(formParams).toString(), {
|
|
3688
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" }
|
|
3689
|
+
});
|
|
3690
|
+
if (res.data.ret !== 0) {
|
|
3691
|
+
throw new Error(`\u63D0\u4EA4\u6D41\u6C34\u53F7\u5931\u8D25: ${res.data.msg} (ret:${res.data.ret})`);
|
|
3692
|
+
}
|
|
3693
|
+
return res.data;
|
|
3694
|
+
}
|
|
3695
|
+
};
|
|
3696
|
+
|
|
3697
|
+
// src/utils/upload-manager.ts
|
|
3698
|
+
var UploadManager = class {
|
|
3699
|
+
uploaders;
|
|
3700
|
+
config;
|
|
3701
|
+
constructor(config = {}) {
|
|
3702
|
+
this.uploaders = /* @__PURE__ */ new Map();
|
|
3703
|
+
this.config = {
|
|
3704
|
+
maxConcurrent: config.maxConcurrent ?? 3,
|
|
3705
|
+
continueOnError: config.continueOnError ?? true,
|
|
3706
|
+
verbose: config.verbose ?? false,
|
|
3707
|
+
cancellationToken: config.cancellationToken
|
|
3708
|
+
};
|
|
3709
|
+
this.registerUploader("pgyer", new PgyerUploader());
|
|
3710
|
+
this.registerUploader("huawei", new HuaweiUploader());
|
|
3711
|
+
this.registerUploader("xiaomi", new XiaomiUploader());
|
|
3712
|
+
this.registerUploader("app_store", new AppleStoreUploader());
|
|
3713
|
+
this.registerUploader("testflight", new TestFlightUploader());
|
|
3714
|
+
this.registerUploader("adhoc", new AdHocUploader());
|
|
3715
|
+
this.registerUploader("harmony", new HarmonyUploader());
|
|
3716
|
+
this.registerUploader("oppo", new OppoUploader());
|
|
3717
|
+
this.registerUploader("vivo", new VivoUploader());
|
|
3718
|
+
this.registerUploader("tencent", new TencentUploader());
|
|
3719
|
+
}
|
|
3720
|
+
/**
|
|
3721
|
+
* 注册上传器
|
|
3722
|
+
*/
|
|
3723
|
+
registerUploader(platform, uploader) {
|
|
3724
|
+
this.uploaders.set(platform, uploader);
|
|
3725
|
+
}
|
|
3726
|
+
/**
|
|
3727
|
+
* 获取启用的平台列表(根据配置)
|
|
3728
|
+
*/
|
|
3729
|
+
getEnabledPlatforms(config) {
|
|
3730
|
+
const enabled = [];
|
|
3731
|
+
if (config?.publish?.pgyer?.enable) enabled.push("pgyer");
|
|
3732
|
+
if (config?.publish?.huawei?.enable) enabled.push("huawei");
|
|
3733
|
+
if (config?.publish?.xiaomi?.enable) enabled.push("xiaomi");
|
|
3734
|
+
if (config?.publish?.oppo?.enable) enabled.push("oppo");
|
|
3735
|
+
if (config?.publish?.vivo?.enable) enabled.push("vivo");
|
|
3736
|
+
if (config?.publish?.tencent?.enable) enabled.push("tencent");
|
|
3737
|
+
if (config?.publish?.app_store?.enable) {
|
|
3738
|
+
const mode = config.publish.app_store.mode || "app_store";
|
|
3739
|
+
enabled.push(mode);
|
|
3740
|
+
}
|
|
3741
|
+
return enabled;
|
|
3742
|
+
}
|
|
3743
|
+
/**
|
|
3744
|
+
* 获取可用的平台列表
|
|
3745
|
+
*/
|
|
3746
|
+
getAvailablePlatforms() {
|
|
3747
|
+
return Array.from(this.uploaders.keys());
|
|
3748
|
+
}
|
|
3749
|
+
/**
|
|
3750
|
+
* 执行并行上传(支持多文件智能分发)
|
|
3751
|
+
*/
|
|
3752
|
+
async uploadToMultiplePlatforms(filePaths, config, metadata, targetPlatforms) {
|
|
3753
|
+
const startTime = Date.now();
|
|
3754
|
+
let platforms = targetPlatforms && targetPlatforms.length > 0 ? targetPlatforms : this.getEnabledPlatforms(config);
|
|
3755
|
+
if (platforms.length === 0) {
|
|
3756
|
+
logger.warn("No platforms enabled for upload");
|
|
3757
|
+
return {
|
|
3758
|
+
totalUploads: 0,
|
|
3759
|
+
successCount: 0,
|
|
3760
|
+
failureCount: 0,
|
|
3761
|
+
successPlatforms: [],
|
|
3762
|
+
failurePlatforms: [],
|
|
3763
|
+
results: [],
|
|
3764
|
+
totalDurationMs: 0,
|
|
3765
|
+
summary: "No platforms configured for upload"
|
|
3766
|
+
};
|
|
3767
|
+
}
|
|
3768
|
+
logger.info(
|
|
3769
|
+
`Starting parallel uploads to ${platforms.length} platform(s): ${platforms.join(", ")}`
|
|
3770
|
+
);
|
|
3771
|
+
const validPlatforms = platforms.filter((platform) => {
|
|
3772
|
+
const uploader = this.uploaders.get(platform);
|
|
3773
|
+
if (!uploader) {
|
|
3774
|
+
logger.warn(`Platform '${platform}' is not supported`);
|
|
3775
|
+
return false;
|
|
3776
|
+
}
|
|
3777
|
+
if (!uploader.validateConfig(config)) {
|
|
3778
|
+
logger.warn(`Configuration for '${platform}' is invalid`);
|
|
3779
|
+
return false;
|
|
3780
|
+
}
|
|
3781
|
+
return true;
|
|
3782
|
+
});
|
|
3783
|
+
if (validPlatforms.length === 0) {
|
|
3784
|
+
logger.error("No valid platforms to upload to");
|
|
3785
|
+
return {
|
|
3786
|
+
totalUploads: platforms.length,
|
|
3787
|
+
successCount: 0,
|
|
3788
|
+
failureCount: platforms.length,
|
|
3789
|
+
successPlatforms: [],
|
|
3790
|
+
failurePlatforms: platforms,
|
|
3791
|
+
results: [],
|
|
3792
|
+
totalDurationMs: Date.now() - startTime,
|
|
3793
|
+
summary: "All platforms failed configuration validation"
|
|
3794
|
+
};
|
|
3795
|
+
}
|
|
3796
|
+
const files = Array.isArray(filePaths) ? filePaths : [filePaths];
|
|
3797
|
+
logger.info(`Processing ${files.length} file(s) for ${validPlatforms.length} platform(s)`);
|
|
3798
|
+
const uploadPromises = validPlatforms.map((platform) => {
|
|
3799
|
+
const uploader = this.uploaders.get(platform);
|
|
3800
|
+
const targetFile = files.find((f) => uploader.canHandle(f));
|
|
3801
|
+
if (!targetFile) {
|
|
3802
|
+
logger.warn(`[${platform}] No compatible file found in provided files`);
|
|
3803
|
+
return Promise.resolve({
|
|
3804
|
+
platform,
|
|
3805
|
+
success: false,
|
|
3806
|
+
message: `No compatible file found for ${platform}`
|
|
3807
|
+
});
|
|
3808
|
+
}
|
|
3809
|
+
logger.info(`[${platform}] Matched file: ${path9.basename(targetFile)}`);
|
|
3810
|
+
return this.uploadToPlatform(platform, targetFile, config, metadata);
|
|
3811
|
+
});
|
|
3812
|
+
const results = await Promise.all(uploadPromises);
|
|
3813
|
+
const report = this.aggregateResults(results, validPlatforms.length, startTime);
|
|
3814
|
+
this.printReport(report);
|
|
3815
|
+
return report;
|
|
3816
|
+
}
|
|
3817
|
+
/**
|
|
3818
|
+
* 上传到单个平台
|
|
3819
|
+
*/
|
|
3820
|
+
async uploadToPlatform(platform, filePath, config, metadata) {
|
|
3821
|
+
if (this.config.cancellationToken?.isCancelled) {
|
|
3822
|
+
logger.warn(`[${platform.toUpperCase()}] Upload skipped due to cancellation`);
|
|
3823
|
+
return {
|
|
3824
|
+
platform,
|
|
3825
|
+
success: false,
|
|
3826
|
+
message: "Upload cancelled by user"
|
|
3827
|
+
};
|
|
3828
|
+
}
|
|
3829
|
+
const uploader = this.uploaders.get(platform);
|
|
3830
|
+
if (!uploader) {
|
|
3831
|
+
return {
|
|
3832
|
+
platform,
|
|
3833
|
+
success: false,
|
|
3834
|
+
message: `Platform '${platform}' is not supported`
|
|
3835
|
+
};
|
|
3836
|
+
}
|
|
3837
|
+
try {
|
|
3838
|
+
if (this.config.verbose) {
|
|
3839
|
+
logger.info(`[${platform.toUpperCase()}] Starting upload...`);
|
|
3840
|
+
}
|
|
3841
|
+
const pkgInfo = await parsePackageInfo(filePath, {
|
|
3842
|
+
androidSdkPath: metadata.globalSettings?.androidSdkPath
|
|
3843
|
+
});
|
|
3844
|
+
if (pkgInfo) {
|
|
3845
|
+
if (pkgInfo.versionName) {
|
|
3846
|
+
metadata.version = pkgInfo.versionName;
|
|
3847
|
+
}
|
|
3848
|
+
if (pkgInfo.versionCode) {
|
|
3849
|
+
metadata.buildNumber = pkgInfo.versionCode;
|
|
3850
|
+
}
|
|
3851
|
+
if (pkgInfo.packageName) {
|
|
3852
|
+
;
|
|
3853
|
+
metadata.packageName = pkgInfo.packageName;
|
|
3854
|
+
}
|
|
3855
|
+
logger.info(
|
|
3856
|
+
`[${platform.toUpperCase()}] \u5DF2\u6821\u51C6\u7248\u672C\u4FE1\u606F: v${metadata.version}+${metadata.buildNumber}`
|
|
3857
|
+
);
|
|
3858
|
+
}
|
|
3859
|
+
const result = await uploader.upload(filePath, config, metadata);
|
|
3860
|
+
return result;
|
|
3861
|
+
} catch (error) {
|
|
3862
|
+
const errorMessage = error.message || String(error);
|
|
3863
|
+
logger.error(`[${platform.toUpperCase()}] Upload error: ${errorMessage}`);
|
|
3864
|
+
return {
|
|
3865
|
+
platform,
|
|
3866
|
+
success: false,
|
|
3867
|
+
message: errorMessage,
|
|
3868
|
+
error
|
|
3869
|
+
};
|
|
3870
|
+
}
|
|
3871
|
+
}
|
|
3872
|
+
/**
|
|
3873
|
+
* 聚合上传结果
|
|
3874
|
+
*/
|
|
3875
|
+
aggregateResults(results, totalPlatforms, startTime) {
|
|
3876
|
+
const successResults = results.filter((r) => r.success);
|
|
3877
|
+
const failureResults = results.filter((r) => !r.success);
|
|
3878
|
+
const successPlatforms = successResults.map((r) => r.platform || "unknown");
|
|
3879
|
+
const failurePlatforms = failureResults.map((r) => r.platform || "unknown");
|
|
3880
|
+
const totalDurationMs = Date.now() - startTime;
|
|
3881
|
+
let summary = "";
|
|
3882
|
+
if (successResults.length === totalPlatforms) {
|
|
3883
|
+
summary = `\u2705 All ${totalPlatforms} platforms uploaded successfully!`;
|
|
3884
|
+
} else if (failureResults.length === totalPlatforms) {
|
|
3885
|
+
summary = `\u274C Upload failed on all ${totalPlatforms} platforms`;
|
|
3886
|
+
} else {
|
|
3887
|
+
summary = `\u26A0\uFE0F Partial success: ${successResults.length} succeeded, ${failureResults.length} failed`;
|
|
3888
|
+
}
|
|
3889
|
+
return {
|
|
3890
|
+
totalUploads: totalPlatforms,
|
|
3891
|
+
successCount: successResults.length,
|
|
3892
|
+
failureCount: failureResults.length,
|
|
3893
|
+
successPlatforms,
|
|
3894
|
+
failurePlatforms,
|
|
3895
|
+
results,
|
|
3896
|
+
totalDurationMs,
|
|
3897
|
+
summary
|
|
3898
|
+
};
|
|
3899
|
+
}
|
|
3900
|
+
/**
|
|
3901
|
+
* 输出上传报告
|
|
3902
|
+
*/
|
|
3903
|
+
printReport(report) {
|
|
3904
|
+
logger.info("");
|
|
3905
|
+
logger.info("\u2550".repeat(60));
|
|
3906
|
+
logger.info("\u{1F4CA} Upload Report");
|
|
3907
|
+
logger.info("\u2550".repeat(60));
|
|
3908
|
+
logger.info(`Total Platforms: ${report.totalUploads}`);
|
|
3909
|
+
logger.info(`\u2705 Success: ${report.successCount}`);
|
|
3910
|
+
logger.info(`\u274C Failure: ${report.failureCount}`);
|
|
3911
|
+
if (report.successPlatforms.length > 0) {
|
|
3912
|
+
logger.info(`Successful: ${report.successPlatforms.join(", ")}`);
|
|
3913
|
+
}
|
|
3914
|
+
if (report.failurePlatforms.length > 0) {
|
|
3915
|
+
logger.info(`Failed: ${report.failurePlatforms.join(", ")}`);
|
|
3916
|
+
}
|
|
3917
|
+
logger.info(`Total Duration: ${this.formatDuration(report.totalDurationMs)}`);
|
|
3918
|
+
logger.info("");
|
|
3919
|
+
logger.info(report.summary);
|
|
3920
|
+
logger.info("\u2550".repeat(60));
|
|
3921
|
+
logger.info("");
|
|
3922
|
+
logger.info("\u{1F4CB} Detailed Results:");
|
|
3923
|
+
report.results.forEach((result) => {
|
|
3924
|
+
if (result.success) {
|
|
3925
|
+
if (this.config.verbose) {
|
|
3926
|
+
logger.success(`[${result.platform}] \u2705 ${result.message}`);
|
|
3927
|
+
if (result.url) {
|
|
3928
|
+
logger.info(` Download: ${result.url}`);
|
|
3929
|
+
}
|
|
3930
|
+
}
|
|
3931
|
+
} else {
|
|
3932
|
+
logger.error(`[${result.platform}] \u274C ${result.message}`);
|
|
3933
|
+
if (result.error && result.error.stack && this.config.verbose) {
|
|
3934
|
+
logger.error(result.error.stack);
|
|
3935
|
+
}
|
|
3936
|
+
}
|
|
3937
|
+
});
|
|
3938
|
+
logger.info("");
|
|
3939
|
+
}
|
|
3940
|
+
/**
|
|
3941
|
+
* 格式化持续时间
|
|
3942
|
+
*/
|
|
3943
|
+
formatDuration(ms) {
|
|
3944
|
+
if (ms < 1e3) {
|
|
3945
|
+
return `${ms}ms`;
|
|
3946
|
+
}
|
|
3947
|
+
if (ms < 6e4) {
|
|
3948
|
+
return `${(ms / 1e3).toFixed(1)}s`;
|
|
3949
|
+
}
|
|
3950
|
+
return `${(ms / 6e4).toFixed(1)}m`;
|
|
3951
|
+
}
|
|
3952
|
+
/**
|
|
3953
|
+
* 获取可重试的失败平台
|
|
3954
|
+
*/
|
|
3955
|
+
getRetryablePlatforms(report) {
|
|
3956
|
+
return report.failurePlatforms;
|
|
3957
|
+
}
|
|
3958
|
+
/**
|
|
3959
|
+
* 重试失败的平台
|
|
3960
|
+
*/
|
|
3961
|
+
async retryFailedPlatforms(failedPlatforms, filePath, config, metadata) {
|
|
3962
|
+
if (failedPlatforms.length === 0) {
|
|
3963
|
+
logger.info("No platforms to retry");
|
|
3964
|
+
return {
|
|
3965
|
+
totalUploads: 0,
|
|
3966
|
+
successCount: 0,
|
|
3967
|
+
failureCount: 0,
|
|
3968
|
+
successPlatforms: [],
|
|
3969
|
+
failurePlatforms: [],
|
|
3970
|
+
results: [],
|
|
3971
|
+
totalDurationMs: 0,
|
|
3972
|
+
summary: "No platforms to retry"
|
|
3973
|
+
};
|
|
3974
|
+
}
|
|
3975
|
+
logger.info(`Retrying ${failedPlatforms.length} failed platform(s)...`);
|
|
3976
|
+
return this.uploadToMultiplePlatforms(filePath, config, metadata, failedPlatforms);
|
|
3977
|
+
}
|
|
3978
|
+
};
|
|
3979
|
+
|
|
3980
|
+
// src/validators/config_validator.ts
|
|
3981
|
+
import fs13 from "fs-extra";
|
|
3982
|
+
import yaml from "js-yaml";
|
|
3983
|
+
|
|
3984
|
+
// src/validators/env_validator.ts
|
|
3985
|
+
var EnvValidator = class {
|
|
3986
|
+
/**
|
|
3987
|
+
* 验证环境变量
|
|
3988
|
+
* @param requiredVars 必需的环境变量列表
|
|
3989
|
+
*/
|
|
3990
|
+
static validate(requiredVars) {
|
|
3991
|
+
const missing = requiredVars.filter((v) => !process.env[v]);
|
|
3992
|
+
if (missing.length > 0) {
|
|
3993
|
+
logger.error(`Missing environment variables: ${missing.join(", ")}`);
|
|
3994
|
+
return false;
|
|
3995
|
+
}
|
|
3996
|
+
return true;
|
|
3997
|
+
}
|
|
3998
|
+
};
|
|
3999
|
+
|
|
4000
|
+
// src/validators/config_validator.ts
|
|
4001
|
+
var ConfigValidator = class {
|
|
4002
|
+
/**
|
|
4003
|
+
* 加载并验证配置文件
|
|
4004
|
+
* @param filePath 配置文件路径
|
|
4005
|
+
*/
|
|
4006
|
+
static loadAndValidate(filePath) {
|
|
4007
|
+
if (!fs13.existsSync(filePath)) {
|
|
4008
|
+
logger.error(`Config file not found: ${filePath}`);
|
|
4009
|
+
return null;
|
|
4010
|
+
}
|
|
4011
|
+
let content = fs13.readFileSync(filePath, "utf8");
|
|
4012
|
+
const envRegex = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
|
|
4013
|
+
const matches = [...content.matchAll(envRegex)];
|
|
4014
|
+
const requiredEnvs = Array.from(new Set(matches.map((m) => m[1])));
|
|
4015
|
+
if (!EnvValidator.validate(requiredEnvs)) {
|
|
4016
|
+
return null;
|
|
4017
|
+
}
|
|
4018
|
+
content = content.replace(envRegex, (_, varName) => process.env[varName] || "");
|
|
4019
|
+
try {
|
|
4020
|
+
const config = yaml.load(content);
|
|
4021
|
+
if (!this.validateSchema(config)) {
|
|
4022
|
+
return null;
|
|
4023
|
+
}
|
|
4024
|
+
return config;
|
|
4025
|
+
} catch (e) {
|
|
4026
|
+
logger.error(`Failed to parse YAML: ${e.message}`);
|
|
4027
|
+
return null;
|
|
4028
|
+
}
|
|
4029
|
+
}
|
|
4030
|
+
/**
|
|
4031
|
+
* 验证配置 Schema
|
|
4032
|
+
* @param config 配置对象
|
|
4033
|
+
*/
|
|
4034
|
+
static validateSchema(config) {
|
|
4035
|
+
if (config.publish?.app_store?.enable) {
|
|
4036
|
+
const auth = config.publish.app_store.auth;
|
|
4037
|
+
if (!auth?.apiKeyId || !auth?.apiIssuerId) {
|
|
4038
|
+
logger.error("App Store enabled but missing auth info (apiKeyId, apiIssuerId)");
|
|
4039
|
+
return false;
|
|
4040
|
+
}
|
|
4041
|
+
}
|
|
4042
|
+
if (config.publish?.huawei?.enable) {
|
|
4043
|
+
const auth = config.publish.huawei.auth;
|
|
4044
|
+
if (!auth?.clientId || !auth?.clientSecret) {
|
|
4045
|
+
logger.error("Huawei enabled but missing auth info (clientId, clientSecret)");
|
|
4046
|
+
return false;
|
|
4047
|
+
}
|
|
4048
|
+
}
|
|
4049
|
+
if (config.publish?.xiaomi?.enable) {
|
|
4050
|
+
const auth = config.publish.xiaomi.auth;
|
|
4051
|
+
if (!auth?.userName || !auth?.privateKey) {
|
|
4052
|
+
logger.error("Xiaomi enabled but missing auth info (userName, privateKey)");
|
|
4053
|
+
return false;
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
4056
|
+
if (config.publish?.oppo?.enable) {
|
|
4057
|
+
const auth = config.publish.oppo.auth;
|
|
4058
|
+
if (!auth?.clientId || !auth?.clientSecret) {
|
|
4059
|
+
logger.error("OPPO enabled but missing auth info (clientId, clientSecret)");
|
|
4060
|
+
return false;
|
|
4061
|
+
}
|
|
4062
|
+
}
|
|
4063
|
+
if (config.publish?.vivo?.enable) {
|
|
4064
|
+
const auth = config.publish.vivo.auth;
|
|
4065
|
+
if (!auth?.accessKey || !auth?.accessSecret) {
|
|
4066
|
+
logger.error("vivo enabled but missing auth info (accessKey, accessSecret)");
|
|
4067
|
+
return false;
|
|
4068
|
+
}
|
|
4069
|
+
}
|
|
4070
|
+
if (config.publish?.tencent?.enable) {
|
|
4071
|
+
const auth = config.publish.tencent.auth;
|
|
4072
|
+
if (!auth?.userId || !auth?.appId || !auth?.accessSecret) {
|
|
4073
|
+
logger.error("Tencent enabled but missing auth info (userId, appId, accessSecret)");
|
|
4074
|
+
return false;
|
|
4075
|
+
}
|
|
4076
|
+
}
|
|
4077
|
+
if (config.publish?.harmony?.enable) {
|
|
4078
|
+
const auth = config.publish.harmony.auth;
|
|
4079
|
+
if (!auth?.clientId || !auth?.clientSecret) {
|
|
4080
|
+
logger.error("Harmony enabled but missing auth info (clientId, clientSecret)");
|
|
4081
|
+
return false;
|
|
4082
|
+
}
|
|
4083
|
+
}
|
|
4084
|
+
return true;
|
|
4085
|
+
}
|
|
4086
|
+
};
|
|
4087
|
+
|
|
4088
|
+
// src/cli.ts
|
|
4089
|
+
import path10 from "path";
|
|
4090
|
+
import fs14 from "fs-extra";
|
|
4091
|
+
import yaml2 from "js-yaml";
|
|
4092
|
+
var VERSION = "0.1.0";
|
|
4093
|
+
function printHelp() {
|
|
4094
|
+
console.log(`
|
|
4095
|
+
app-ship v${VERSION} \u2014 Multi-platform app store uploader
|
|
4096
|
+
|
|
4097
|
+
Usage:
|
|
4098
|
+
app-ship upload -c <config> -f <file> [--platform pgyer,huawei]
|
|
4099
|
+
app-ship platforms List supported platforms
|
|
4100
|
+
app-ship init Generate sample config file
|
|
4101
|
+
app-ship --help Show this help
|
|
4102
|
+
app-ship --version Show version
|
|
4103
|
+
|
|
4104
|
+
Upload Options:
|
|
4105
|
+
-c, --config <path> Config file path (YAML)
|
|
4106
|
+
-f, --file <path> App package file (APK/IPA/AAB/HAP)
|
|
4107
|
+
-p, --platform <list> Comma-separated platform list (optional, uses config if omitted)
|
|
4108
|
+
-v, --version <ver> Version string (default: from config or "1.0.0")
|
|
4109
|
+
-b, --build-number <n> Build number (default: "1")
|
|
4110
|
+
--changelog <text> Changelog / release notes
|
|
4111
|
+
--verbose Show detailed output
|
|
4112
|
+
`);
|
|
4113
|
+
}
|
|
4114
|
+
function parseArgs(argv) {
|
|
4115
|
+
const args = {};
|
|
4116
|
+
let i = 2;
|
|
4117
|
+
while (i < argv.length) {
|
|
4118
|
+
const arg = argv[i];
|
|
4119
|
+
if (arg === "--help" || arg === "-h") {
|
|
4120
|
+
args.help = true;
|
|
4121
|
+
} else if (arg === "--version") {
|
|
4122
|
+
args.showVersion = true;
|
|
4123
|
+
} else if (arg === "--verbose") {
|
|
4124
|
+
args.verbose = true;
|
|
4125
|
+
} else if ((arg === "-c" || arg === "--config") && argv[i + 1]) {
|
|
4126
|
+
args.config = argv[++i];
|
|
4127
|
+
} else if ((arg === "-f" || arg === "--file") && argv[i + 1]) {
|
|
4128
|
+
args.file = argv[++i];
|
|
4129
|
+
} else if ((arg === "-p" || arg === "--platform") && argv[i + 1]) {
|
|
4130
|
+
args.platform = argv[++i];
|
|
4131
|
+
} else if ((arg === "-v" || arg === "--ver") && argv[i + 1]) {
|
|
4132
|
+
args.ver = argv[++i];
|
|
4133
|
+
} else if ((arg === "-b" || arg === "--build-number") && argv[i + 1]) {
|
|
4134
|
+
args.buildNumber = argv[++i];
|
|
4135
|
+
} else if (arg === "--changelog" && argv[i + 1]) {
|
|
4136
|
+
args.changelog = argv[++i];
|
|
4137
|
+
} else if (!arg.startsWith("-")) {
|
|
4138
|
+
args.command = arg;
|
|
4139
|
+
}
|
|
4140
|
+
i++;
|
|
4141
|
+
}
|
|
4142
|
+
return args;
|
|
4143
|
+
}
|
|
4144
|
+
async function cmdUpload(args) {
|
|
4145
|
+
const configPath = args.config;
|
|
4146
|
+
const filePath = args.file;
|
|
4147
|
+
if (!configPath) {
|
|
4148
|
+
logger.error("Missing config file. Use: app-ship upload -c <config.yaml> -f <file>");
|
|
4149
|
+
process.exit(1);
|
|
4150
|
+
}
|
|
4151
|
+
if (!filePath) {
|
|
4152
|
+
logger.error("Missing file path. Use: app-ship upload -c <config.yaml> -f <file>");
|
|
4153
|
+
process.exit(1);
|
|
4154
|
+
}
|
|
4155
|
+
const absConfig = path10.resolve(configPath);
|
|
4156
|
+
const absFile = path10.resolve(filePath);
|
|
4157
|
+
if (!fs14.existsSync(absFile)) {
|
|
4158
|
+
logger.error(`File not found: ${absFile}`);
|
|
4159
|
+
process.exit(1);
|
|
4160
|
+
}
|
|
4161
|
+
const config = ConfigValidator.loadAndValidate(absConfig);
|
|
4162
|
+
if (!config) {
|
|
4163
|
+
logger.error("Config validation failed");
|
|
4164
|
+
process.exit(1);
|
|
4165
|
+
}
|
|
4166
|
+
const platforms = args.platform ? args.platform.split(",").map((s) => s.trim()) : void 0;
|
|
4167
|
+
const metadata = {
|
|
4168
|
+
version: args.ver || config.version || "1.0.0",
|
|
4169
|
+
buildNumber: args.buildNumber || "1",
|
|
4170
|
+
changelog: args.changelog || ""
|
|
4171
|
+
};
|
|
4172
|
+
logger.title("App Ship \u2014 Starting Upload");
|
|
4173
|
+
logger.info(`File: ${absFile}`);
|
|
4174
|
+
logger.info(`Config: ${absConfig}`);
|
|
4175
|
+
if (platforms) {
|
|
4176
|
+
logger.info(`Platforms: ${platforms.join(", ")}`);
|
|
4177
|
+
}
|
|
4178
|
+
logger.newLine();
|
|
4179
|
+
const manager = new UploadManager({
|
|
4180
|
+
maxConcurrent: 3,
|
|
4181
|
+
continueOnError: true,
|
|
4182
|
+
verbose: !!args.verbose
|
|
4183
|
+
});
|
|
4184
|
+
const report = await manager.uploadToMultiplePlatforms(absFile, config, metadata, platforms);
|
|
4185
|
+
process.exit(report.failureCount > 0 ? 1 : 0);
|
|
4186
|
+
}
|
|
4187
|
+
function cmdPlatforms() {
|
|
4188
|
+
const manager = new UploadManager();
|
|
4189
|
+
const platforms = manager.getAvailablePlatforms();
|
|
4190
|
+
logger.title("Supported Platforms");
|
|
4191
|
+
for (const p of platforms) {
|
|
4192
|
+
console.log(` \u2022 ${p}`);
|
|
4193
|
+
}
|
|
4194
|
+
console.log(`
|
|
4195
|
+
Total: ${platforms.length} platforms`);
|
|
4196
|
+
}
|
|
4197
|
+
function cmdInit() {
|
|
4198
|
+
const filename = "app-ship.yaml";
|
|
4199
|
+
if (fs14.existsSync(filename)) {
|
|
4200
|
+
logger.warn(`${filename} already exists. Skipping.`);
|
|
4201
|
+
return;
|
|
4202
|
+
}
|
|
4203
|
+
const sample = {
|
|
4204
|
+
version: "1.0.0",
|
|
4205
|
+
publish: {
|
|
4206
|
+
pgyer: {
|
|
4207
|
+
enable: true,
|
|
4208
|
+
apiKey: "YOUR_PGYER_API_KEY"
|
|
4209
|
+
},
|
|
4210
|
+
huawei: {
|
|
4211
|
+
enable: false,
|
|
4212
|
+
auth: {
|
|
4213
|
+
clientId: "",
|
|
4214
|
+
clientSecret: ""
|
|
4215
|
+
},
|
|
4216
|
+
appId: ""
|
|
4217
|
+
},
|
|
4218
|
+
xiaomi: {
|
|
4219
|
+
enable: false,
|
|
4220
|
+
auth: {
|
|
4221
|
+
userName: "",
|
|
4222
|
+
privateKey: ""
|
|
4223
|
+
},
|
|
4224
|
+
packageName: "com.example.app"
|
|
4225
|
+
},
|
|
4226
|
+
oppo: {
|
|
4227
|
+
enable: false,
|
|
4228
|
+
auth: {
|
|
4229
|
+
clientId: "",
|
|
4230
|
+
clientSecret: ""
|
|
4231
|
+
}
|
|
4232
|
+
},
|
|
4233
|
+
vivo: {
|
|
4234
|
+
enable: false,
|
|
4235
|
+
auth: {
|
|
4236
|
+
accessKey: "",
|
|
4237
|
+
accessSecret: ""
|
|
4238
|
+
}
|
|
4239
|
+
},
|
|
4240
|
+
tencent: {
|
|
4241
|
+
enable: false,
|
|
4242
|
+
auth: {
|
|
4243
|
+
appId: "",
|
|
4244
|
+
accessSecret: ""
|
|
4245
|
+
}
|
|
4246
|
+
},
|
|
4247
|
+
harmony: {
|
|
4248
|
+
enable: false,
|
|
4249
|
+
auth: {
|
|
4250
|
+
clientId: "",
|
|
4251
|
+
clientSecret: ""
|
|
4252
|
+
},
|
|
4253
|
+
appId: ""
|
|
4254
|
+
},
|
|
4255
|
+
app_store: {
|
|
4256
|
+
enable: false,
|
|
4257
|
+
auth: {
|
|
4258
|
+
appleId: "",
|
|
4259
|
+
appPassword: ""
|
|
4260
|
+
}
|
|
4261
|
+
}
|
|
4262
|
+
}
|
|
4263
|
+
};
|
|
4264
|
+
fs14.writeFileSync(filename, yaml2.dump(sample, { indent: 2 }), "utf8");
|
|
4265
|
+
logger.success(`Created ${filename}`);
|
|
4266
|
+
logger.info("Edit the file to configure your platform credentials.");
|
|
4267
|
+
}
|
|
4268
|
+
async function main() {
|
|
4269
|
+
const args = parseArgs(process.argv);
|
|
4270
|
+
if (args.help) {
|
|
4271
|
+
printHelp();
|
|
4272
|
+
return;
|
|
4273
|
+
}
|
|
4274
|
+
if (args.showVersion) {
|
|
4275
|
+
console.log(`app-ship v${VERSION}`);
|
|
4276
|
+
return;
|
|
4277
|
+
}
|
|
4278
|
+
const command = args.command;
|
|
4279
|
+
switch (command) {
|
|
4280
|
+
case "upload":
|
|
4281
|
+
await cmdUpload(args);
|
|
4282
|
+
break;
|
|
4283
|
+
case "platforms":
|
|
4284
|
+
cmdPlatforms();
|
|
4285
|
+
break;
|
|
4286
|
+
case "init":
|
|
4287
|
+
cmdInit();
|
|
4288
|
+
break;
|
|
4289
|
+
default:
|
|
4290
|
+
printHelp();
|
|
4291
|
+
break;
|
|
4292
|
+
}
|
|
4293
|
+
}
|
|
4294
|
+
main().catch((err) => {
|
|
4295
|
+
logger.error(err.message || String(err));
|
|
4296
|
+
process.exit(1);
|
|
4297
|
+
});
|
|
4298
|
+
//# sourceMappingURL=cli.js.map
|