@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.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 三种等价写法,统一规范化为 record:
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 StoresSchema = z.union([z.record(z.string(), z.union([StoreConfigSchema, z.string()])), z.array(z.string())]).transform((stores) => {
48
- if (Array.isArray(stores)) return Object.fromEntries(stores.map((id) => [id, {}]));
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
- }).default({});
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
- pkgName: z.string(),
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
- bundleName: z.string(),
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: z.string(),
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
- process.stderr.write?.(`[applift] 全局配置解析失败,使用默认值: ${e.message}\n`);
200
- return GlobalConfigSchema.parse({});
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: ProjectConfigSchema.parse(result.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 内部共用小工具(不进公共 API) */
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", new Blob([fs.readFileSync(ctx.artifactPath)]), path.basename(ctx.artifactPath));
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", new Blob([fs.readFileSync(ctx.artifactPath)]), fileName);
1037
- form.append("icon", new Blob([fs.readFileSync(iconPath)]), path.basename(iconPath));
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", new Blob([fs.readFileSync(ctx.artifactPath)]), fileName);
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", new Blob([fs.readFileSync(ctx.artifactPath)]), fileName);
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", new Blob([fs.readFileSync(ctx.artifactPath)]), fileName);
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, init);
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": "应用未登记隐私政策协议:AGC 控制台「更多服务 → 协议服务」新建隐私政策并提交(一次性前置,API 无法代办)"
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: { testTaskInfo: { groupInfos: [{
2701
- groupId,
2702
- groupName
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}`, { headers: {
2760
- ...headers,
2761
- appId
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
- /** plan 失败/被插件删除时的兜底条目(保 summary.plan 非可选契约) */
3557
- function fallbackPlan(channelId) {
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: "android",
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 ["harmony/entry/build/default/outputs/**/entry-*-signed.hap", "harmony/entry/build/default/outputs/**/entry-*.app"];
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.includes(code)) return `${message}\n 提示: ${hint}`;
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/version.ts
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
- if (info.versionName || info.versionCode !== void 0) return {
4091
- versionName: info.versionName,
4092
- versionCode: String(info.versionCode ?? ""),
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 };