@applift/core 0.4.0 → 0.6.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/dist/index.d.mts +225 -7
- package/dist/index.mjs +1173 -54
- package/dist/{plugin-DzhwzVfu.d.mts → plugin-B2GGlI_2.d.mts} +50 -31
- package/dist/plugins/index.d.mts +2 -2
- package/package.json +4 -1
package/dist/index.mjs
CHANGED
|
@@ -9,6 +9,8 @@ import { createJiti } from "jiti";
|
|
|
9
9
|
import crypto, { X509Certificate, constants, createHash, createHmac, createSign, publicEncrypt } from "node:crypto";
|
|
10
10
|
import { Readable } from "node:stream";
|
|
11
11
|
import { spawnSync } from "node:child_process";
|
|
12
|
+
import semver from "semver";
|
|
13
|
+
import { CommitParser } from "conventional-commits-parser";
|
|
12
14
|
//#region src/registry.ts
|
|
13
15
|
var DefaultChannelRegistry = class {
|
|
14
16
|
channels = /* @__PURE__ */ new Map();
|
|
@@ -39,15 +41,23 @@ profiles: z.record(z.string(), CredentialsProfileSchema) });
|
|
|
39
41
|
/** 单渠道配置(appId、pkgName 等渠道侧键) */
|
|
40
42
|
const StoreConfigSchema = z.record(z.string(), z.string());
|
|
41
43
|
/**
|
|
42
|
-
* stores
|
|
44
|
+
* stores 等价写法,统一规范化为 record:
|
|
43
45
|
* ① 对象:{ huawei: { appId: 'x' } }(原有写法,原样保留)
|
|
44
46
|
* ② 数组:['oppo', 'vivo'] → { oppo: {}, vivo: {} }(无配置渠道)
|
|
45
47
|
* ③ 字符串值:{ huawei: '10xxx' } → { huawei: { appId: '10xxx' } }(appId 简写)
|
|
48
|
+
* ④ 混排数组(README 首例):['huawei', { yyb: 'appId值' }] → 元素对象须单键 { 渠道: 值 },
|
|
49
|
+
* 值走 ③ 同款 appId 简写归一
|
|
46
50
|
*/
|
|
47
|
-
const
|
|
48
|
-
if (Array.isArray(stores)) return Object.fromEntries(stores.map((
|
|
51
|
+
const storesNormalize = (stores) => {
|
|
52
|
+
if (Array.isArray(stores)) return Object.fromEntries(stores.map((el) => typeof el === "string" ? [el, {}] : (() => {
|
|
53
|
+
const entries = Object.entries(el);
|
|
54
|
+
if (entries.length !== 1) throw new Error(`stores 数组内的对象元素必须形如 { 渠道id: appId简写 }(单键),收到: ${JSON.stringify(el)}`);
|
|
55
|
+
const [id, cfg] = entries[0];
|
|
56
|
+
return [id, typeof cfg === "string" ? { appId: cfg } : cfg];
|
|
57
|
+
})()));
|
|
49
58
|
return Object.fromEntries(Object.entries(stores).map(([id, cfg]) => [id, typeof cfg === "string" ? { appId: cfg } : cfg]));
|
|
50
|
-
}
|
|
59
|
+
};
|
|
60
|
+
const StoresSchema = z.union([z.record(z.string(), z.union([StoreConfigSchema, z.string()])), z.array(z.union([z.string(), StoreConfigSchema]))]).transform(storesNormalize).default({});
|
|
51
61
|
/** 项目级配置(applift.config.ts 的形状);beta 段见上方 BetaConfigSchema */
|
|
52
62
|
/**
|
|
53
63
|
* 内测分发配置(applift beta 无平台参数时并行发布的渠道开关):
|
|
@@ -56,6 +66,8 @@ const StoresSchema = z.union([z.record(z.string(), z.union([StoreConfigSchema, z
|
|
|
56
66
|
* - testflight: App Store Connect 上传(ipa 进 TestFlight)
|
|
57
67
|
*/
|
|
58
68
|
const BetaConfigSchema = z.object({
|
|
69
|
+
/** 自动 version:true(走推断)| major/minor/patch/prerelease | <semver>;CLI --bump/--no-bump 覆盖 */
|
|
70
|
+
bump: z.union([z.boolean(), z.string()]).optional(),
|
|
59
71
|
pgyer: z.object({
|
|
60
72
|
platforms: z.array(z.enum([
|
|
61
73
|
"android",
|
|
@@ -74,22 +86,35 @@ const BetaConfigSchema = z.object({
|
|
|
74
86
|
}).optional(),
|
|
75
87
|
testflight: z.object({ note: z.string().optional() }).optional()
|
|
76
88
|
});
|
|
89
|
+
/** versionCode 策略:"increment"(默认 +1) | 公式(可限定平台) */
|
|
90
|
+
const VersionCodeConfigSchema = z.union([z.literal("increment"), z.object({
|
|
91
|
+
/** 白名单表达式:major/minor/patch 与 + - * ( ),如 "major*1000000+minor*1000+patch" */
|
|
92
|
+
formula: z.string(),
|
|
93
|
+
/** 仅限平台(缺省全部);实际写入 = max(公式值, 现值+1) */
|
|
94
|
+
platforms: z.array(z.string()).optional()
|
|
95
|
+
})]);
|
|
96
|
+
const VersionConfigSchema = z.object({ versionCode: VersionCodeConfigSchema.default("increment") });
|
|
97
|
+
/** 正式发版段的自动 version 开关(语义同 beta.bump) */
|
|
98
|
+
const ReleaseConfigSchema = z.object({ bump: z.union([z.boolean(), z.string()]).optional() });
|
|
77
99
|
const ProjectConfigSchema = z.object({
|
|
78
100
|
/** 项目显示名 */
|
|
79
101
|
name: z.string().optional(),
|
|
80
102
|
/** 团队默认凭据 profile(个人用 applift use / --profile 覆盖),缺省 default */
|
|
81
103
|
profile: z.string().default("default"),
|
|
82
104
|
android: z.object({
|
|
83
|
-
|
|
105
|
+
/** 缺省经 detectBundleIds 从工程文件自动推导(README 首例即只有 stores 的最小配置) */
|
|
106
|
+
pkgName: z.string().optional(),
|
|
84
107
|
/** 渠道 → 渠道侧配置(appId 等);stores 里列出的渠道才发 */
|
|
85
108
|
stores: StoresSchema
|
|
86
109
|
}).optional(),
|
|
87
110
|
harmony: z.object({
|
|
88
|
-
|
|
111
|
+
/** 缺省自动推导(AppScope/app.json5) */
|
|
112
|
+
bundleName: z.string().optional(),
|
|
89
113
|
appId: z.string().optional()
|
|
90
114
|
}).optional(),
|
|
91
115
|
ios: z.object({
|
|
92
|
-
scheme
|
|
116
|
+
/** 缺省自动推导(工程 scheme 探测) */
|
|
117
|
+
scheme: z.string().optional(),
|
|
93
118
|
workspace: z.string().optional(),
|
|
94
119
|
/** App Store Connect 按 bundleId 定位 App(asc 渠道 submit 用) */
|
|
95
120
|
bundleId: z.string().optional()
|
|
@@ -110,6 +135,10 @@ const ProjectConfigSchema = z.object({
|
|
|
110
135
|
macos: z.array(z.string()).optional(),
|
|
111
136
|
linux: z.array(z.string()).optional()
|
|
112
137
|
}).catchall(z.array(z.string()).optional()).optional(),
|
|
138
|
+
/** 版本管理(versionCode 策略等) */
|
|
139
|
+
version: VersionConfigSchema.optional(),
|
|
140
|
+
/** 正式发版自动 version 开关 */
|
|
141
|
+
release: ReleaseConfigSchema.optional(),
|
|
113
142
|
/** 内测分发(beta 无平台参数时的多渠道并行配置) */
|
|
114
143
|
beta: BetaConfigSchema.optional()
|
|
115
144
|
});
|
|
@@ -190,14 +219,16 @@ function resolveProfile(creds, profile, knownKeys) {
|
|
|
190
219
|
for (const [envName, value] of Object.entries(process.env)) if (envName.startsWith("APPLIFT_") && value) merged[envName.slice(8)] = value;
|
|
191
220
|
return merged;
|
|
192
221
|
}
|
|
193
|
-
/**
|
|
222
|
+
/** 全局配置装载(缺失/损坏降级默认值;警告经返回值交给调用方,不阻断) */
|
|
194
223
|
function loadGlobalConfig(filePath = GLOBAL_CONFIG_PATH) {
|
|
195
|
-
if (!fs.existsSync(filePath)) return GlobalConfigSchema.parse({});
|
|
224
|
+
if (!fs.existsSync(filePath)) return { config: GlobalConfigSchema.parse({}) };
|
|
196
225
|
try {
|
|
197
|
-
return GlobalConfigSchema.parse(parse(fs.readFileSync(filePath, "utf8")));
|
|
226
|
+
return { config: GlobalConfigSchema.parse(parse(fs.readFileSync(filePath, "utf8"))) };
|
|
198
227
|
} catch (e) {
|
|
199
|
-
|
|
200
|
-
|
|
228
|
+
return {
|
|
229
|
+
config: GlobalConfigSchema.parse({}),
|
|
230
|
+
warning: `全局配置解析失败,使用默认值: ${e.message}`
|
|
231
|
+
};
|
|
201
232
|
}
|
|
202
233
|
}
|
|
203
234
|
/** 写凭据(目录自动创建,文件强制 0600) */
|
|
@@ -248,8 +279,13 @@ async function loadProjectConfig(cwd) {
|
|
|
248
279
|
packageJson: false,
|
|
249
280
|
defaults: ProjectConfigSchema.parse({})
|
|
250
281
|
});
|
|
282
|
+
const parsed = ProjectConfigSchema.safeParse(result.config ?? {});
|
|
283
|
+
if (!parsed.success) {
|
|
284
|
+
const details = parsed.error.issues.map((i) => `${i.path.join(".") || "(根)"}: ${i.message}`).join(";");
|
|
285
|
+
throw new Error(`applift.config.ts 字段校验失败 → ${details}`);
|
|
286
|
+
}
|
|
251
287
|
return {
|
|
252
|
-
config:
|
|
288
|
+
config: parsed.data,
|
|
253
289
|
raw: result.config ?? {},
|
|
254
290
|
cwd: result.cwd ?? cwd
|
|
255
291
|
};
|
|
@@ -352,8 +388,19 @@ async function formPost(url, params, { timeout = 6e4 } = {}) {
|
|
|
352
388
|
}
|
|
353
389
|
}
|
|
354
390
|
//#endregion
|
|
391
|
+
//#region src/channels/shared/file-blob.ts
|
|
392
|
+
/**
|
|
393
|
+
* 惰性文件 Blob:FormData multipart 上传的文件部件。
|
|
394
|
+
* 优先 fs.openAsBlob(Node ≥19.8,按需分片读,零整读内存);
|
|
395
|
+
* 能力检测不存在时回退整读(旧运行时兜底)。
|
|
396
|
+
*/
|
|
397
|
+
async function fileBlob(p) {
|
|
398
|
+
if (typeof fs.openAsBlob === "function") return fs.openAsBlob(p);
|
|
399
|
+
return new Blob([await fs.promises.readFile(p)]);
|
|
400
|
+
}
|
|
401
|
+
//#endregion
|
|
355
402
|
//#region src/util.ts
|
|
356
|
-
/** core
|
|
403
|
+
/** core 内部共用小工具(sleep/formatSize 仅内部使用;sha256File 经 index 导出,CLI 历史指纹用) */
|
|
357
404
|
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
|
|
358
405
|
/** 分块计算文件 SHA256(产物可达上百 MB,8MB 分块避免整读内存,同 sign.ts md5File 先例) */
|
|
359
406
|
function sha256File(filePath) {
|
|
@@ -395,7 +442,8 @@ async function pgyerPost(host, apiPath, form) {
|
|
|
395
442
|
const res = await fetch(`https://${host}${apiPath}`, {
|
|
396
443
|
method: "POST",
|
|
397
444
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
398
|
-
body
|
|
445
|
+
body,
|
|
446
|
+
signal: AbortSignal.timeout(3e4)
|
|
399
447
|
});
|
|
400
448
|
const text = await res.text();
|
|
401
449
|
try {
|
|
@@ -413,6 +461,8 @@ var PgyerChannel = class {
|
|
|
413
461
|
scheduled: false,
|
|
414
462
|
status: false
|
|
415
463
|
};
|
|
464
|
+
/** 平台错误码 → 人话(1247:源码注释已证 = buildKey 尚未就绪/COS 仍在入库) */
|
|
465
|
+
errorHints = { "1247": "buildKey 尚未就绪(COS 上传仍在入库),属处理中状态,稍候即可" };
|
|
416
466
|
async plan(ctx) {
|
|
417
467
|
return {
|
|
418
468
|
channel: this.id,
|
|
@@ -469,7 +519,7 @@ var PgyerChannel = class {
|
|
|
469
519
|
form.append("x-cos-security-token", params["x-cos-security-token"]);
|
|
470
520
|
form.append("key", params.key);
|
|
471
521
|
form.append("x-cos-meta-file-name", path.basename(ctx.artifactPath));
|
|
472
|
-
form.append("file",
|
|
522
|
+
form.append("file", await fileBlob(ctx.artifactPath), path.basename(ctx.artifactPath));
|
|
473
523
|
const upRes = await fetch(endpoint, {
|
|
474
524
|
method: "POST",
|
|
475
525
|
body: form,
|
|
@@ -952,6 +1002,11 @@ var XiaomiChannel = class {
|
|
|
952
1002
|
scheduled: false,
|
|
953
1003
|
status: true
|
|
954
1004
|
};
|
|
1005
|
+
/** 平台错误码 → 人话(result 码:依据为本仓测试已固化的真实失败路径文案) */
|
|
1006
|
+
errorHints = {
|
|
1007
|
+
"401": "签名无效或账号无该包名权限(检查 MI_CLIENT_SECRET 与 /dev/query 权限)",
|
|
1008
|
+
"1005": "push SIG 校验失败(检查证书公钥与密码是否匹配)"
|
|
1009
|
+
};
|
|
955
1010
|
async plan(_ctx) {
|
|
956
1011
|
return {
|
|
957
1012
|
channel: this.id,
|
|
@@ -1033,8 +1088,8 @@ var XiaomiChannel = class {
|
|
|
1033
1088
|
const form = new FormData();
|
|
1034
1089
|
form.append("RequestData", requestJson);
|
|
1035
1090
|
form.append("SIG", sig);
|
|
1036
|
-
form.append("apk",
|
|
1037
|
-
form.append("icon",
|
|
1091
|
+
form.append("apk", await fileBlob(ctx.artifactPath), fileName);
|
|
1092
|
+
form.append("icon", await fileBlob(iconPath), path.basename(iconPath));
|
|
1038
1093
|
const res = await fetch(`${MI_API_BASE}/dev/push`, {
|
|
1039
1094
|
method: "POST",
|
|
1040
1095
|
body: form,
|
|
@@ -1357,7 +1412,7 @@ var OppoChannel = class {
|
|
|
1357
1412
|
const form = new FormData();
|
|
1358
1413
|
form.append("type", "apk");
|
|
1359
1414
|
form.append("sign", sign);
|
|
1360
|
-
form.append("file",
|
|
1415
|
+
form.append("file", await fileBlob(ctx.artifactPath), fileName);
|
|
1361
1416
|
const upRes = await fetch(uploadUrl, {
|
|
1362
1417
|
method: "POST",
|
|
1363
1418
|
body: form,
|
|
@@ -1652,7 +1707,7 @@ var VivoChannel = class {
|
|
|
1652
1707
|
params.sign = hmacSign(accessSecret, params);
|
|
1653
1708
|
const form = new FormData();
|
|
1654
1709
|
for (const [k, v] of Object.entries(params)) form.append(k, v);
|
|
1655
|
-
form.append("file",
|
|
1710
|
+
form.append("file", await fileBlob(ctx.artifactPath), fileName);
|
|
1656
1711
|
const upRes = await fetch(API_BASE$2, {
|
|
1657
1712
|
method: "POST",
|
|
1658
1713
|
body: form,
|
|
@@ -1907,7 +1962,7 @@ var HonorChannel = class {
|
|
|
1907
1962
|
message: `上传 ${fileName}(${formatSize(fileSize)})`
|
|
1908
1963
|
});
|
|
1909
1964
|
const form = new FormData();
|
|
1910
|
-
form.append("file",
|
|
1965
|
+
form.append("file", await fileBlob(ctx.artifactPath), fileName);
|
|
1911
1966
|
const upRes = await fetch(uploadUrl, {
|
|
1912
1967
|
method: "POST",
|
|
1913
1968
|
headers: { Authorization: `Bearer ${token}` },
|
|
@@ -2099,6 +2154,8 @@ var YybChannel = class {
|
|
|
2099
2154
|
scheduled: true,
|
|
2100
2155
|
status: true
|
|
2101
2156
|
};
|
|
2157
|
+
/** 平台错误码 → 人话(ret 码:依据为本仓测试已固化的真实失败路径) */
|
|
2158
|
+
errorHints = { "10010": "应用不存在(检查 YYB_APP_ID 与 user_id 是否匹配该应用)" };
|
|
2102
2159
|
/** stage 产物暂存(serial_number + md5),submit 按 appId 取回 */
|
|
2103
2160
|
staged = /* @__PURE__ */ new Map();
|
|
2104
2161
|
required(ctx, key, where) {
|
|
@@ -2301,7 +2358,10 @@ const COMPILE_POLL_MS = 15e3;
|
|
|
2301
2358
|
const APP_TEST_BASE = "https://connect-api.cloud.huawei.com/api/app-test/v1";
|
|
2302
2359
|
/** AGC JSON API 封装:判 ret.code !== 0 抛错 —— v1 agc.mjs agcJson 原样 */
|
|
2303
2360
|
async function agcJson(url, init, context) {
|
|
2304
|
-
const res = await fetch(url,
|
|
2361
|
+
const res = await fetch(url, {
|
|
2362
|
+
...init,
|
|
2363
|
+
signal: init.signal ?? AbortSignal.timeout(6e4)
|
|
2364
|
+
});
|
|
2305
2365
|
let json;
|
|
2306
2366
|
try {
|
|
2307
2367
|
json = await res.json();
|
|
@@ -2359,7 +2419,7 @@ var AgcHarmonyChannel = class {
|
|
|
2359
2419
|
"1003": "client_id/client_secret 无效(AGC → 项目设置 → 常规 → API Client 重新获取)",
|
|
2360
2420
|
"204144702": "签名/证书不匹配:检查签名证书指纹(SHA256)与 AGC 后台登记是否一致",
|
|
2361
2421
|
"204144677": "bundleName 与应用不匹配:确认 appId 对应的应用包名",
|
|
2362
|
-
"204144692": "
|
|
2422
|
+
"204144692": "提审前置校验失败(按 msg 判断):privacyPolicy → 控制台「更多服务→协议服务」登记隐私政策;count of harmony test in audit → 在审测试版本超限,先停止/删除在审的测试版本再提"
|
|
2363
2423
|
};
|
|
2364
2424
|
async authHeaders(ctx) {
|
|
2365
2425
|
const clientId = required$1(ctx, "AGC_CLIENT_ID");
|
|
@@ -2688,6 +2748,7 @@ var AgcHarmonyChannel = class {
|
|
|
2688
2748
|
const groupName = ctx.target.testGroup?.trim();
|
|
2689
2749
|
if (groupName) {
|
|
2690
2750
|
const groupId = await this.resolveTestGroupId(ctx, appId, groupName, headers);
|
|
2751
|
+
const now = Date.now();
|
|
2691
2752
|
await agcJson(`${BASE}/publish/v2/test/app/version?appId=${encodeURIComponent(appId)}`, {
|
|
2692
2753
|
method: "PUT",
|
|
2693
2754
|
headers: {
|
|
@@ -2697,10 +2758,14 @@ var AgcHarmonyChannel = class {
|
|
|
2697
2758
|
body: JSON.stringify({
|
|
2698
2759
|
versionId,
|
|
2699
2760
|
pkgId,
|
|
2700
|
-
openTestInfo: {
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2761
|
+
openTestInfo: {
|
|
2762
|
+
startTime: now,
|
|
2763
|
+
endTime: now + 2592e6,
|
|
2764
|
+
testTaskInfo: { groupInfos: [{
|
|
2765
|
+
groupId,
|
|
2766
|
+
groupName
|
|
2767
|
+
}] }
|
|
2768
|
+
}
|
|
2704
2769
|
})
|
|
2705
2770
|
}, "绑定测试群组");
|
|
2706
2771
|
ctx.emit({
|
|
@@ -2756,10 +2821,13 @@ var AgcHarmonyChannel = class {
|
|
|
2756
2821
|
current: String(current),
|
|
2757
2822
|
pageSize: "50"
|
|
2758
2823
|
});
|
|
2759
|
-
const res = await fetch(`${APP_TEST_BASE}/test-group/list?${qs}`, {
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2824
|
+
const res = await fetch(`${APP_TEST_BASE}/test-group/list?${qs}`, {
|
|
2825
|
+
headers: {
|
|
2826
|
+
...headers,
|
|
2827
|
+
appId
|
|
2828
|
+
},
|
|
2829
|
+
signal: AbortSignal.timeout(6e4)
|
|
2830
|
+
});
|
|
2763
2831
|
if (!res.ok) throw new Error(`AGC 查询测试群组失败 HTTP ${res.status}`);
|
|
2764
2832
|
const json = await res.json();
|
|
2765
2833
|
if (Number(json.rtnCode) !== 0) throw new Error(`AGC 查询测试群组失败: rtnCode=${json.rtnCode ?? "?"}${json.rtnDesc ? ` ${json.rtnDesc}` : ""}`);
|
|
@@ -3224,6 +3292,8 @@ var GooglePlayChannel = class {
|
|
|
3224
3292
|
scheduled: false,
|
|
3225
3293
|
status: true
|
|
3226
3294
|
};
|
|
3295
|
+
/** 平台错误码 → 人话(HTTP 状态:依据为本仓测试已固化的失败路径) */
|
|
3296
|
+
errorHints = { "403": "权限不足(检查 Service Account 是否已关联 Play Console 且有发布权限)" };
|
|
3227
3297
|
async plan(_ctx) {
|
|
3228
3298
|
return {
|
|
3229
3299
|
channel: this.id,
|
|
@@ -3240,12 +3310,40 @@ var GooglePlayChannel = class {
|
|
|
3240
3310
|
ref: await this.uploadAndAssign(ctx, "internal", void 0)
|
|
3241
3311
|
};
|
|
3242
3312
|
}
|
|
3243
|
-
async submit(ctx) {
|
|
3313
|
+
async submit(ctx, stagedRef) {
|
|
3314
|
+
const reusedVc = stagedRef?.split(":")[1];
|
|
3315
|
+
if (reusedVc !== void 0 && reusedVc !== "") return {
|
|
3316
|
+
ok: true,
|
|
3317
|
+
ref: await this.assignExisting(ctx, "production", Number(reusedVc))
|
|
3318
|
+
};
|
|
3244
3319
|
return {
|
|
3245
3320
|
ok: true,
|
|
3246
3321
|
ref: await this.uploadAndAssign(ctx, "production", ctx.updateDescription)
|
|
3247
3322
|
};
|
|
3248
3323
|
}
|
|
3324
|
+
/** 复用 stage 已上传的 versionCode:开新 edit(仍需 auth)→ 部署 production → commit,跳过上传 */
|
|
3325
|
+
async assignExisting(ctx, track, versionCode) {
|
|
3326
|
+
const pkgName = this.requirePkgName(ctx);
|
|
3327
|
+
const token = await getAccessToken(ctx.credentials);
|
|
3328
|
+
ctx.emit({
|
|
3329
|
+
type: "phase",
|
|
3330
|
+
phase: "auth",
|
|
3331
|
+
message: "OAuth2 token 获取成功"
|
|
3332
|
+
});
|
|
3333
|
+
const edit = await apiFetch(`${API_BASE}/${pkgName}/edits`, token, {
|
|
3334
|
+
method: "POST",
|
|
3335
|
+
body: "{}"
|
|
3336
|
+
}, "创建 edit");
|
|
3337
|
+
if (!edit.id) throw new Error(`Google Play 创建 edit 响应缺少 id: ${JSON.stringify(edit).slice(0, 300)}`);
|
|
3338
|
+
const editId = edit.id;
|
|
3339
|
+
ctx.emit({
|
|
3340
|
+
type: "phase",
|
|
3341
|
+
phase: "upload",
|
|
3342
|
+
message: `复用 stage 上传的 versionCode ${versionCode}(跳过重复上传)`
|
|
3343
|
+
});
|
|
3344
|
+
await this.deployAndCommit(ctx, pkgName, token, editId, track, versionCode, ctx.updateDescription);
|
|
3345
|
+
return `${editId}:${versionCode}`;
|
|
3346
|
+
}
|
|
3249
3347
|
/** 上传 + 部署到指定轨道 + commit,返回 `editId:versionCode` */
|
|
3250
3348
|
async uploadAndAssign(ctx, track, releaseNotes) {
|
|
3251
3349
|
const pkgName = this.requirePkgName(ctx);
|
|
@@ -3284,6 +3382,15 @@ var GooglePlayChannel = class {
|
|
|
3284
3382
|
type: "progress",
|
|
3285
3383
|
detail: `上传成功(versionCode: ${versionCode})`
|
|
3286
3384
|
});
|
|
3385
|
+
return this.deployAndCommit(ctx, pkgName, token, editId, track, versionCode, releaseNotes);
|
|
3386
|
+
}
|
|
3387
|
+
/**
|
|
3388
|
+
* 部署 versionCode 到指定轨道并 commit:
|
|
3389
|
+
* 先 GET 轨道现有 releases 再合并 PUT——目标 release(同 status=completed 配置)追加新 versionCode,
|
|
3390
|
+
* 其余 releases 原样保留(status/userFraction 不变),避免整轨覆盖把旧版本从轨道剔除;
|
|
3391
|
+
* :commit 冒号语法(对拍 apkgo googleplay.go 权威;对 production 含提交审核语义,internal 即生效)
|
|
3392
|
+
*/
|
|
3393
|
+
async deployAndCommit(ctx, pkgName, token, editId, track, versionCode, releaseNotes) {
|
|
3287
3394
|
const releases = [...(await apiFetch(`${API_BASE}/${pkgName}/edits/${editId}/tracks/${track}`, token, { method: "GET" }, `读取 ${track} 轨道现有 releases`)).releases ?? []];
|
|
3288
3395
|
const release = {
|
|
3289
3396
|
versionCodes: [versionCode],
|
|
@@ -3351,6 +3458,11 @@ var GooglePlayChannel = class {
|
|
|
3351
3458
|
};
|
|
3352
3459
|
//#endregion
|
|
3353
3460
|
//#region src/pipeline.ts
|
|
3461
|
+
/**
|
|
3462
|
+
* 发布编排引擎:构建计划 → 确认协议 → 并发执行 → 汇总。
|
|
3463
|
+
* headless:确认通过注入的 confirm 回调完成(CLI 用 clack/弹窗),core 不碰终端。
|
|
3464
|
+
* 插件:可选 PluginHost,在 plan/stage/submit 生命周期触发钩子(可拦截可修改)。
|
|
3465
|
+
*/
|
|
3354
3466
|
async function runLimited(items, limit, fn) {
|
|
3355
3467
|
const queue = [...items];
|
|
3356
3468
|
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
@@ -3474,12 +3586,13 @@ async function runSubmit(input, options) {
|
|
|
3474
3586
|
label: channel.label,
|
|
3475
3587
|
ok: false,
|
|
3476
3588
|
submitError: "被插件跳过",
|
|
3477
|
-
plan: planFor(plans, basePlans, channel.id)
|
|
3589
|
+
plan: planFor(plans, basePlans, channel.id, ctx.artifactPath)
|
|
3478
3590
|
});
|
|
3479
3591
|
return;
|
|
3480
3592
|
}
|
|
3481
3593
|
effectiveCtx = r.value;
|
|
3482
3594
|
}
|
|
3595
|
+
let stagedRef;
|
|
3483
3596
|
if (!options.skipStage && channel.capabilities.stage) {
|
|
3484
3597
|
const stageResult = await channel.stage(effectiveCtx);
|
|
3485
3598
|
if (!stageResult.ok) {
|
|
@@ -3488,7 +3601,7 @@ async function runSubmit(input, options) {
|
|
|
3488
3601
|
label: channel.label,
|
|
3489
3602
|
ok: false,
|
|
3490
3603
|
stageError: stageResult.error,
|
|
3491
|
-
plan: planFor(plans, basePlans, channel.id)
|
|
3604
|
+
plan: planFor(plans, basePlans, channel.id, ctx.artifactPath)
|
|
3492
3605
|
});
|
|
3493
3606
|
input.historySink?.({
|
|
3494
3607
|
channel: channel.id,
|
|
@@ -3498,6 +3611,7 @@ async function runSubmit(input, options) {
|
|
|
3498
3611
|
});
|
|
3499
3612
|
return;
|
|
3500
3613
|
}
|
|
3614
|
+
stagedRef = stageResult.ref;
|
|
3501
3615
|
input.historySink?.({
|
|
3502
3616
|
channel: channel.id,
|
|
3503
3617
|
action: "stage",
|
|
@@ -3506,13 +3620,13 @@ async function runSubmit(input, options) {
|
|
|
3506
3620
|
artifactVersion: plans.find((p) => p.channel === channel.id)?.artifactVersion || void 0
|
|
3507
3621
|
});
|
|
3508
3622
|
}
|
|
3509
|
-
const r = await channel.submit(effectiveCtx);
|
|
3623
|
+
const r = await channel.submit(effectiveCtx, stagedRef);
|
|
3510
3624
|
summary.push({
|
|
3511
3625
|
channel: channel.id,
|
|
3512
3626
|
label: channel.label,
|
|
3513
3627
|
ok: r.ok,
|
|
3514
3628
|
submitError: r.error,
|
|
3515
|
-
plan: planFor(plans, basePlans, channel.id)
|
|
3629
|
+
plan: planFor(plans, basePlans, channel.id, ctx.artifactPath)
|
|
3516
3630
|
});
|
|
3517
3631
|
input.historySink?.({
|
|
3518
3632
|
channel: channel.id,
|
|
@@ -3527,7 +3641,7 @@ async function runSubmit(input, options) {
|
|
|
3527
3641
|
label: channel.label,
|
|
3528
3642
|
ok: false,
|
|
3529
3643
|
submitError: e.message,
|
|
3530
|
-
plan: planFor(plans, basePlans, channel.id)
|
|
3644
|
+
plan: planFor(plans, basePlans, channel.id, ctx.artifactPath)
|
|
3531
3645
|
});
|
|
3532
3646
|
input.historySink?.({
|
|
3533
3647
|
channel: channel.id,
|
|
@@ -3550,22 +3664,29 @@ async function safePlan(channel, ctx) {
|
|
|
3550
3664
|
try {
|
|
3551
3665
|
return await channel.plan(ctx);
|
|
3552
3666
|
} catch {
|
|
3553
|
-
return fallbackPlan(channel.id);
|
|
3667
|
+
return fallbackPlan(channel.id, ctx.artifactPath);
|
|
3554
3668
|
}
|
|
3555
3669
|
}
|
|
3556
|
-
/**
|
|
3557
|
-
function
|
|
3670
|
+
/** 按产物扩展名推断平台(兜底条目用):.ipa→ios,.hap→harmony,.apk/.aab 及其余→android */
|
|
3671
|
+
function platformOfArtifact(artifactPath) {
|
|
3672
|
+
const ext = path.extname(artifactPath).toLowerCase();
|
|
3673
|
+
if (ext === ".ipa") return "ios";
|
|
3674
|
+
if (ext === ".hap") return "harmony";
|
|
3675
|
+
return "android";
|
|
3676
|
+
}
|
|
3677
|
+
/** plan 失败/被插件删除时的兜底条目(保 summary.plan 非可选契约;平台按产物推断不再硬编码 android) */
|
|
3678
|
+
function fallbackPlan(channelId, artifactPath) {
|
|
3558
3679
|
return {
|
|
3559
3680
|
channel: channelId,
|
|
3560
|
-
platform:
|
|
3681
|
+
platform: platformOfArtifact(artifactPath),
|
|
3561
3682
|
artifactVersion: "",
|
|
3562
3683
|
action: "stage+submit",
|
|
3563
3684
|
afterApproval: "auto-publish"
|
|
3564
3685
|
};
|
|
3565
3686
|
}
|
|
3566
3687
|
/** 汇总条目取计划:plan:before 改写后的 plans 优先,被插件删除时回退改写前快照,永不返回 undefined */
|
|
3567
|
-
function planFor(plans, base, id) {
|
|
3568
|
-
return plans.find((p) => p.channel === id) ?? base.get(id) ?? fallbackPlan(id);
|
|
3688
|
+
function planFor(plans, base, id, artifactPath) {
|
|
3689
|
+
return plans.find((p) => p.channel === id) ?? base.get(id) ?? fallbackPlan(id, artifactPath);
|
|
3569
3690
|
}
|
|
3570
3691
|
//#endregion
|
|
3571
3692
|
//#region src/plugin.ts
|
|
@@ -3576,6 +3697,8 @@ var PluginHost = class {
|
|
|
3576
3697
|
}
|
|
3577
3698
|
/**
|
|
3578
3699
|
* 顺序执行某钩子;支持「链式替换」(返回对象则替换后继续传给下一插件)与「拦截」(返回 false 立即中止)。
|
|
3700
|
+
* 信任边界:返回值按 T 强转、不做形状校验(vite 同款取舍)——插件作者自负其责,
|
|
3701
|
+
* 坏形状会在后续消费点显式抛错,宿主不代为静默兜底。
|
|
3579
3702
|
* @returns {[被拦截?, 最终值]} false 表示被某插件拦截
|
|
3580
3703
|
*/
|
|
3581
3704
|
async apply(hook, value, ctx) {
|
|
@@ -3662,7 +3785,12 @@ const rnAdapter = {
|
|
|
3662
3785
|
artifactPatterns(platform) {
|
|
3663
3786
|
if (platform === "android") return ["android/app/build/outputs/apk/release/*-release.apk"];
|
|
3664
3787
|
if (platform === "ios") return ["build/ios/ipa/*.ipa"];
|
|
3665
|
-
return [
|
|
3788
|
+
return [
|
|
3789
|
+
"harmony/entry/build/default/outputs/**/entry-*-signed.hap",
|
|
3790
|
+
"harmony/entry/build/default/outputs/**/entry-*.app",
|
|
3791
|
+
"harmony/build/outputs/**/*.app",
|
|
3792
|
+
"build/outputs/**/*.app"
|
|
3793
|
+
];
|
|
3666
3794
|
},
|
|
3667
3795
|
buildCommand(platform) {
|
|
3668
3796
|
if (platform === "android") return [
|
|
@@ -3897,7 +4025,7 @@ function findArtifacts(cwd, patterns) {
|
|
|
3897
4025
|
return [...byPath].sort((a, b) => b[1] - a[1]).map(([p]) => p);
|
|
3898
4026
|
}
|
|
3899
4027
|
/** 文本读取(容错:不存在/不可读返回 null,绝不抛;detectBundleIds 各源共用) */
|
|
3900
|
-
function readText(filePath) {
|
|
4028
|
+
function readText$1(filePath) {
|
|
3901
4029
|
try {
|
|
3902
4030
|
return fs.readFileSync(filePath, "utf8");
|
|
3903
4031
|
} catch {
|
|
@@ -3922,7 +4050,7 @@ function gradleApplicationId(cwd) {
|
|
|
3922
4050
|
path.join(cwd, "android", "app", "build.gradle.kts")
|
|
3923
4051
|
];
|
|
3924
4052
|
for (const file of candidates) {
|
|
3925
|
-
const text = readText(file);
|
|
4053
|
+
const text = readText$1(file);
|
|
3926
4054
|
if (!text) continue;
|
|
3927
4055
|
const m = /(?:^|\s)applicationId\s*[=:]?\s*["']([^"'\s]+)["']/.exec(text);
|
|
3928
4056
|
if (m?.[1]) return m[1];
|
|
@@ -3930,7 +4058,7 @@ function gradleApplicationId(cwd) {
|
|
|
3930
4058
|
}
|
|
3931
4059
|
/** 鸿蒙 AppScope/app.json5 的 bundleName(json5 允许注释/尾逗号,正则取值而非 JSON 解析) */
|
|
3932
4060
|
function appJson5BundleName(file) {
|
|
3933
|
-
const text = readText(file);
|
|
4061
|
+
const text = readText$1(file);
|
|
3934
4062
|
return (text && /"bundleName"\s*:\s*"([^"]+)"/.exec(text))?.[1];
|
|
3935
4063
|
}
|
|
3936
4064
|
/** 两层子目录内找首个 *.xcodeproj:pbxproj 第一个非变量(含 $ 的 $(...) 与 ${...})的 PRODUCT_BUNDLE_IDENTIFIER + 工程名(Pods 已被 subdirs 排除) */
|
|
@@ -3944,7 +4072,7 @@ function findPbxproj(cwd) {
|
|
|
3944
4072
|
}
|
|
3945
4073
|
const projects = entries.filter((e) => e.isDirectory() && e.name.endsWith(".xcodeproj")).map((e) => e.name).sort();
|
|
3946
4074
|
for (const name of projects) {
|
|
3947
|
-
const pbx = readText(path.join(dir, name, "project.pbxproj"));
|
|
4075
|
+
const pbx = readText$1(path.join(dir, name, "project.pbxproj"));
|
|
3948
4076
|
if (!pbx) continue;
|
|
3949
4077
|
for (const m of pbx.matchAll(/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*([^;]+);/g)) {
|
|
3950
4078
|
const value = m[1].trim().replace(/^["']|["']$/g, "");
|
|
@@ -4045,14 +4173,1000 @@ function readEntries(query = {}, filePath = HISTORY_PATH) {
|
|
|
4045
4173
|
//#region src/channels/shared/error-codes.ts
|
|
4046
4174
|
/**
|
|
4047
4175
|
* 错误码人话化:消费各渠道自带 errorHints(平台内聚——表与渠道实现同文件)。
|
|
4176
|
+
* 网络类故障(AbortSignal 超时/断连/DNS)渠道层不转译,统一在此兜底提示。
|
|
4048
4177
|
*/
|
|
4178
|
+
/** 网络故障特征 → 人话提示(命中即返回,优先于渠道码表) */
|
|
4179
|
+
const NETWORK_HINTS = [[/aborted due to timeout|TimeoutError/i, "请求超时(服务端无响应或网络不稳):可重跑;大包上传建议在稳定网络环境执行"], [/\b(?:ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|EPIPE)\b|socket hang up|fetch failed/i, "网络故障(连接被重置/DNS 解析失败等):检查网络或代理后重跑"]];
|
|
4180
|
+
/** 码匹配:纯数字码加数字边界(防 1003 误中 11003/10031);其余按包含匹配 */
|
|
4181
|
+
function matchesCode(message, code) {
|
|
4182
|
+
if (/^\d+$/.test(code)) return new RegExp(`(?<!\\d)${code}(?!\\d)`).test(message);
|
|
4183
|
+
return message.includes(code);
|
|
4184
|
+
}
|
|
4049
4185
|
function humanizeError(hints, message) {
|
|
4186
|
+
for (const [re, hint] of NETWORK_HINTS) if (re.test(message)) return `${message}\n 提示: ${hint}`;
|
|
4050
4187
|
if (!hints) return message;
|
|
4051
|
-
for (const [code, hint] of Object.entries(hints)) if (message
|
|
4188
|
+
for (const [code, hint] of Object.entries(hints)) if (matchesCode(message, code)) return `${message}\n 提示: ${hint}`;
|
|
4052
4189
|
return message;
|
|
4053
4190
|
}
|
|
4054
4191
|
//#endregion
|
|
4055
|
-
//#region src/
|
|
4192
|
+
//#region src/channels/shared/redact.ts
|
|
4193
|
+
/**
|
|
4194
|
+
* 凭据脱敏(对拍 apkgo pkg/httptrace/redact.go 权威)。
|
|
4195
|
+
* 判定依据是“名字”:JSON 键 / 表单字段 / query 参数 / header 名携带凭据语义即整值掩码为
|
|
4196
|
+
* [REDACTED <len>](保留长度线索),空串保持空串——“签名为空”本身就是排查线索。
|
|
4197
|
+
* 非敏感名下的值仍递归扫内容(signed URL / 内嵌 JSON / name=value 自由文本)。
|
|
4198
|
+
* 故意分歧:掩码不做 percent-encode(日志可读性优先);JSON 再序列化保持原键序(Go map 序随机)。
|
|
4199
|
+
* 挂接点:CLI / 错误消息层接入(core 先提供能力)。
|
|
4200
|
+
*/
|
|
4201
|
+
/** 归一化后「包含即敏感」的词(对拍 redact.go sensitiveWords) */
|
|
4202
|
+
const SENSITIVE_WORDS = [
|
|
4203
|
+
"secret",
|
|
4204
|
+
"token",
|
|
4205
|
+
"password",
|
|
4206
|
+
"passwd",
|
|
4207
|
+
"authorization",
|
|
4208
|
+
"credential",
|
|
4209
|
+
"cookie",
|
|
4210
|
+
"privatekey",
|
|
4211
|
+
"apikey",
|
|
4212
|
+
"assertion",
|
|
4213
|
+
"jwt",
|
|
4214
|
+
"signature"
|
|
4215
|
+
];
|
|
4216
|
+
/** 归一化后全等即敏感的名字(对拍 redact.go sensitiveNames) */
|
|
4217
|
+
const SENSITIVE_NAMES = {
|
|
4218
|
+
sig: true,
|
|
4219
|
+
sign: true,
|
|
4220
|
+
pwd: true,
|
|
4221
|
+
auth: true
|
|
4222
|
+
};
|
|
4223
|
+
/** 名字是否携带凭据语义(JSON 键 / 表单字段 / query 参数 / header 名通用;归一化 = 小写去非字母数字) */
|
|
4224
|
+
function isSensitiveName(name) {
|
|
4225
|
+
const n = name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
4226
|
+
if (n in SENSITIVE_NAMES) return true;
|
|
4227
|
+
if (n === "tokentype" || n.startsWith("accesscontrol")) return false;
|
|
4228
|
+
if (n.endsWith("sign") && n !== "design") return true;
|
|
4229
|
+
return SENSITIVE_WORDS.some((w) => n.includes(w));
|
|
4230
|
+
}
|
|
4231
|
+
/** 整值掩码:保留长度;空串保持(“签名为空”是有效诊断信息)。3 处调用点需锁步一致 */
|
|
4232
|
+
function mask(v) {
|
|
4233
|
+
return v === "" ? "" : `[REDACTED ${v.length}]`;
|
|
4234
|
+
}
|
|
4235
|
+
/**
|
|
4236
|
+
* 按名字脱敏单个值:敏感名 → 整值掩码;Authorization 保留 scheme("Bearer [REDACTED 812]",
|
|
4237
|
+
* 对拍 redact.go redactHeaders);非敏感名 → 递归 redactText 扫值内凭据。
|
|
4238
|
+
*/
|
|
4239
|
+
function redactValue(name, v) {
|
|
4240
|
+
if (/^authorization$/i.test(name) && v.includes(" ")) {
|
|
4241
|
+
const schemeEnd = v.indexOf(" ");
|
|
4242
|
+
return `${v.slice(0, schemeEnd)} ${mask(v.slice(schemeEnd + 1))}`;
|
|
4243
|
+
}
|
|
4244
|
+
return isSensitiveName(name) ? mask(v) : redactText(v);
|
|
4245
|
+
}
|
|
4246
|
+
/** redactURL:剥离 userinfo + query 参数按名脱敏,路径/fragment 原样保留 */
|
|
4247
|
+
function redactUrlString(v) {
|
|
4248
|
+
let out = v.replace(/^(https?:\/\/)[^/?#]*@/, "$1");
|
|
4249
|
+
const hashIdx = out.indexOf("#");
|
|
4250
|
+
const hash = hashIdx >= 0 ? out.slice(hashIdx) : "";
|
|
4251
|
+
if (hashIdx >= 0) out = out.slice(0, hashIdx);
|
|
4252
|
+
const qIdx = out.indexOf("?");
|
|
4253
|
+
if (qIdx < 0) return out + hash;
|
|
4254
|
+
const query = new URLSearchParams(out.slice(qIdx + 1));
|
|
4255
|
+
const pairs = [];
|
|
4256
|
+
for (const [k, val] of query) pairs.push(`${k}=${isSensitiveName(k) ? mask(val) : val}`);
|
|
4257
|
+
return `${out.slice(0, qIdx)}?${pairs.join("&")}${hash}`;
|
|
4258
|
+
}
|
|
4259
|
+
/** free 文本中的 name=value / "name":"value" 凭据对(对拍 redact.go scrubRe;掩码无长度) */
|
|
4260
|
+
const SCRUB_RE = /([\w.-]*(?:secret|token|password|passwd|signature|credential|authorization|api_?key|assertion)[\w.-]*"?\s*[=:]\s*"?)([^"&\s,;<}]+)/gi;
|
|
4261
|
+
/** JSON 树递归:敏感名下字符串掩码、数字/布尔置 [REDACTED],数组元素继承外层键名 */
|
|
4262
|
+
function redactTree(name, v) {
|
|
4263
|
+
if (v === null || typeof v !== "object") {
|
|
4264
|
+
if (typeof v === "string") return redactValue(name, v);
|
|
4265
|
+
return isSensitiveName(name) ? "[REDACTED]" : v;
|
|
4266
|
+
}
|
|
4267
|
+
if (Array.isArray(v)) return v.map((x) => redactTree(name, x));
|
|
4268
|
+
const out = {};
|
|
4269
|
+
for (const [k, val] of Object.entries(v)) out[k] = redactTree(k, val);
|
|
4270
|
+
return out;
|
|
4271
|
+
}
|
|
4272
|
+
/**
|
|
4273
|
+
* 文本脱敏(三层层叠,逐层尝试):
|
|
4274
|
+
* 1) http(s) URL 带 query/userinfo → query 按名脱敏 + 剥 userinfo
|
|
4275
|
+
* 2) { / [ 开头且整串为合法 JSON → 递归按键名脱敏
|
|
4276
|
+
* 3) 其余 → name=value 正则扫(错误消息等无结构文本)
|
|
4277
|
+
*/
|
|
4278
|
+
function redactText(v) {
|
|
4279
|
+
if (/^https?:\/\//i.test(v) && (v.includes("?") || /^https?:\/\/[^/?#]*@/i.test(v))) return redactUrlString(v);
|
|
4280
|
+
if (v.length > 1 && (v.startsWith("{") || v.startsWith("["))) try {
|
|
4281
|
+
return JSON.stringify(redactTree("", JSON.parse(v)));
|
|
4282
|
+
} catch {}
|
|
4283
|
+
return v.replace(SCRUB_RE, "$1[REDACTED]");
|
|
4284
|
+
}
|
|
4285
|
+
//#endregion
|
|
4286
|
+
//#region src/version/project.ts
|
|
4287
|
+
/**
|
|
4288
|
+
* 工程文件版本读写(可接管模式:版本事实源永远是工程文件,applift 只代读代写)。
|
|
4289
|
+
* 只认标准模板形态(AGP/Xcode/DevEco/pubspec/package.json/tauri.conf);行级改写、
|
|
4290
|
+
* 幂等(字段定位 + 写死目标值,不依赖旧值,跑 N 次 diff 不变——qy-driver sync-app-identity 同款模式)。
|
|
4291
|
+
* version catalog 引用形态(libs.versions.xxx.get())读侧识别并标注 problem,不代写。
|
|
4292
|
+
*/
|
|
4293
|
+
function readText(p) {
|
|
4294
|
+
try {
|
|
4295
|
+
return fs.readFileSync(p, "utf8");
|
|
4296
|
+
} catch {
|
|
4297
|
+
return null;
|
|
4298
|
+
}
|
|
4299
|
+
}
|
|
4300
|
+
function firstExisting(...paths) {
|
|
4301
|
+
return paths.find((p) => fs.existsSync(p));
|
|
4302
|
+
}
|
|
4303
|
+
function locateIosProject(dir) {
|
|
4304
|
+
const find = (d) => {
|
|
4305
|
+
try {
|
|
4306
|
+
return fs.readdirSync(d, { withFileTypes: true }).find((e) => e.isDirectory() && e.name.endsWith(".xcodeproj") && !e.name.startsWith(".") && e.name !== "Pods.xcodeproj");
|
|
4307
|
+
} catch {
|
|
4308
|
+
return;
|
|
4309
|
+
}
|
|
4310
|
+
};
|
|
4311
|
+
let xcodeproj = find(dir);
|
|
4312
|
+
let base = dir;
|
|
4313
|
+
if (!xcodeproj) try {
|
|
4314
|
+
const subs = fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules" && e.name !== "Pods");
|
|
4315
|
+
for (const s of subs) {
|
|
4316
|
+
xcodeproj = find(path.join(dir, s.name));
|
|
4317
|
+
if (xcodeproj) {
|
|
4318
|
+
base = path.join(dir, s.name);
|
|
4319
|
+
break;
|
|
4320
|
+
}
|
|
4321
|
+
}
|
|
4322
|
+
} catch {}
|
|
4323
|
+
const pbxproj = xcodeproj ? path.join(base, xcodeproj.name, "project.pbxproj") : void 0;
|
|
4324
|
+
const projDir = xcodeproj?.name.replace(/\.xcodeproj$/, "");
|
|
4325
|
+
const plist = projDir && fs.existsSync(path.join(base, projDir, "Info.plist")) ? path.join(base, projDir, "Info.plist") : void 0;
|
|
4326
|
+
return {
|
|
4327
|
+
pbxproj: pbxproj && fs.existsSync(pbxproj) ? pbxproj : void 0,
|
|
4328
|
+
plist
|
|
4329
|
+
};
|
|
4330
|
+
}
|
|
4331
|
+
function locateVersionFiles(cwd, framework) {
|
|
4332
|
+
const loc = {};
|
|
4333
|
+
const id = framework?.id ?? "";
|
|
4334
|
+
const tryAll = framework === null;
|
|
4335
|
+
if (tryAll || id === "rn" || id === "native-android" || id === "flutter") loc.android = firstExisting(path.join(cwd, "android", "app", "build.gradle.kts"), path.join(cwd, "android", "app", "build.gradle"), path.join(cwd, "app", "build.gradle.kts"), path.join(cwd, "app", "build.gradle"));
|
|
4336
|
+
if (tryAll || id === "rn" || id === "native-ios" || id === "flutter") {
|
|
4337
|
+
const sub = locateIosProject(path.join(cwd, "ios"));
|
|
4338
|
+
const root = sub.pbxproj ? sub : locateIosProject(cwd);
|
|
4339
|
+
loc.ios = root.pbxproj;
|
|
4340
|
+
loc.plist = root.plist;
|
|
4341
|
+
}
|
|
4342
|
+
if (tryAll || id === "rn" || id === "native-harmony" || id === "flutter") loc.harmony = firstExisting(path.join(cwd, "harmony", "AppScope", "app.json5"), path.join(cwd, "AppScope", "app.json5"));
|
|
4343
|
+
if (tryAll || id === "flutter") loc.pubspec = fs.existsSync(path.join(cwd, "pubspec.yaml")) ? path.join(cwd, "pubspec.yaml") : void 0;
|
|
4344
|
+
if (tryAll || id === "rn" || id === "electron") loc.packageJson = fs.existsSync(path.join(cwd, "package.json")) ? path.join(cwd, "package.json") : void 0;
|
|
4345
|
+
if (tryAll || id === "tauri") {
|
|
4346
|
+
loc.tauri = firstExisting(path.join(cwd, "src-tauri", "tauri.conf.json"), path.join(cwd, "src-tauri", "tauri.conf.json5"));
|
|
4347
|
+
loc.packageJson = fs.existsSync(path.join(cwd, "package.json")) ? path.join(cwd, "package.json") : void 0;
|
|
4348
|
+
}
|
|
4349
|
+
if (id === "rn") {
|
|
4350
|
+
const appJson = readText(path.join(cwd, "app.json"));
|
|
4351
|
+
if (appJson && /"expo"\s*:/.test(appJson)) loc.expoAppJson = path.join(cwd, "app.json");
|
|
4352
|
+
}
|
|
4353
|
+
return loc;
|
|
4354
|
+
}
|
|
4355
|
+
function readGradle(file) {
|
|
4356
|
+
const text = readText(file);
|
|
4357
|
+
if (!text) return {};
|
|
4358
|
+
const out = {};
|
|
4359
|
+
const codeM = /(versionCode(?!Suffix)\s*=?\s*)(\d+)/.exec(text);
|
|
4360
|
+
const nameM = /(versionName(?!Suffix)\s*=?\s*)("[^"]*"|'[^']*'|[\w.+-]+)/.exec(text);
|
|
4361
|
+
if (codeM) out.versionCode = codeM[2];
|
|
4362
|
+
if (nameM) {
|
|
4363
|
+
const nameVal = nameM[2].replace(/^["']|["']$/g, "");
|
|
4364
|
+
if (nameVal.startsWith("libs.versions.")) out.problem = "versionName 走 version catalog 引用(libs.versions.*):非标准形态,请把版本字段搬回 app/build.gradle(.kts) 字面量";
|
|
4365
|
+
else out.versionName = nameVal;
|
|
4366
|
+
}
|
|
4367
|
+
if (/(versionCode|versionName)\s*=?\s*libs\.versions\./.test(text) && !out.problem) out.problem = "版本字段走 version catalog 引用(libs.versions.*):非标准形态,请把版本字段搬回 app/build.gradle(.kts) 字面量";
|
|
4368
|
+
return out;
|
|
4369
|
+
}
|
|
4370
|
+
function readPbxproj(file) {
|
|
4371
|
+
const text = readText(file);
|
|
4372
|
+
if (!text) return {};
|
|
4373
|
+
const names = [...text.matchAll(/MARKETING_VERSION\s*=\s*([^;]+);/g)].map((m) => m[1].trim());
|
|
4374
|
+
return {
|
|
4375
|
+
versionName: names[0],
|
|
4376
|
+
versionCode: /CURRENT_PROJECT_VERSION\s*=\s*([^;]+);/.exec(text)?.[1]?.trim(),
|
|
4377
|
+
problem: new Set(names).size > 1 ? `多 target MARKETING_VERSION 不一致(${[...new Set(names)].join(" / ")}):读第一个,bump 时将全部同步` : void 0
|
|
4378
|
+
};
|
|
4379
|
+
}
|
|
4380
|
+
function readPlist(file) {
|
|
4381
|
+
const text = readText(file);
|
|
4382
|
+
if (!text) return {};
|
|
4383
|
+
const name = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/.exec(text)?.[1];
|
|
4384
|
+
const code = /<key>CFBundleVersion<\/key>\s*<string>([^<]+)<\/string>/.exec(text)?.[1];
|
|
4385
|
+
if (name && !name.startsWith("$")) return {
|
|
4386
|
+
versionName: name,
|
|
4387
|
+
versionCode: code
|
|
4388
|
+
};
|
|
4389
|
+
return {};
|
|
4390
|
+
}
|
|
4391
|
+
function readAppJson5(file) {
|
|
4392
|
+
const text = readText(file);
|
|
4393
|
+
if (!text) return {};
|
|
4394
|
+
return {
|
|
4395
|
+
versionName: /"versionName"\s*:\s*"([^"]+)"/.exec(text)?.[1],
|
|
4396
|
+
versionCode: /"versionCode"\s*:\s*(\d+)/.exec(text)?.[1]
|
|
4397
|
+
};
|
|
4398
|
+
}
|
|
4399
|
+
function readPubspec(file) {
|
|
4400
|
+
const text = readText(file);
|
|
4401
|
+
if (!text) return {};
|
|
4402
|
+
const m = /^version:\s*(\S+)(?:\s*#.*)?$/m.exec(text);
|
|
4403
|
+
if (!m) return {};
|
|
4404
|
+
const [name, code] = m[1].split("+");
|
|
4405
|
+
return {
|
|
4406
|
+
versionName: name,
|
|
4407
|
+
versionCode: code
|
|
4408
|
+
};
|
|
4409
|
+
}
|
|
4410
|
+
function readJsonVersionKey(file) {
|
|
4411
|
+
const text = readText(file);
|
|
4412
|
+
if (!text) return {};
|
|
4413
|
+
let best;
|
|
4414
|
+
for (const m of text.matchAll(/^(\s*)"version"\s*:\s*"([^"]+)"\s*,?\s*$/gm)) {
|
|
4415
|
+
const indent = m[1].length;
|
|
4416
|
+
if (!best || indent < best.indent) best = {
|
|
4417
|
+
indent,
|
|
4418
|
+
value: m[2]
|
|
4419
|
+
};
|
|
4420
|
+
}
|
|
4421
|
+
if (best) return { versionName: best.value };
|
|
4422
|
+
try {
|
|
4423
|
+
const obj = JSON.parse(text);
|
|
4424
|
+
if (typeof obj.version === "string") return { versionName: obj.version };
|
|
4425
|
+
} catch {}
|
|
4426
|
+
const flat = /"version"\s*:\s*"([^"]+)"/.exec(text)?.[1];
|
|
4427
|
+
return flat ? { versionName: flat } : {};
|
|
4428
|
+
}
|
|
4429
|
+
function readExpoAppJson(file) {
|
|
4430
|
+
const text = readText(file);
|
|
4431
|
+
if (!text) return {};
|
|
4432
|
+
return {
|
|
4433
|
+
versionName: /"version"\s*:\s*"([^"]+)"/.exec(text)?.[1],
|
|
4434
|
+
versionCode: /"versionCode"\s*:\s*(\d+)/.exec(text)?.[1] ?? /"buildNumber"\s*:\s*"([^"]+)"/.exec(text)?.[1]
|
|
4435
|
+
};
|
|
4436
|
+
}
|
|
4437
|
+
/** 各端版本实况(按框架布局;未识别框架时尝试全部标准位置) */
|
|
4438
|
+
function readProjectVersions(cwd, framework) {
|
|
4439
|
+
const loc = locateVersionFiles(cwd, framework);
|
|
4440
|
+
const out = [];
|
|
4441
|
+
if (loc.android) out.push({
|
|
4442
|
+
platform: "android",
|
|
4443
|
+
file: loc.android,
|
|
4444
|
+
kind: "gradle",
|
|
4445
|
+
...readGradle(loc.android)
|
|
4446
|
+
});
|
|
4447
|
+
if (loc.ios) out.push({
|
|
4448
|
+
platform: "ios",
|
|
4449
|
+
file: loc.ios,
|
|
4450
|
+
kind: "pbxproj",
|
|
4451
|
+
...readPbxproj(loc.ios)
|
|
4452
|
+
});
|
|
4453
|
+
else if (loc.plist) {
|
|
4454
|
+
const plist = readPlist(loc.plist);
|
|
4455
|
+
if (plist.versionName) out.push({
|
|
4456
|
+
platform: "ios",
|
|
4457
|
+
file: loc.plist,
|
|
4458
|
+
kind: "pbxproj",
|
|
4459
|
+
...plist,
|
|
4460
|
+
problem: "未找到 pbxproj,读 Info.plist 字面量"
|
|
4461
|
+
});
|
|
4462
|
+
}
|
|
4463
|
+
if (loc.harmony) out.push({
|
|
4464
|
+
platform: "harmony",
|
|
4465
|
+
file: loc.harmony,
|
|
4466
|
+
kind: "app-json5",
|
|
4467
|
+
...readAppJson5(loc.harmony)
|
|
4468
|
+
});
|
|
4469
|
+
if (loc.pubspec) out.push({
|
|
4470
|
+
platform: "flutter",
|
|
4471
|
+
file: loc.pubspec,
|
|
4472
|
+
kind: "pubspec",
|
|
4473
|
+
...readPubspec(loc.pubspec)
|
|
4474
|
+
});
|
|
4475
|
+
if (loc.packageJson) out.push({
|
|
4476
|
+
platform: "js",
|
|
4477
|
+
file: loc.packageJson,
|
|
4478
|
+
kind: "package-json",
|
|
4479
|
+
...readJsonVersionKey(loc.packageJson)
|
|
4480
|
+
});
|
|
4481
|
+
if (loc.tauri) out.push({
|
|
4482
|
+
platform: "tauri",
|
|
4483
|
+
file: loc.tauri,
|
|
4484
|
+
kind: "tauri",
|
|
4485
|
+
...readJsonVersionKey(loc.tauri)
|
|
4486
|
+
});
|
|
4487
|
+
if (loc.expoAppJson) out.push({
|
|
4488
|
+
platform: "expo",
|
|
4489
|
+
file: loc.expoAppJson,
|
|
4490
|
+
kind: "expo-app-json",
|
|
4491
|
+
...readExpoAppJson(loc.expoAppJson)
|
|
4492
|
+
});
|
|
4493
|
+
return out;
|
|
4494
|
+
}
|
|
4495
|
+
/** 指定捕获组替换为 value(其余组原样保留;约定各正则组外无字面量) */
|
|
4496
|
+
function replaceGroup(re, text, group, value) {
|
|
4497
|
+
let count = 0;
|
|
4498
|
+
return {
|
|
4499
|
+
text: text.replace(re, (_m, ...groups) => {
|
|
4500
|
+
count++;
|
|
4501
|
+
const g = groups.slice(0, -2);
|
|
4502
|
+
g[group - 1] = value;
|
|
4503
|
+
return g.join("");
|
|
4504
|
+
}),
|
|
4505
|
+
count
|
|
4506
|
+
};
|
|
4507
|
+
}
|
|
4508
|
+
function writeIfChanged(file, next) {
|
|
4509
|
+
if (readText(file) === next) return false;
|
|
4510
|
+
fs.writeFileSync(file, next, "utf8");
|
|
4511
|
+
return true;
|
|
4512
|
+
}
|
|
4513
|
+
function writeGradle(file, w) {
|
|
4514
|
+
const text = readText(file);
|
|
4515
|
+
if (!text) throw new Error(`无法读取 ${file}`);
|
|
4516
|
+
if (/version(Code|Name)\s*=?\s*libs\.versions\./.test(text)) throw new Error(`${file} 的版本字段是 version catalog 引用形态:请先改造为字面量(applift 只写标准形态)`);
|
|
4517
|
+
let next = text;
|
|
4518
|
+
const code = replaceGroup(/(versionCode(?!Suffix)\s*=?\s*)(\d+)/g, next, 2, String(w.versionCode));
|
|
4519
|
+
next = code.text;
|
|
4520
|
+
const name = replaceGroup(/(versionName(?!Suffix)\s*=?\s*)("[^"]*"|'[^']*'|[\w.+-]+)/g, next, 2, `"${w.versionName}"`);
|
|
4521
|
+
next = name.text;
|
|
4522
|
+
if (code.count === 0 && name.count === 0) throw new Error(`${file} 未找到 versionCode/versionName 字面量`);
|
|
4523
|
+
return writeIfChanged(file, next);
|
|
4524
|
+
}
|
|
4525
|
+
function writePbxproj(file, w) {
|
|
4526
|
+
const text = readText(file);
|
|
4527
|
+
if (!text) throw new Error(`无法读取 ${file}`);
|
|
4528
|
+
let next = text;
|
|
4529
|
+
const name = replaceGroup(/(MARKETING_VERSION\s*=\s*)([^;]+)(;)/g, next, 2, w.versionName);
|
|
4530
|
+
next = name.text;
|
|
4531
|
+
const code = replaceGroup(/(CURRENT_PROJECT_VERSION\s*=\s*)([^;]+)(;)/g, next, 2, String(w.versionCode));
|
|
4532
|
+
next = code.text;
|
|
4533
|
+
if (name.count === 0 && code.count === 0) throw new Error(`${file} 未找到 MARKETING_VERSION/CURRENT_PROJECT_VERSION`);
|
|
4534
|
+
return writeIfChanged(file, next);
|
|
4535
|
+
}
|
|
4536
|
+
function writeAppJson5(file, w) {
|
|
4537
|
+
const text = readText(file);
|
|
4538
|
+
if (!text) throw new Error(`无法读取 ${file}`);
|
|
4539
|
+
let next = text;
|
|
4540
|
+
const name = replaceGroup(/("versionName"\s*:\s*")([^"]+)(")/g, next, 2, w.versionName);
|
|
4541
|
+
next = name.text;
|
|
4542
|
+
const code = replaceGroup(/("versionCode"\s*:\s*)(\d+)/g, next, 2, String(w.versionCode));
|
|
4543
|
+
next = code.text;
|
|
4544
|
+
if (name.count === 0 && code.count === 0) throw new Error(`${file} 未找到 versionName/versionCode`);
|
|
4545
|
+
return writeIfChanged(file, next);
|
|
4546
|
+
}
|
|
4547
|
+
function writePubspec(file, w) {
|
|
4548
|
+
const text = readText(file);
|
|
4549
|
+
if (!text) throw new Error(`无法读取 ${file}`);
|
|
4550
|
+
if (!/^version:\s*\S+/m.test(text)) throw new Error(`${file} 未找到 version: 行`);
|
|
4551
|
+
return writeIfChanged(file, text.replace(/^version:[^\n#]*?(\s*)(#.*)?$/m, (_m, gap, comment) => `version: ${w.versionName}+${w.versionCode}${comment ? gap ?? " " : ""}${comment ?? ""}`));
|
|
4552
|
+
}
|
|
4553
|
+
/** JSON 顶层 version 键改写(两遍:先定位最小缩进行,再只改该行——单遍回调在嵌套键
|
|
4554
|
+
* 先出现时会把它一并改写,篡改依赖声明)。单行 JSON 回退首个匹配。 */
|
|
4555
|
+
function writeJsonVersionKey(file, versionName) {
|
|
4556
|
+
const text = readText(file);
|
|
4557
|
+
if (!text) throw new Error(`无法读取 ${file}`);
|
|
4558
|
+
const lines = text.split("\n");
|
|
4559
|
+
const re = /^(\s*)"version"\s*:\s*"([^"]+)"(\s*,?\s*)$/;
|
|
4560
|
+
let target = -1;
|
|
4561
|
+
let bestIndent = Number.POSITIVE_INFINITY;
|
|
4562
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4563
|
+
const m = re.exec(lines[i]);
|
|
4564
|
+
if (!m) continue;
|
|
4565
|
+
const ind = m[1].length;
|
|
4566
|
+
if (ind < bestIndent) {
|
|
4567
|
+
bestIndent = ind;
|
|
4568
|
+
target = i;
|
|
4569
|
+
}
|
|
4570
|
+
}
|
|
4571
|
+
let next;
|
|
4572
|
+
if (target >= 0) {
|
|
4573
|
+
const m = re.exec(lines[target]);
|
|
4574
|
+
lines[target] = `${m[1]}"version": "${versionName}"${m[3]}`;
|
|
4575
|
+
next = lines.join("\n");
|
|
4576
|
+
} else {
|
|
4577
|
+
let topVersion;
|
|
4578
|
+
try {
|
|
4579
|
+
const obj = JSON.parse(text);
|
|
4580
|
+
if (typeof obj.version === "string") topVersion = obj.version;
|
|
4581
|
+
} catch {}
|
|
4582
|
+
const esc = (topVersion ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4583
|
+
const re1 = topVersion ? new RegExp(`"version"\\s*:\\s*"${esc}"`) : /"version"\s*:\s*"([^"]+)"/;
|
|
4584
|
+
if (!re1.test(text)) throw new Error(`${file} 未找到 "version" 键`);
|
|
4585
|
+
next = text.replace(re1, `"version": "${versionName}"`);
|
|
4586
|
+
}
|
|
4587
|
+
return writeIfChanged(file, next);
|
|
4588
|
+
}
|
|
4589
|
+
function writeExpoAppJson(file, w) {
|
|
4590
|
+
const text = readText(file);
|
|
4591
|
+
if (!text) throw new Error(`无法读取 ${file}`);
|
|
4592
|
+
const v = replaceGroup(/("version"\s*:\s*")([^"]+)(")/g, text, 2, w.versionName);
|
|
4593
|
+
if (v.count === 0) throw new Error(`${file} 未找到 expo "version" 键`);
|
|
4594
|
+
let next = v.text;
|
|
4595
|
+
next = replaceGroup(/("versionCode"\s*:\s*)(\d+)/g, next, 2, String(w.versionCode)).text;
|
|
4596
|
+
next = replaceGroup(/("buildNumber"\s*:\s*")([^"]+)(")/g, next, 2, String(w.versionCode)).text;
|
|
4597
|
+
return writeIfChanged(file, next);
|
|
4598
|
+
}
|
|
4599
|
+
/**
|
|
4600
|
+
* 写回各端版本(多端 versionName 同步、versionCode 各端独立)。
|
|
4601
|
+
* 返回实际改动的文件列表(commit 范围)。problem 端跳过并在返回中列出(不阻断其他端)。
|
|
4602
|
+
*/
|
|
4603
|
+
function writeProjectVersions(cwd, framework, writes) {
|
|
4604
|
+
const loc = locateVersionFiles(cwd, framework);
|
|
4605
|
+
const changed = [];
|
|
4606
|
+
const skipped = [];
|
|
4607
|
+
const plat = new Map(writes.map((w) => [w.platform, w]));
|
|
4608
|
+
const apply = (file, fn, key) => {
|
|
4609
|
+
if (!file) {
|
|
4610
|
+
const w = plat.get(key);
|
|
4611
|
+
if (w) skipped.push({
|
|
4612
|
+
platform: w.platform,
|
|
4613
|
+
reason: `未找到版本文件(${key})`
|
|
4614
|
+
});
|
|
4615
|
+
return;
|
|
4616
|
+
}
|
|
4617
|
+
try {
|
|
4618
|
+
if (fn(file)) changed.push(file);
|
|
4619
|
+
} catch (e) {
|
|
4620
|
+
const code = e.code;
|
|
4621
|
+
if (code === "EACCES" || code === "EPERM") throw e;
|
|
4622
|
+
skipped.push({
|
|
4623
|
+
platform: key,
|
|
4624
|
+
reason: e.message
|
|
4625
|
+
});
|
|
4626
|
+
}
|
|
4627
|
+
};
|
|
4628
|
+
if (plat.has("android")) apply(loc.android, (f) => writeGradle(f, plat.get("android")), "android");
|
|
4629
|
+
if (plat.has("ios")) apply(loc.ios, (f) => writePbxproj(f, plat.get("ios")), "ios");
|
|
4630
|
+
if (plat.has("harmony")) apply(loc.harmony, (f) => writeAppJson5(f, plat.get("harmony")), "harmony");
|
|
4631
|
+
if (plat.has("flutter")) apply(loc.pubspec, (f) => writePubspec(f, plat.get("flutter")), "flutter");
|
|
4632
|
+
if (plat.has("js")) apply(loc.packageJson, (f) => writeJsonVersionKey(f, plat.get("js").versionName), "js");
|
|
4633
|
+
if (plat.has("tauri")) apply(loc.tauri, (f) => writeJsonVersionKey(f, plat.get("tauri").versionName), "tauri");
|
|
4634
|
+
if (plat.has("expo")) apply(loc.expoAppJson, (f) => writeExpoAppJson(f, plat.get("expo")), "expo");
|
|
4635
|
+
return {
|
|
4636
|
+
changed: [...new Set(changed)],
|
|
4637
|
+
skipped
|
|
4638
|
+
};
|
|
4639
|
+
}
|
|
4640
|
+
//#endregion
|
|
4641
|
+
//#region src/version/git.ts
|
|
4642
|
+
/**
|
|
4643
|
+
* git 只读/写小助手(bump 编排与推断共用)。
|
|
4644
|
+
* spawnSync + 超时;失败返回 ok:false 由调用方决定语义,不在此抛。
|
|
4645
|
+
*/
|
|
4646
|
+
function git(cwd, args) {
|
|
4647
|
+
const r = spawnSync("git", args, {
|
|
4648
|
+
cwd,
|
|
4649
|
+
encoding: "utf8",
|
|
4650
|
+
timeout: 6e4
|
|
4651
|
+
});
|
|
4652
|
+
return {
|
|
4653
|
+
ok: !r.error && r.status === 0,
|
|
4654
|
+
stdout: (r.stdout ?? "").trim(),
|
|
4655
|
+
stderr: (r.stderr ?? "").trim()
|
|
4656
|
+
};
|
|
4657
|
+
}
|
|
4658
|
+
function isGitRepo(cwd) {
|
|
4659
|
+
return git(cwd, ["rev-parse", "--is-inside-work-tree"]).stdout === "true";
|
|
4660
|
+
}
|
|
4661
|
+
/** 宽松 semver 解析(可选 v/前缀,如 v4.1.0 / 4.1.0-beta.3 / release-4.1.0 里取 semver 部分) */
|
|
4662
|
+
function looseSemver(tag) {
|
|
4663
|
+
const direct = semver.valid(tag);
|
|
4664
|
+
if (direct) return direct;
|
|
4665
|
+
const m = /(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)/.exec(tag);
|
|
4666
|
+
return m ? semver.valid(m[1]) ?? void 0 : void 0;
|
|
4667
|
+
}
|
|
4668
|
+
/**
|
|
4669
|
+
* 最新 semver tag(按版本序取最大,非创建时间——修复补打旧版本 tag 的场景更稳)。
|
|
4670
|
+
* 无 tag / 非 git 仓库返回 undefined。
|
|
4671
|
+
*/
|
|
4672
|
+
function latestSemverTag(cwd) {
|
|
4673
|
+
const r = git(cwd, ["tag", "-l"]);
|
|
4674
|
+
if (!r.ok) return void 0;
|
|
4675
|
+
let best;
|
|
4676
|
+
for (const raw of r.stdout.split("\n")) {
|
|
4677
|
+
const t = raw.trim();
|
|
4678
|
+
if (!t) continue;
|
|
4679
|
+
const v = looseSemver(t);
|
|
4680
|
+
if (!v) continue;
|
|
4681
|
+
if (!best || semver.gt(v, best.v)) best = {
|
|
4682
|
+
raw: t,
|
|
4683
|
+
v
|
|
4684
|
+
};
|
|
4685
|
+
}
|
|
4686
|
+
return best?.raw;
|
|
4687
|
+
}
|
|
4688
|
+
/** 锚点 tag..HEAD 的完整 commit messages(%B 含 footer,NUL 分隔——body 内建换行安全) */
|
|
4689
|
+
function commitsSince(cwd, ref) {
|
|
4690
|
+
const r = git(cwd, [
|
|
4691
|
+
"log",
|
|
4692
|
+
ref ? `${ref}..HEAD` : "HEAD",
|
|
4693
|
+
"--format=%B%x00",
|
|
4694
|
+
"--no-merges"
|
|
4695
|
+
]);
|
|
4696
|
+
if (!r.ok) return [];
|
|
4697
|
+
return r.stdout.split("\0").map((m) => m.trim()).filter(Boolean);
|
|
4698
|
+
}
|
|
4699
|
+
/** 工作区状态(--porcelain)。注意:不走通用 git()——其 stdout.trim() 会剥掉首行前导空格,
|
|
4700
|
+
* 破坏 porcelain 的两列状态位(" M file" → "M file"),此处保留原始行仅去行尾。 */
|
|
4701
|
+
function statusShort(cwd) {
|
|
4702
|
+
const r = spawnSync("git", ["status", "--porcelain"], {
|
|
4703
|
+
cwd,
|
|
4704
|
+
encoding: "utf8",
|
|
4705
|
+
timeout: 3e4
|
|
4706
|
+
});
|
|
4707
|
+
if (r.error || r.status !== 0) return [];
|
|
4708
|
+
return (r.stdout ?? "").split("\n").map((l) => l.trimEnd()).filter(Boolean);
|
|
4709
|
+
}
|
|
4710
|
+
//#endregion
|
|
4711
|
+
//#region src/version/infer.ts
|
|
4712
|
+
/**
|
|
4713
|
+
* 版本推断与决定:锚点 tag..HEAD 的 conventional commits → bump level(纯函数),
|
|
4714
|
+
* 版本演进全用 semver(inc / prerelease 剥离),不自写版本数学。
|
|
4715
|
+
* 纯函数输入 git log 文本,便于契约测试;git 交互在 git.ts。
|
|
4716
|
+
*/
|
|
4717
|
+
/** conventional commits → bump level:BREAKING > feat(minor) > fix(patch);无可识别提交 → patch */
|
|
4718
|
+
function inferBumpLevel(messages) {
|
|
4719
|
+
let breaking = 0;
|
|
4720
|
+
let feat = 0;
|
|
4721
|
+
let fix = 0;
|
|
4722
|
+
const parser = new CommitParser();
|
|
4723
|
+
for (const raw of messages) {
|
|
4724
|
+
let c = null;
|
|
4725
|
+
try {
|
|
4726
|
+
c = parser.parse(raw);
|
|
4727
|
+
} catch {
|
|
4728
|
+
continue;
|
|
4729
|
+
}
|
|
4730
|
+
if (!c) continue;
|
|
4731
|
+
const type = (c.type ?? "").toLowerCase();
|
|
4732
|
+
if ((c.notes ?? []).some((n) => /breaking/i.test(n.title ?? ""))) breaking++;
|
|
4733
|
+
else if (type === "feat") feat++;
|
|
4734
|
+
else if (type === "fix") fix++;
|
|
4735
|
+
}
|
|
4736
|
+
const total = messages.length;
|
|
4737
|
+
let level;
|
|
4738
|
+
let reason;
|
|
4739
|
+
if (breaking > 0) {
|
|
4740
|
+
level = "major";
|
|
4741
|
+
reason = `git 推断:${breaking} 个 BREAKING CHANGE → major`;
|
|
4742
|
+
} else if (feat > 0) {
|
|
4743
|
+
level = "minor";
|
|
4744
|
+
reason = `git 推断:feat ${feat} 个(fix ${fix} 个)→ minor`;
|
|
4745
|
+
} else if (fix > 0) {
|
|
4746
|
+
level = "patch";
|
|
4747
|
+
reason = `git 推断:fix ${fix} 个,无 feat/BREAKING → patch`;
|
|
4748
|
+
} else {
|
|
4749
|
+
level = "patch";
|
|
4750
|
+
reason = total === 0 ? "锚点 tag..HEAD 无提交 → patch 兜底" : `git 推断:${total} 个提交无 feat/fix/BREAKING(chore/docs 等)→ patch 兜底`;
|
|
4751
|
+
}
|
|
4752
|
+
return {
|
|
4753
|
+
level,
|
|
4754
|
+
reason,
|
|
4755
|
+
total,
|
|
4756
|
+
breaking,
|
|
4757
|
+
feat,
|
|
4758
|
+
fix
|
|
4759
|
+
};
|
|
4760
|
+
}
|
|
4761
|
+
/**
|
|
4762
|
+
* 从当前版本决定下一版本(纯函数)。
|
|
4763
|
+
* - 显式 level:semver.inc;当前是 prerelease 且不带 --pre → 剥离转正式(4.0.0-beta.3 + patch → 4.0.0)
|
|
4764
|
+
* - 无 level:用 infer 结果
|
|
4765
|
+
* - prerelease level:当前有 pre → 同基线递进(beta.3 → beta.4);无 pre → 报错(应使用 --pre 进入)
|
|
4766
|
+
* - --pre:从 level 出发挂 prerelease(4.0.0 + minor + beta → 4.1.0-beta.1)
|
|
4767
|
+
* current 非 semver 时抛错(调用方要求显式指定或先手动规范化)。
|
|
4768
|
+
*/
|
|
4769
|
+
function decideNextVersion(current, opts, infer) {
|
|
4770
|
+
const base = semver.valid(current);
|
|
4771
|
+
if (!base) throw new Error(`当前版本 "${current}" 不是合法 semver(如前导零 3.0.08):显式指定目标版本(bump 4.0.0)或先手动规范化`);
|
|
4772
|
+
const level = opts.level;
|
|
4773
|
+
if (level && ![
|
|
4774
|
+
"major",
|
|
4775
|
+
"minor",
|
|
4776
|
+
"patch",
|
|
4777
|
+
"prerelease"
|
|
4778
|
+
].includes(level)) {
|
|
4779
|
+
const explicit = semver.valid(level);
|
|
4780
|
+
if (!explicit) throw new Error(`无法识别的版本/级别 "${level}"`);
|
|
4781
|
+
if (!semver.gt(explicit, base)) throw new Error(`目标版本 ${explicit} 必须大于当前 ${base}`);
|
|
4782
|
+
return {
|
|
4783
|
+
next: explicit,
|
|
4784
|
+
reason: "显式指定"
|
|
4785
|
+
};
|
|
4786
|
+
}
|
|
4787
|
+
const hasPre = semver.prerelease(base) !== null;
|
|
4788
|
+
const preId = opts.pre ?? semver.prerelease(base)?.[0];
|
|
4789
|
+
if (level === "prerelease") {
|
|
4790
|
+
if (hasPre) {
|
|
4791
|
+
const next = preId ? semver.inc(base, "prerelease", preId) : semver.inc(base, "prerelease");
|
|
4792
|
+
if (!next) throw new Error(`prerelease 递进失败:${base}`);
|
|
4793
|
+
return {
|
|
4794
|
+
next,
|
|
4795
|
+
reason: `prerelease 递进(${base} → ${next})`
|
|
4796
|
+
};
|
|
4797
|
+
}
|
|
4798
|
+
throw new Error(`当前 ${base} 不是 prerelease:用 --pre beta(进入 beta.1)或显式版本(bump 4.1.0-beta.1)`);
|
|
4799
|
+
}
|
|
4800
|
+
const effectiveLevel = level ?? infer?.level ?? "patch";
|
|
4801
|
+
const reasonPrefix = level ? `显式 ${level}` : infer ? infer.reason : "无依据 → patch 兜底";
|
|
4802
|
+
if (opts.pre) {
|
|
4803
|
+
const next = semver.inc(base, `pre${effectiveLevel}`, opts.pre);
|
|
4804
|
+
if (!next) throw new Error(`pre${effectiveLevel} 计算失败:${base}`);
|
|
4805
|
+
return {
|
|
4806
|
+
next,
|
|
4807
|
+
reason: `${reasonPrefix} + prerelease(${opts.pre})`
|
|
4808
|
+
};
|
|
4809
|
+
}
|
|
4810
|
+
const next = semver.inc(base, effectiveLevel);
|
|
4811
|
+
if (!next) throw new Error(`${effectiveLevel} 递进失败:${base}`);
|
|
4812
|
+
return {
|
|
4813
|
+
next,
|
|
4814
|
+
reason: reasonPrefix
|
|
4815
|
+
};
|
|
4816
|
+
}
|
|
4817
|
+
/** 工程文件写入用的三段数字 versionName(prerelease 只留在 tag,不进商店) */
|
|
4818
|
+
function storeVersionName(full) {
|
|
4819
|
+
const v = new semver.SemVer(full);
|
|
4820
|
+
return `${v.major}.${v.minor}.${v.patch}`;
|
|
4821
|
+
}
|
|
4822
|
+
//#endregion
|
|
4823
|
+
//#region src/version/changelog.ts
|
|
4824
|
+
/**
|
|
4825
|
+
* CHANGELOG 生成:conventional commits 归类渲染(conventional-changelog 风格),
|
|
4826
|
+
* 倒序插入项目根 CHANGELOG.md(同版本 section 幂等替换)。
|
|
4827
|
+
* 渲染自研(conventional-changelog-core 依赖链对 CLI 太重),解析复用 parser。
|
|
4828
|
+
*/
|
|
4829
|
+
/** raw commit messages → 结构化条目(feat/fix/BREAKING 之外的 type 也保留,渲染时归类) */
|
|
4830
|
+
function toChangelogEntries(messages) {
|
|
4831
|
+
const parser = new CommitParser();
|
|
4832
|
+
const out = [];
|
|
4833
|
+
for (const raw of messages) {
|
|
4834
|
+
let c = null;
|
|
4835
|
+
try {
|
|
4836
|
+
c = parser.parse(raw);
|
|
4837
|
+
} catch {
|
|
4838
|
+
continue;
|
|
4839
|
+
}
|
|
4840
|
+
if (!c || !c.header) continue;
|
|
4841
|
+
const headerType = /^(\w+)(\([^)]*\))?!?:\s/.exec(c.header)?.[1]?.toLowerCase();
|
|
4842
|
+
const type = (c.type ?? headerType)?.toLowerCase();
|
|
4843
|
+
if (!type) continue;
|
|
4844
|
+
const breaking = (c.notes ?? []).some((n) => /breaking/i.test(n.title ?? ""));
|
|
4845
|
+
out.push({
|
|
4846
|
+
type,
|
|
4847
|
+
scope: c.scope || void 0,
|
|
4848
|
+
subject: (c.subject || c.header || "").trim(),
|
|
4849
|
+
breaking,
|
|
4850
|
+
hash: c.hash || void 0
|
|
4851
|
+
});
|
|
4852
|
+
}
|
|
4853
|
+
return out;
|
|
4854
|
+
}
|
|
4855
|
+
function line(e) {
|
|
4856
|
+
const scope = e.scope ? `(${e.scope})` : "";
|
|
4857
|
+
const hash = e.hash ? ` (${e.hash.slice(0, 7)})` : "";
|
|
4858
|
+
return `- ${e.type}${scope}: ${e.subject}${hash}`;
|
|
4859
|
+
}
|
|
4860
|
+
/** 渲染单个版本段落(## [4.1.0] - 2026-10-17) */
|
|
4861
|
+
function renderChangelogSection(version, date, entries) {
|
|
4862
|
+
const body = [
|
|
4863
|
+
["⚠ BREAKING CHANGES", entries.filter((e) => e.breaking)],
|
|
4864
|
+
["Features", entries.filter((e) => !e.breaking && e.type === "feat")],
|
|
4865
|
+
["Bug Fixes", entries.filter((e) => !e.breaking && e.type === "fix")],
|
|
4866
|
+
["Others", entries.filter((e) => !e.breaking && e.type !== "feat" && e.type !== "fix")]
|
|
4867
|
+
].filter(([, list]) => list.length > 0).map(([title, list]) => `### ${title}\n\n${list.map(line).join("\n")}`).join("\n\n");
|
|
4868
|
+
const heading = `## [${version}] - ${date}`;
|
|
4869
|
+
return body ? `${heading}\n\n${body}` : heading;
|
|
4870
|
+
}
|
|
4871
|
+
const HEADER_RE = /^#\s*changelog\b/i;
|
|
4872
|
+
/**
|
|
4873
|
+
* 插入/替换 CHANGELOG.md 的版本段落(幂等):
|
|
4874
|
+
* - 已有 `## [version]` → 整段替换为最新渲染
|
|
4875
|
+
* - 否则插到首个 # Changelog 标题之后;无标题/无文件 → 新建带标题
|
|
4876
|
+
* 返回写入路径;entries 为空也写入 heading(bump 闭环保持 tag ↔ changelog 对齐)。
|
|
4877
|
+
*/
|
|
4878
|
+
function upsertChangelog(cwd, version, section) {
|
|
4879
|
+
const filePath = `${cwd}/CHANGELOG.md`;
|
|
4880
|
+
let content = "";
|
|
4881
|
+
if (fs.existsSync(filePath)) content = fs.readFileSync(filePath, "utf8");
|
|
4882
|
+
const lines = content.split("\n");
|
|
4883
|
+
const start = lines.findIndex((l) => l.startsWith(`## [${version}]`));
|
|
4884
|
+
if (start >= 0) {
|
|
4885
|
+
let end = lines.length;
|
|
4886
|
+
for (let i = start + 1; i < lines.length; i++) if (lines[i].startsWith("## ")) {
|
|
4887
|
+
end = i;
|
|
4888
|
+
break;
|
|
4889
|
+
}
|
|
4890
|
+
content = [
|
|
4891
|
+
...lines.slice(0, start),
|
|
4892
|
+
...section.split("\n"),
|
|
4893
|
+
"",
|
|
4894
|
+
...lines.slice(end)
|
|
4895
|
+
].join("\n");
|
|
4896
|
+
} else if (HEADER_RE.test(content)) content = content.replace(/(^#\s*changelog\b[^\n]*\n+)/im, `$1\n${section}\n`);
|
|
4897
|
+
else content = `# Changelog\n\n${section}\n${content ? `\n${content}` : ""}`;
|
|
4898
|
+
fs.writeFileSync(filePath, content.endsWith("\n") ? content : `${content}\n`, "utf8");
|
|
4899
|
+
return filePath;
|
|
4900
|
+
}
|
|
4901
|
+
//#endregion
|
|
4902
|
+
//#region src/version/version-code.ts
|
|
4903
|
+
/**
|
|
4904
|
+
* versionCode 计算:默认递增 +1;可配公式(如 major*1000000+minor*1000+patch)。
|
|
4905
|
+
* 实际写入 = max(公式值, 现值+1)——公式在同 versionName 重发时算出同值,
|
|
4906
|
+
* +1 兜底保证单调(AGC 同版本不能重复提交 / Google Play 单调 / App Store 单调)。
|
|
4907
|
+
*/
|
|
4908
|
+
/** 公式白名单求值(仅支持 + - * ( ) 数字 与 major/minor/patch 变量;不 eval) */
|
|
4909
|
+
function evalVersionCodeFormula(formula, v) {
|
|
4910
|
+
const tokens = [];
|
|
4911
|
+
let i = 0;
|
|
4912
|
+
const src = formula.replace(/\s+/g, "");
|
|
4913
|
+
while (i < src.length) {
|
|
4914
|
+
const c = src.charAt(i);
|
|
4915
|
+
if (c === "+" || c === "-" || c === "*" || c === "(" || c === ")") {
|
|
4916
|
+
tokens.push({ op: c });
|
|
4917
|
+
i++;
|
|
4918
|
+
} else if (/\d/.test(c)) {
|
|
4919
|
+
let j = i;
|
|
4920
|
+
while (j < src.length && /\d/.test(src.charAt(j))) j++;
|
|
4921
|
+
tokens.push({ n: Number(src.slice(i, j)) });
|
|
4922
|
+
i = j;
|
|
4923
|
+
} else {
|
|
4924
|
+
const word = /^(major|minor|patch)/.exec(src.slice(i));
|
|
4925
|
+
if (!word) return void 0;
|
|
4926
|
+
const name = word[1];
|
|
4927
|
+
tokens.push({ n: v[name] });
|
|
4928
|
+
i += name.length;
|
|
4929
|
+
}
|
|
4930
|
+
}
|
|
4931
|
+
if (tokens.length === 0) return void 0;
|
|
4932
|
+
let pos = 0;
|
|
4933
|
+
const peek = () => tokens[pos];
|
|
4934
|
+
function factor() {
|
|
4935
|
+
const t = peek();
|
|
4936
|
+
if (!t) return void 0;
|
|
4937
|
+
if ("n" in t) {
|
|
4938
|
+
pos++;
|
|
4939
|
+
return t.n;
|
|
4940
|
+
}
|
|
4941
|
+
if (t.op === "(") {
|
|
4942
|
+
pos++;
|
|
4943
|
+
const inner = expr();
|
|
4944
|
+
const close = peek();
|
|
4945
|
+
if (inner === void 0 || !close || !("op" in close) || close.op !== ")") return void 0;
|
|
4946
|
+
pos++;
|
|
4947
|
+
return inner;
|
|
4948
|
+
}
|
|
4949
|
+
if (t.op === "-") {
|
|
4950
|
+
pos++;
|
|
4951
|
+
const f = factor();
|
|
4952
|
+
return f === void 0 ? void 0 : -f;
|
|
4953
|
+
}
|
|
4954
|
+
}
|
|
4955
|
+
function term() {
|
|
4956
|
+
let left = factor();
|
|
4957
|
+
for (;;) {
|
|
4958
|
+
const t = peek();
|
|
4959
|
+
if (left === void 0 || !t || !("op" in t) || t.op !== "*") return left;
|
|
4960
|
+
pos++;
|
|
4961
|
+
const right = factor();
|
|
4962
|
+
if (right === void 0) return void 0;
|
|
4963
|
+
left = left * right;
|
|
4964
|
+
}
|
|
4965
|
+
}
|
|
4966
|
+
function expr() {
|
|
4967
|
+
let left = term();
|
|
4968
|
+
for (;;) {
|
|
4969
|
+
const t = peek();
|
|
4970
|
+
if (left === void 0 || !t || !("op" in t) || t.op !== "+" && t.op !== "-") return left;
|
|
4971
|
+
pos++;
|
|
4972
|
+
const right = term();
|
|
4973
|
+
if (right === void 0) return void 0;
|
|
4974
|
+
left = t.op === "+" ? left + right : left - right;
|
|
4975
|
+
}
|
|
4976
|
+
}
|
|
4977
|
+
const result = expr();
|
|
4978
|
+
if (result === void 0 || pos !== tokens.length) return void 0;
|
|
4979
|
+
return Number.isFinite(result) && Number.isInteger(result) && result > 0 ? result : void 0;
|
|
4980
|
+
}
|
|
4981
|
+
/**
|
|
4982
|
+
* 下一 versionCode:max(公式值, 现值+1)。
|
|
4983
|
+
* - 无现值(首接工程无该字段):公式值,否则 1
|
|
4984
|
+
* - 有现值:至少现值+1(吞 3052/4000000 等任意历史值,首次 bump 即归一)
|
|
4985
|
+
*/
|
|
4986
|
+
function nextVersionCode(current, formulaValue) {
|
|
4987
|
+
if (current === void 0) return formulaValue ?? 1;
|
|
4988
|
+
return Math.max(formulaValue ?? Number.NEGATIVE_INFINITY, current + 1);
|
|
4989
|
+
}
|
|
4990
|
+
//#endregion
|
|
4991
|
+
//#region src/version/bump.ts
|
|
4992
|
+
/** 多端 versionName 的基线:取 semver 合法值中最大;全部非法 → null(需显式指定) */
|
|
4993
|
+
function baselineVersion(currents) {
|
|
4994
|
+
let best = null;
|
|
4995
|
+
for (const c of currents) {
|
|
4996
|
+
const v = semver.valid(c.versionName ?? "");
|
|
4997
|
+
if (v && (!best || semver.gt(v, best))) best = v;
|
|
4998
|
+
}
|
|
4999
|
+
return best;
|
|
5000
|
+
}
|
|
5001
|
+
/** 并发 bump 互斥锁(O_EXCL 原子创建;>10 分钟视为陈旧自动接管) */
|
|
5002
|
+
function acquireBumpLock(cwd) {
|
|
5003
|
+
const lockPath = path.join(cwd, ".git", "applift-bump.lock");
|
|
5004
|
+
try {
|
|
5005
|
+
const fd = fs.openSync(lockPath, "wx");
|
|
5006
|
+
fs.closeSync(fd);
|
|
5007
|
+
} catch (e) {
|
|
5008
|
+
if (e.code !== "EEXIST") throw e;
|
|
5009
|
+
try {
|
|
5010
|
+
if (Date.now() - fs.statSync(lockPath).mtimeMs > 6e5) fs.writeFileSync(lockPath, String(process.pid));
|
|
5011
|
+
else return /* @__PURE__ */ new Error(`另一 bump 进程正在运行(${lockPath})——并发 bump 会产出内容错位的提交,稍后重试或确认无并发后删除锁文件`);
|
|
5012
|
+
} catch {
|
|
5013
|
+
return /* @__PURE__ */ new Error(`无法获取 bump 锁(${lockPath})`);
|
|
5014
|
+
}
|
|
5015
|
+
}
|
|
5016
|
+
return () => {
|
|
5017
|
+
try {
|
|
5018
|
+
fs.unlinkSync(lockPath);
|
|
5019
|
+
} catch {}
|
|
5020
|
+
};
|
|
5021
|
+
}
|
|
5022
|
+
/** HEAD 提交是否是我们打的 release commit(失败重跑识别用) */
|
|
5023
|
+
function lastCommitIsRelease(cwd) {
|
|
5024
|
+
return git(cwd, [
|
|
5025
|
+
"log",
|
|
5026
|
+
"-1",
|
|
5027
|
+
"--format=%s"
|
|
5028
|
+
]).stdout.startsWith("chore(release): ");
|
|
5029
|
+
}
|
|
5030
|
+
function planBump(opts) {
|
|
5031
|
+
let anchorTag;
|
|
5032
|
+
let commits = [];
|
|
5033
|
+
let infer;
|
|
5034
|
+
if (isGitRepo(opts.cwd)) {
|
|
5035
|
+
anchorTag = latestSemverTag(opts.cwd);
|
|
5036
|
+
if (anchorTag) {
|
|
5037
|
+
commits = commitsSince(opts.cwd, anchorTag);
|
|
5038
|
+
if (commits.length > 0) infer = inferBumpLevel(commits);
|
|
5039
|
+
}
|
|
5040
|
+
}
|
|
5041
|
+
const currents = readProjectVersions(opts.cwd, opts.framework).filter((c) => (c.versionName || c.versionCode) && !opts.platforms || opts.platforms?.includes(c.platform));
|
|
5042
|
+
const anchorVersion = anchorTag ? looseSemver(anchorTag) ?? void 0 : void 0;
|
|
5043
|
+
const base = (anchorVersion ? semver.prerelease(anchorVersion) !== null : false) ? anchorVersion : baselineVersion([...currents, ...anchorVersion ? [{
|
|
5044
|
+
platform: "__tag__",
|
|
5045
|
+
versionName: anchorVersion
|
|
5046
|
+
}] : []]);
|
|
5047
|
+
if (!base) {
|
|
5048
|
+
if (!opts.level || !semver.valid(opts.level)) throw new Error(`各端均无可解析的 semver versionName(${currents.map((c) => `${c.platform}=${c.versionName ?? "?"}`).join(" ")}):显式指定目标版本,如 applift version bump 4.0.0`);
|
|
5049
|
+
}
|
|
5050
|
+
const decided = decideNextVersion(base ?? "0.0.0", {
|
|
5051
|
+
level: opts.level,
|
|
5052
|
+
pre: opts.pre
|
|
5053
|
+
}, infer);
|
|
5054
|
+
if (!anchorTag && !opts.level) decided.reason = "无发版锚点 tag(首次 bump)→ patch 兜底;打第一个 tag 后激活 git 推断";
|
|
5055
|
+
const suspectRerun = !!anchorTag && lastCommitIsRelease(opts.cwd) && commits.every((c) => c.startsWith("chore(release): "));
|
|
5056
|
+
if (suspectRerun) decided.reason += ";⚠ 锚点后无新业务提交且 HEAD 已是 release commit——疑似失败重跑,建议 --no-bump 复用已 bump 版本";
|
|
5057
|
+
const formulaCfg = opts.versionCode && opts.versionCode !== "increment" ? opts.versionCode : void 0;
|
|
5058
|
+
if (opts.platforms && opts.platforms.length > 0) {
|
|
5059
|
+
const known = new Set(currents.map((c) => c.platform));
|
|
5060
|
+
const unknown = opts.platforms.filter((p) => !known.has(p));
|
|
5061
|
+
if (unknown.length > 0) throw new Error(`未知平台: ${unknown.join(", ")}(可用: ${[...known].join("/") || "无"})`);
|
|
5062
|
+
}
|
|
5063
|
+
const writes = [];
|
|
5064
|
+
let formulaNote = "递增 +1";
|
|
5065
|
+
let formulaApplied = false;
|
|
5066
|
+
const resetCodes = [];
|
|
5067
|
+
for (const c of currents) {
|
|
5068
|
+
if (opts.platforms && !opts.platforms.includes(c.platform)) continue;
|
|
5069
|
+
const cur = c.versionCode !== void 0 && /^\d+$/.test(c.versionCode) ? Number(c.versionCode) : void 0;
|
|
5070
|
+
if (c.versionCode !== void 0 && cur === void 0) resetCodes.push(`${c.platform}=${c.versionCode}`);
|
|
5071
|
+
let formulaValue;
|
|
5072
|
+
if (formulaCfg && (!formulaCfg.platforms || formulaCfg.platforms.includes(c.platform))) {
|
|
5073
|
+
const v = new semver.SemVer(decided.next);
|
|
5074
|
+
formulaValue = evalVersionCodeFormula(formulaCfg.formula, {
|
|
5075
|
+
major: v.major,
|
|
5076
|
+
minor: v.minor,
|
|
5077
|
+
patch: v.patch
|
|
5078
|
+
});
|
|
5079
|
+
if (formulaValue === void 0) throw new Error(`versionCode 公式无法求值:${formulaCfg.formula}(仅支持 + - * ( ) 与 major/minor/patch)`);
|
|
5080
|
+
formulaNote = `max(公式 ${formulaCfg.formula}, 现值+1)`;
|
|
5081
|
+
formulaApplied = true;
|
|
5082
|
+
}
|
|
5083
|
+
const code = nextVersionCode(cur, formulaValue);
|
|
5084
|
+
if (code > 21e8) throw new Error(`versionCode ${code} 超过商店上限 2100000000(${c.platform}):检查公式或手动规范化`);
|
|
5085
|
+
writes.push({
|
|
5086
|
+
platform: c.platform,
|
|
5087
|
+
versionName: storeVersionName(decided.next),
|
|
5088
|
+
versionCode: code
|
|
5089
|
+
});
|
|
5090
|
+
}
|
|
5091
|
+
if (formulaCfg && !formulaApplied && writes.length > 0) formulaNote += `;⚠ 公式 ${formulaCfg.formula} 未命中任何端${formulaCfg.platforms ? `(platforms: ${formulaCfg.platforms.join("/")},可用端: ${writes.map((w) => w.platform).join("/")})` : ""}——全部端走 +1 兜底`;
|
|
5092
|
+
if (resetCodes.length > 0) formulaNote += `;⚠ 非数字 versionCode 已从 1 重新起号(${resetCodes.join(", ")})——商店单调性可能拒绝,建议配置 version.versionCode 公式或手动规范化`;
|
|
5093
|
+
return {
|
|
5094
|
+
anchorTag,
|
|
5095
|
+
currentVersions: currents,
|
|
5096
|
+
writes,
|
|
5097
|
+
base: base ?? decided.next,
|
|
5098
|
+
nextVersion: decided.next,
|
|
5099
|
+
storeName: storeVersionName(decided.next),
|
|
5100
|
+
reason: decided.reason,
|
|
5101
|
+
infer,
|
|
5102
|
+
commits,
|
|
5103
|
+
suspectRerun,
|
|
5104
|
+
versionCodeNote: formulaNote
|
|
5105
|
+
};
|
|
5106
|
+
}
|
|
5107
|
+
function applyBump(cwd, plan, framework, opts) {
|
|
5108
|
+
const lock = isGitRepo(cwd) ? acquireBumpLock(cwd) : void 0;
|
|
5109
|
+
if (lock instanceof Error) throw lock;
|
|
5110
|
+
try {
|
|
5111
|
+
const { changed, skipped } = writeProjectVersions(cwd, framework, plan.writes);
|
|
5112
|
+
if (plan.writes.length === 0) throw new Error(`没有可写入的端(读取期被丢弃或 --platform 过滤为空)——不生成 release commit/tag;catalog 引用形态需先改造为字面量`);
|
|
5113
|
+
if (plan.writes.length > 0 && changed.length === 0 && skipped.length > 0) throw new Error(`所有端的版本写入均被跳过:${skipped.map((s) => `${s.platform}(${s.reason})`).join(";")}——不生成 release commit/tag`);
|
|
5114
|
+
if (plan.writes.length > 0 && changed.length === 0 && skipped.length === 0) {}
|
|
5115
|
+
const warnings = [];
|
|
5116
|
+
if (opts.commit && isGitRepo(cwd)) {
|
|
5117
|
+
const staging = new Set([...changed, `${cwd}/CHANGELOG.md`].map((f) => path.relative(cwd, f) || f));
|
|
5118
|
+
const dirty = statusShort(cwd).filter((line) => {
|
|
5119
|
+
const f = line.slice(3).split(" -> ").pop().trim().replace(/\/$/, "");
|
|
5120
|
+
return ![...staging].some((s) => s === f || s.startsWith(`${f}/`));
|
|
5121
|
+
});
|
|
5122
|
+
if (dirty.length > 0) throw new Error(`工作区有计划外未提交变更,拒绝代 commit(防混入 release 提交):\n${dirty.join("\n")}\n先提交它们或移出工作区(.gitignore),或用 --no-commit`);
|
|
5123
|
+
}
|
|
5124
|
+
const entries = toChangelogEntries(plan.commits);
|
|
5125
|
+
const changelogFile = upsertChangelog(cwd, plan.nextVersion, renderChangelogSection(plan.nextVersion, (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), entries));
|
|
5126
|
+
const changedFiles = [.../* @__PURE__ */ new Set([...changed, changelogFile])];
|
|
5127
|
+
let committed = false;
|
|
5128
|
+
let tagged = false;
|
|
5129
|
+
if (opts.commit || opts.tag) {
|
|
5130
|
+
if (!isGitRepo(cwd)) warnings.push("非 git 仓库:跳过 commit/tag");
|
|
5131
|
+
else {
|
|
5132
|
+
const files = changedFiles.map((f) => path.relative(cwd, f) || f);
|
|
5133
|
+
if (opts.commit) {
|
|
5134
|
+
const add = git(cwd, [
|
|
5135
|
+
"add",
|
|
5136
|
+
"--",
|
|
5137
|
+
...files
|
|
5138
|
+
]);
|
|
5139
|
+
const commit = add.ok ? git(cwd, [
|
|
5140
|
+
"commit",
|
|
5141
|
+
"-m",
|
|
5142
|
+
`chore(release): ${plan.nextVersion}`,
|
|
5143
|
+
"--",
|
|
5144
|
+
...files
|
|
5145
|
+
]) : add;
|
|
5146
|
+
if (commit.ok) committed = true;
|
|
5147
|
+
else warnings.push(`git commit 失败:${commit.stderr || commit.stdout || "未知错误"}`);
|
|
5148
|
+
}
|
|
5149
|
+
if (opts.tag && (!opts.commit || committed)) {
|
|
5150
|
+
const tag = git(cwd, ["tag", `v${plan.nextVersion}`]);
|
|
5151
|
+
if (tag.ok) tagged = true;
|
|
5152
|
+
else warnings.push(`git tag 失败:${tag.stderr || tag.stdout || "未知错误"}`);
|
|
5153
|
+
}
|
|
5154
|
+
}
|
|
5155
|
+
}
|
|
5156
|
+
return {
|
|
5157
|
+
changedFiles,
|
|
5158
|
+
skipped,
|
|
5159
|
+
changelogFile,
|
|
5160
|
+
committed,
|
|
5161
|
+
tagged,
|
|
5162
|
+
warnings
|
|
5163
|
+
};
|
|
5164
|
+
} finally {
|
|
5165
|
+
lock?.();
|
|
5166
|
+
}
|
|
5167
|
+
}
|
|
5168
|
+
//#endregion
|
|
5169
|
+
//#region src/version/artifact.ts
|
|
4056
5170
|
/**
|
|
4057
5171
|
* 产物版本读取(只读不写——版本号变更属开发提交,发版工具只负责读版本防回退)。
|
|
4058
5172
|
* .hap/.app:pack.info 是纯 JSON,零依赖直接读;
|
|
@@ -4084,12 +5198,17 @@ function fromPackInfo(artifactPath) {
|
|
|
4084
5198
|
if (v) return v;
|
|
4085
5199
|
} catch {}
|
|
4086
5200
|
}
|
|
5201
|
+
/** 真实鸿蒙 pack.info schema(qy-driver 实测样本):summary.app.version.{name,code,build};
|
|
5202
|
+
* 顶层 versionName/versionCode 为历史兼容形态。 */
|
|
4087
5203
|
function parsePackInfo(text, source) {
|
|
4088
5204
|
try {
|
|
4089
5205
|
const info = JSON.parse(text);
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
5206
|
+
const nested = info.summary?.app?.version;
|
|
5207
|
+
const versionName = nested?.name ?? info.versionName;
|
|
5208
|
+
const versionCode = nested?.code ?? info.versionCode;
|
|
5209
|
+
if (versionName || versionCode !== void 0) return {
|
|
5210
|
+
versionName,
|
|
5211
|
+
versionCode: String(versionCode ?? ""),
|
|
4093
5212
|
source
|
|
4094
5213
|
};
|
|
4095
5214
|
} catch {}
|
|
@@ -4123,4 +5242,4 @@ function fromAapt(artifactPath) {
|
|
|
4123
5242
|
}
|
|
4124
5243
|
}
|
|
4125
5244
|
//#endregion
|
|
4126
|
-
export { APPLIFT_HOME, AgcHarmonyChannel, AscChannel, BetaConfigSchema, CREDENTIALS_PATH, CredentialsFileSchema, CredentialsProfileSchema, DefaultChannelRegistry, GLOBAL_CONFIG_PATH, GlobalConfigSchema, GooglePlayChannel, HISTORY_PATH, HonorChannel, HuaweiAndroidChannel, OppoChannel, PROJECTS_MAP_PATH, PgyerChannel, PluginHost, ProjectConfigSchema, StoreConfigSchema, StoresSchema, VivoChannel, WAIT_BEFORE_SUBMIT_MS, XiaomiChannel, YybChannel, appendEntry, assertListingFiles, defineConfig, detectBundleIds, detectFramework, electronAdapter, findArtifacts, findUp, flutterAdapter, formPost, getAgcToken, getHarmonyAppId, hmacSign, humanizeError, loadCredentials, loadCredentialsLayered, loadGlobalConfig, loadListingFile, loadProjectConfig, loadProjectProfileMap, md5File, md5String, nativeAndroidAdapter, nativeHarmonyAdapter, nativeIosAdapter, projectCredentialsPath, readArtifactVersion, readEntries, registry, resolveListing, resolvePlugins, resolveProfile, resolveProfileFor, rnAdapter, runStage, runSubmit, saveCredentials, saveProjectProfileMap, setProjectProfile, sha256File, sortedQueryString, tauriAdapter, tsMillis, tsSeconds };
|
|
5245
|
+
export { APPLIFT_HOME, AgcHarmonyChannel, AscChannel, BetaConfigSchema, CREDENTIALS_PATH, CredentialsFileSchema, CredentialsProfileSchema, DefaultChannelRegistry, GLOBAL_CONFIG_PATH, GlobalConfigSchema, GooglePlayChannel, HISTORY_PATH, HonorChannel, HuaweiAndroidChannel, OppoChannel, PROJECTS_MAP_PATH, PgyerChannel, PluginHost, ProjectConfigSchema, ReleaseConfigSchema, StoreConfigSchema, StoresSchema, VersionCodeConfigSchema, VersionConfigSchema, VivoChannel, WAIT_BEFORE_SUBMIT_MS, XiaomiChannel, YybChannel, appendEntry, applyBump, assertListingFiles, commitsSince, decideNextVersion, defineConfig, detectBundleIds, detectFramework, electronAdapter, evalVersionCodeFormula, findArtifacts, findUp, flutterAdapter, formPost, getAgcToken, getHarmonyAppId, git, hmacSign, humanizeError, inferBumpLevel, isGitRepo, latestSemverTag, loadCredentials, loadCredentialsLayered, loadGlobalConfig, loadListingFile, loadProjectConfig, loadProjectProfileMap, looseSemver, md5File, md5String, nativeAndroidAdapter, nativeHarmonyAdapter, nativeIosAdapter, nextVersionCode, planBump, projectCredentialsPath, readArtifactVersion, readEntries, readProjectVersions, redactText, registry, renderChangelogSection, resolveListing, resolvePlugins, resolveProfile, resolveProfileFor, rnAdapter, runStage, runSubmit, saveCredentials, saveProjectProfileMap, setProjectProfile, sha256File, sortedQueryString, statusShort, storeVersionName, tauriAdapter, toChangelogEntries, tsMillis, tsSeconds, upsertChangelog, writeProjectVersions };
|