shipup 0.2.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.
Files changed (83) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/LICENSE +14 -0
  3. package/README.md +112 -0
  4. package/README.zh-CN.md +105 -0
  5. package/SECURITY.md +32 -0
  6. package/creds.example.yaml +62 -0
  7. package/dist/adapters/android/honor/api.js +360 -0
  8. package/dist/adapters/android/honor/api.js.map +1 -0
  9. package/dist/adapters/android/honor/index.js +122 -0
  10. package/dist/adapters/android/honor/index.js.map +1 -0
  11. package/dist/adapters/android/huawei/api.js +389 -0
  12. package/dist/adapters/android/huawei/api.js.map +1 -0
  13. package/dist/adapters/android/huawei/index.js +128 -0
  14. package/dist/adapters/android/huawei/index.js.map +1 -0
  15. package/dist/adapters/android/meizu/api.js +367 -0
  16. package/dist/adapters/android/meizu/api.js.map +1 -0
  17. package/dist/adapters/android/meizu/index.js +129 -0
  18. package/dist/adapters/android/meizu/index.js.map +1 -0
  19. package/dist/adapters/android/oppo/api.js +397 -0
  20. package/dist/adapters/android/oppo/api.js.map +1 -0
  21. package/dist/adapters/android/oppo/index.js +135 -0
  22. package/dist/adapters/android/oppo/index.js.map +1 -0
  23. package/dist/adapters/android/qq/api.js +184 -0
  24. package/dist/adapters/android/qq/api.js.map +1 -0
  25. package/dist/adapters/android/qq/index.js +80 -0
  26. package/dist/adapters/android/qq/index.js.map +1 -0
  27. package/dist/adapters/android/samsung/api.js +355 -0
  28. package/dist/adapters/android/samsung/api.js.map +1 -0
  29. package/dist/adapters/android/samsung/index.js +111 -0
  30. package/dist/adapters/android/samsung/index.js.map +1 -0
  31. package/dist/adapters/android/specs.js +106 -0
  32. package/dist/adapters/android/specs.js.map +1 -0
  33. package/dist/adapters/android/stub.js +27 -0
  34. package/dist/adapters/android/stub.js.map +1 -0
  35. package/dist/adapters/android/vivo/api.js +236 -0
  36. package/dist/adapters/android/vivo/api.js.map +1 -0
  37. package/dist/adapters/android/vivo/index.js +121 -0
  38. package/dist/adapters/android/vivo/index.js.map +1 -0
  39. package/dist/adapters/android/xiaomi/api.js +163 -0
  40. package/dist/adapters/android/xiaomi/api.js.map +1 -0
  41. package/dist/adapters/android/xiaomi/index.js +108 -0
  42. package/dist/adapters/android/xiaomi/index.js.map +1 -0
  43. package/dist/adapters/ios/appstore/asc.js +280 -0
  44. package/dist/adapters/ios/appstore/asc.js.map +1 -0
  45. package/dist/adapters/ios/appstore/index.js +323 -0
  46. package/dist/adapters/ios/appstore/index.js.map +1 -0
  47. package/dist/adapters/registry.js +34 -0
  48. package/dist/adapters/registry.js.map +1 -0
  49. package/dist/adapters/util.js +26 -0
  50. package/dist/adapters/util.js.map +1 -0
  51. package/dist/cli/index.js +479 -0
  52. package/dist/cli/index.js.map +1 -0
  53. package/dist/cli/output.js +65 -0
  54. package/dist/cli/output.js.map +1 -0
  55. package/dist/core/exit.js +20 -0
  56. package/dist/core/exit.js.map +1 -0
  57. package/dist/core/orchestrator.js +24 -0
  58. package/dist/core/orchestrator.js.map +1 -0
  59. package/dist/core/types.js +20 -0
  60. package/dist/core/types.js.map +1 -0
  61. package/dist/creds/load.js +68 -0
  62. package/dist/creds/load.js.map +1 -0
  63. package/dist/creds/schema.js +30 -0
  64. package/dist/creds/schema.js.map +1 -0
  65. package/dist/infra/crypto.js +12 -0
  66. package/dist/infra/crypto.js.map +1 -0
  67. package/dist/infra/http.js +87 -0
  68. package/dist/infra/http.js.map +1 -0
  69. package/dist/pkginfo/apk-icon.js +89 -0
  70. package/dist/pkginfo/apk-icon.js.map +1 -0
  71. package/dist/pkginfo/icon.js +83 -0
  72. package/dist/pkginfo/icon.js.map +1 -0
  73. package/dist/pkginfo/index.js +33 -0
  74. package/dist/pkginfo/index.js.map +1 -0
  75. package/docs/android-markets.md +190 -0
  76. package/docs/credentials.md +72 -0
  77. package/docs/providers.md +60 -0
  78. package/lib/common.mjs +265 -0
  79. package/lib/harmony.mjs +190 -0
  80. package/lib/huawei.mjs +153 -0
  81. package/lib/ios.mjs +477 -0
  82. package/package.json +70 -0
  83. package/shipup.mjs +160 -0
package/lib/ios.mjs ADDED
@@ -0,0 +1,477 @@
1
+ // App Store Connect(altool 上传 + ASC API v1 提审/状态/放量)。
2
+ // 发布链路:
3
+ // upload = altool 传 ipa(3 次退避重试,容忍「已上传」)+(--submit-review 时走共享提审流程)
4
+ // submit = 等 build VALID → 建/取 appStoreVersion →(幂等守卫)→ 文案 → 关联 build → reviewSubmissions 三步
5
+ // status = appStoreState 归一化(+灰度进度)
6
+ // release = phased 开灰度 / complete|pause|resume 改灰度 / 默认手动发布请求
7
+ // 鉴权:JWT ES256(dsaEncoding ieee-p1363 直接得 JOSE R||S,无需 DER 转换)。
8
+ import { createPrivateKey, sign } from "node:crypto";
9
+ import { execFile } from "node:child_process";
10
+ import { chmodSync, mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs";
11
+ import { homedir, tmpdir } from "node:os";
12
+ import { join, resolve } from "node:path";
13
+ import { promisify } from "node:util";
14
+ import {
15
+ CliError, ExitCode, log, b64url, fetchJson, sleep, remainingMs, resolveValue, redactText,
16
+ } from "./common.mjs";
17
+
18
+ const execFileAsync = promisify(execFile);
19
+ const ASC_BASE = "https://api.appstoreconnect.apple.com";
20
+
21
+ // appStoreState → 归一化状态
22
+ export function mapAppStoreState(state) {
23
+ switch (state) {
24
+ case "WAITING_FOR_REVIEW":
25
+ case "IN_REVIEW":
26
+ return "pending_review";
27
+ case "PENDING_DEVELOPER_RELEASE":
28
+ case "PENDING_APPLE_RELEASE":
29
+ return "approved";
30
+ case "READY_FOR_SALE":
31
+ return "published";
32
+ case "REJECTED":
33
+ case "DEVELOPER_REJECTED":
34
+ case "METADATA_REJECTED":
35
+ case "INVALID_BINARY":
36
+ return "rejected";
37
+ case "DEVELOPER_REMOVED_FROM_SALE":
38
+ case "REMOVED_FROM_SALE":
39
+ case "REPLACED_WITH_NEW_VERSION":
40
+ return "offline";
41
+ case "PREPARE_FOR_SUBMISSION":
42
+ case "READY_FOR_REVIEW":
43
+ case "PROCESSING_FOR_APP_STORE":
44
+ return "uploaded";
45
+ default:
46
+ return "failed";
47
+ }
48
+ }
49
+
50
+ // 已在审核/发布流程中的状态:重跑跳过提审(幂等,重试安全)
51
+ const IN_FLIGHT_STATES = new Set([
52
+ "WAITING_FOR_REVIEW", "IN_REVIEW", "PENDING_DEVELOPER_RELEASE",
53
+ "PENDING_APPLE_RELEASE", "PROCESSING_FOR_APP_STORE", "READY_FOR_SALE",
54
+ ]);
55
+
56
+ function requireCreds(creds) {
57
+ for (const f of ["issuer_id", "key_id", "private_key"]) {
58
+ if (!creds[f]) {
59
+ throw new CliError(ExitCode.CREDS,
60
+ `ios 凭证缺少 ${f}(App Store Connect → 用户和访问 → 集成 → App Store Connect API → 团队密钥)`);
61
+ }
62
+ }
63
+ }
64
+
65
+ // ---------- ASC 客户端 ----------
66
+
67
+ export function makeAscToken(creds) {
68
+ const now = Math.floor(Date.now() / 1000);
69
+ const header = b64url(JSON.stringify({ alg: "ES256", kid: creds.key_id, typ: "JWT" }));
70
+ const payload = b64url(JSON.stringify({ iss: creds.issuer_id, iat: now, exp: now + 1200, aud: "appstoreconnect-v1" }));
71
+ const key = createPrivateKey(creds.private_key);
72
+ const signature = sign("sha256", Buffer.from(`${header}.${payload}`), { key, dsaEncoding: "ieee-p1363" });
73
+ return `${header}.${payload}.${b64url(signature)}`;
74
+ }
75
+
76
+ const enc = encodeURIComponent;
77
+
78
+ class Asc {
79
+ constructor(creds, deadline) {
80
+ this.creds = creds;
81
+ this.deadline = deadline;
82
+ this.tokenExp = 0;
83
+ }
84
+
85
+ auth() {
86
+ const now = Math.floor(Date.now() / 1000);
87
+ if (!this.token || now >= this.tokenExp - 60) {
88
+ this.token = makeAscToken(this.creds);
89
+ this.tokenExp = now + 1200;
90
+ }
91
+ return this.token;
92
+ }
93
+
94
+ /** 通用请求;非 2xx 合并 ASC errors 抛出。仅 GET 幂等重试。 */
95
+ async fetch(method, path, body) {
96
+ const url = path.startsWith("http") ? path : `${ASC_BASE}${path}`;
97
+ try {
98
+ return await fetchJson(url, {
99
+ method,
100
+ headers: {
101
+ Authorization: `Bearer ${this.auth()}`,
102
+ ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
103
+ },
104
+ body: body !== undefined ? JSON.stringify(body) : undefined,
105
+ }, this.deadline, { retries: method === "GET" ? 3 : 0 });
106
+ } catch (e) {
107
+ // 提审失败常一次返回多条(缺截图/缺合规/缺 whatsNew),全部拼出便于诊断
108
+ if (e instanceof CliError && e.message.includes('"errors"')) {
109
+ try {
110
+ const errs = JSON.parse(e.message.slice(e.message.indexOf("\n") + 1))?.errors;
111
+ if (Array.isArray(errs) && errs.length) {
112
+ const msg = errs.map((x) => [x.title, x.detail].filter(Boolean).join(" — ")).join(" | ");
113
+ throw new CliError(e.exitCode, `ASC ${method} ${path}: ${msg}`, String(errs[0].code ?? e.errorCode));
114
+ }
115
+ } catch (inner) {
116
+ if (inner instanceof CliError) throw inner;
117
+ }
118
+ }
119
+ throw e;
120
+ }
121
+ }
122
+
123
+ async getAppStoreVersion(appId, versionString) {
124
+ const data = await this.fetch("GET",
125
+ `/v1/apps/${enc(appId)}/appStoreVersions?filter[versionString]=${enc(versionString)}&filter[platform]=IOS&limit=1`);
126
+ return data?.data?.[0] ?? null;
127
+ }
128
+
129
+ /** app_id 优先;否则用 bundleId 反查数字 Apple ID。 */
130
+ async resolveAppId(appId, bundleId) {
131
+ if (appId) return appId;
132
+ if (bundleId) {
133
+ const data = await this.fetch("GET", `/v1/apps?filter[bundleId]=${enc(bundleId)}&limit=1`);
134
+ const id = data?.data?.[0]?.id;
135
+ if (!id) throw new CliError(ExitCode.FAIL, `ASC 未找到 bundleId=${bundleId} 对应的 app(确认 API key 有此 app 权限)`);
136
+ return String(id);
137
+ }
138
+ throw new CliError(ExitCode.CREDS, "需要 creds.app_id(App Store 数字 ID)或 bundle_id 之一");
139
+ }
140
+
141
+ /**
142
+ * 轮询 build 直到 processingState=VALID。空结果只容忍 5 分钟(通常是上游上传失败),
143
+ * 见过 build 后正常等到总超时。CFBundleVersion+营销版本双过滤,取最新上传。
144
+ */
145
+ async waitBuildValid(appId, buildVersion, versionString) {
146
+ const train = versionString ? `&filter[preReleaseVersion.version]=${enc(versionString)}` : "";
147
+ const query = `/v1/builds?filter[app]=${enc(appId)}&filter[version]=${enc(buildVersion)}${train}&sort=-uploadedDate&limit=1`;
148
+ const notFoundDeadline = Math.min(Date.now() + 300_000, this.deadline);
149
+ let seen = false;
150
+ for (let attempt = 1; ; attempt++) {
151
+ const data = await this.fetch("GET", query);
152
+ const build = data?.data?.[0];
153
+ const state = build?.attributes?.processingState;
154
+ if (state === "VALID") return build.id;
155
+ if (state === "FAILED" || state === "INVALID") {
156
+ throw new CliError(ExitCode.FAIL, `build ${buildVersion} 处理失败: ${state}`, state);
157
+ }
158
+ if (build) seen = true;
159
+ log(`[asc] 等待 build ${buildVersion}(${versionString}) 就绪… 第 ${attempt} 次,state=${state ?? "未出现"}`);
160
+ if (!seen && Date.now() > notFoundDeadline) {
161
+ throw new CliError(ExitCode.FAIL,
162
+ `ASC 上找不到 build ${buildVersion}(5 分钟内未出现)。通常是上传失败/未完成——确认 ipa 已成功传到 ASC 再提审。`,
163
+ "build_not_found");
164
+ }
165
+ await sleep(15_000, this.deadline);
166
+ }
167
+ }
168
+
169
+ async ensureAppStoreVersion(appId, versionString, releaseType) {
170
+ const existing = await this.getAppStoreVersion(appId, versionString);
171
+ if (existing) {
172
+ if (releaseType && existing.attributes?.releaseType !== releaseType) {
173
+ await this.fetch("PATCH", `/v1/appStoreVersions/${enc(existing.id)}`, {
174
+ data: { type: "appStoreVersions", id: existing.id, attributes: { releaseType } },
175
+ });
176
+ }
177
+ return existing.id;
178
+ }
179
+ const attributes = { platform: "IOS", versionString };
180
+ if (releaseType) attributes.releaseType = releaseType;
181
+ const res = await this.fetch("POST", "/v1/appStoreVersions", {
182
+ data: { type: "appStoreVersions", attributes, relationships: { app: { data: { type: "apps", id: appId } } } },
183
+ });
184
+ return res.data.id;
185
+ }
186
+
187
+ /** 写多 locale 文案:已存在 PATCH,否则 POST(仅写非空字段)。 */
188
+ async writeLocalizations(versionId, byLocale) {
189
+ const locales = Object.keys(byLocale);
190
+ if (!locales.length) return;
191
+ const list = await this.fetch("GET", `/v1/appStoreVersions/${enc(versionId)}/appStoreVersionLocalizations?limit=50`);
192
+ const existing = list?.data ?? [];
193
+ for (const locale of locales) {
194
+ const attrs = {};
195
+ for (const [k, v] of Object.entries(byLocale[locale])) if (v != null && v !== "") attrs[k] = v;
196
+ if (!Object.keys(attrs).length) continue;
197
+ const found = existing.find((l) => l.attributes?.locale === locale);
198
+ if (found) {
199
+ await this.fetch("PATCH", `/v1/appStoreVersionLocalizations/${enc(found.id)}`, {
200
+ data: { type: "appStoreVersionLocalizations", id: found.id, attributes: attrs },
201
+ });
202
+ } else {
203
+ await this.fetch("POST", "/v1/appStoreVersionLocalizations", {
204
+ data: {
205
+ type: "appStoreVersionLocalizations",
206
+ attributes: { locale, ...attrs },
207
+ relationships: { appStoreVersion: { data: { type: "appStoreVersions", id: versionId } } },
208
+ },
209
+ });
210
+ }
211
+ log(`✓ 文案已写入 ${locale}(${Object.keys(attrs).join(", ")})`);
212
+ }
213
+ }
214
+
215
+ async attachBuild(versionId, buildId) {
216
+ await this.fetch("PATCH", `/v1/appStoreVersions/${enc(versionId)}/relationships/build`, {
217
+ data: { type: "builds", id: buildId },
218
+ });
219
+ }
220
+
221
+ /** reviewSubmissions 三步:创建 → 加 item → submitted=true。 */
222
+ async submitForReview(appId, versionId) {
223
+ const sub = await this.fetch("POST", "/v1/reviewSubmissions", {
224
+ data: { type: "reviewSubmissions", attributes: { platform: "IOS" }, relationships: { app: { data: { type: "apps", id: appId } } } },
225
+ });
226
+ await this.fetch("POST", "/v1/reviewSubmissionItems", {
227
+ data: {
228
+ type: "reviewSubmissionItems",
229
+ relationships: {
230
+ reviewSubmission: { data: { type: "reviewSubmissions", id: sub.data.id } },
231
+ appStoreVersion: { data: { type: "appStoreVersions", id: versionId } },
232
+ },
233
+ },
234
+ });
235
+ await this.fetch("PATCH", `/v1/reviewSubmissions/${enc(sub.data.id)}`, {
236
+ data: { type: "reviewSubmissions", id: sub.data.id, attributes: { submitted: true } },
237
+ });
238
+ }
239
+
240
+ async getPhasedRelease(versionId) {
241
+ const data = await this.fetch("GET", `/v1/appStoreVersions/${enc(versionId)}/appStoreVersionPhasedRelease`);
242
+ return data?.data ?? null;
243
+ }
244
+
245
+ async startPhasedRelease(versionId) {
246
+ await this.fetch("POST", "/v1/appStoreVersionPhasedReleases", {
247
+ data: { type: "appStoreVersionPhasedReleases", relationships: { appStoreVersion: { data: { type: "appStoreVersions", id: versionId } } } },
248
+ });
249
+ }
250
+
251
+ async setPhasedReleaseState(phasedId, state) {
252
+ await this.fetch("PATCH", `/v1/appStoreVersionPhasedReleases/${enc(phasedId)}`, {
253
+ data: { type: "appStoreVersionPhasedReleases", id: phasedId, attributes: { phasedReleaseState: state } },
254
+ });
255
+ }
256
+
257
+ async requestRelease(versionId) {
258
+ await this.fetch("POST", "/v1/appStoreVersionReleaseRequests", {
259
+ data: { type: "appStoreVersionReleaseRequests", relationships: { appStoreVersion: { data: { type: "appStoreVersions", id: versionId } } } },
260
+ });
261
+ }
262
+ }
263
+
264
+ // ---------- ipa 版本解析(macOS 原生 unzip + plutil,fail-soft)----------
265
+
266
+ async function readIpaVersion(ipaPath) {
267
+ const tmp = join(tmpdir(), `shipup-info-${process.pid}-${Date.now()}.plist`);
268
+ try {
269
+ const { stdout: listing } = await execFileAsync("unzip", ["-Z1", ipaPath], { maxBuffer: 16 * 1024 * 1024 });
270
+ const entry = listing.split("\n").find((l) => /^Payload\/[^/]+\.app\/Info\.plist$/.test(l.trim()));
271
+ if (!entry) return null;
272
+ await execFileAsync("unzip", ["-p", ipaPath, entry.trim()], { encoding: "buffer", maxBuffer: 16 * 1024 * 1024 })
273
+ .then(({ stdout }) => writeFileSync(tmp, stdout));
274
+ const { stdout } = await execFileAsync("plutil", ["-convert", "json", "-o", "-", tmp]);
275
+ const plist = JSON.parse(stdout);
276
+ return {
277
+ bundleId: plist.CFBundleIdentifier,
278
+ versionName: plist.CFBundleShortVersionString,
279
+ versionCode: plist.CFBundleVersion,
280
+ };
281
+ } catch {
282
+ return null;
283
+ } finally {
284
+ rmSync(tmp, { force: true });
285
+ }
286
+ }
287
+
288
+ // ---------- altool ----------
289
+
290
+ /** p8 落盘到 ~/.appstoreconnect/private_keys/AuthKey_<key_id>.p8(0600)供 altool 读取。 */
291
+ function writeAuthKey(keyId, privateKey) {
292
+ const dir = join(homedir(), ".appstoreconnect", "private_keys");
293
+ mkdirSync(dir, { recursive: true });
294
+ const file = join(dir, `AuthKey_${keyId}.p8`);
295
+ writeFileSync(file, privateKey, { mode: 0o600 });
296
+ chmodSync(file, 0o600);
297
+ return file;
298
+ }
299
+
300
+ async function uploadViaAltool(ipaPath, creds, deadline) {
301
+ writeAuthKey(creds.key_id, creds.private_key);
302
+ const args = ["altool", "--upload-app", "--type", "ios", "--file", ipaPath, "--apiKey", creds.key_id, "--apiIssuer", creds.issuer_id];
303
+ const maxAttempts = 3;
304
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
305
+ try {
306
+ await execFileAsync("xcrun", args, { timeout: remainingMs(deadline), maxBuffer: 16 * 1024 * 1024 });
307
+ return;
308
+ } catch (e) {
309
+ const detail = redactText(String(e?.stderr || e?.stdout || e?.message || e)).slice(0, 1000);
310
+ // 重试时若提示「已上传/冗余二进制」,说明上一次其实已成功,按成功处理
311
+ if (attempt > 1 && /already been uploaded|redundant binary|same version|already exists/i.test(detail)) {
312
+ log("[altool] 检测到二进制已上传,按成功处理");
313
+ return;
314
+ }
315
+ if (attempt === maxAttempts) throw new CliError(ExitCode.FAIL, `altool 上传失败: ${detail}`, "altool");
316
+ log(`[retry] altool 第 ${attempt}/${maxAttempts} 次失败,${2000 * attempt}ms 后重试: ${detail.slice(0, 200)}`);
317
+ await sleep(2000 * attempt, deadline);
318
+ }
319
+ }
320
+ }
321
+
322
+ // ---------- 共享提审流程 ----------
323
+
324
+ export function buildLocaleMap(opts, credsBaseDir) {
325
+ if (opts.metadata) {
326
+ const p = resolve(opts.metadata);
327
+ if (!existsSync(p)) throw new CliError(ExitCode.MISSING_INPUT, `metadata 文件不存在: ${p}`);
328
+ // 简易两级 YAML:locale → {whatsNew/description/promotionalText/keywords}
329
+ const map = {};
330
+ let cur = null;
331
+ for (const line of readFileSync(p, "utf8").split("\n")) {
332
+ const noComment = line.replace(/(^|\s)#.*$/, "");
333
+ if (!noComment.trim()) continue;
334
+ const m = noComment.trim().match(/^([\w-]+)\s*:\s*(.*)$/);
335
+ if (!m) continue;
336
+ let value = m[2].trim().replace(/^["']|["']$/g, "");
337
+ if (!/^\s/.test(noComment) && !value) { cur = m[1]; map[cur] = {}; }
338
+ else if (cur && value) map[cur][m[1]] = resolveValue(value, credsBaseDir);
339
+ }
340
+ return map;
341
+ }
342
+ if (opts.whatsNew) return { "zh-Hans": { whatsNew: resolveValue(opts.whatsNew, process.cwd()) } };
343
+ return {};
344
+ }
345
+
346
+ async function runSubmitFlow(client, { appId, version, buildVersion, localeMap, releaseType }) {
347
+ // 幂等守卫:版本已在审核/发布流程中则跳过重复提审
348
+ const existing = await client.getAppStoreVersion(appId, version);
349
+ const state = existing?.attributes?.appStoreState;
350
+ if (existing && state && IN_FLIGHT_STATES.has(state)) {
351
+ return { status: mapAppStoreState(state), message: `版本已处于 ${state},跳过重复提审` };
352
+ }
353
+ const buildId = await client.waitBuildValid(appId, buildVersion, version);
354
+ log(`✓ build 就绪 (${buildId})`);
355
+ const versionId = await client.ensureAppStoreVersion(appId, version, releaseType);
356
+ await client.writeLocalizations(versionId, localeMap);
357
+ await client.attachBuild(versionId, buildId);
358
+ await client.submitForReview(appId, versionId);
359
+ log("✓ 已提交审核");
360
+ return { status: "submitted", message: "" };
361
+ }
362
+
363
+ // ---------- 命令 ----------
364
+
365
+ export async function upload(creds, opts, deadline) {
366
+ requireCreds(creds);
367
+ const ipaPath = resolve(opts.package);
368
+ if (!existsSync(ipaPath)) throw new CliError(ExitCode.MISSING_INPUT, `ipa 不存在: ${ipaPath}`);
369
+
370
+ const info = await readIpaVersion(ipaPath);
371
+ if (info) {
372
+ log(`ipa: bundleId=${info.bundleId} version=${info.versionName}(${info.versionCode})`);
373
+ if (creds.bundle_id && info.bundleId !== creds.bundle_id) {
374
+ throw new CliError(ExitCode.MISSING_INPUT,
375
+ `bundleId 不匹配: ipa 是 ${info.bundleId},凭证 bundle_id 是 ${creds.bundle_id}(防止传错 app)`);
376
+ }
377
+ }
378
+ const version = opts.version || info?.versionName;
379
+ const buildVersion = opts.build || info?.versionCode;
380
+ if (opts.submitReview && (!version || !buildVersion)) {
381
+ throw new CliError(ExitCode.USAGE, "--submit-review 需要版本号:ipa 解析失败时请传 --version 与 --build");
382
+ }
383
+
384
+ if (opts.dryRun) {
385
+ log(`[dry-run] 将 altool 上传${opts.submitReview ? " 并提审" : ""},不执行`);
386
+ return { status: "skipped", message: "dry-run", ...info };
387
+ }
388
+
389
+ log("altool 上传 ipa …");
390
+ await uploadViaAltool(ipaPath, creds, deadline);
391
+ log("✓ 上传完成(ASC 处理需数分钟)");
392
+
393
+ if (opts.submitReview) {
394
+ const client = new Asc(creds, deadline);
395
+ const appId = await client.resolveAppId(creds.app_id, creds.bundle_id);
396
+ const flow = await runSubmitFlow(client, {
397
+ appId, version, buildVersion,
398
+ localeMap: buildLocaleMap(opts, creds._baseDir),
399
+ releaseType: opts.releaseType,
400
+ });
401
+ return { ...flow, versionName: version, versionCode: buildVersion };
402
+ }
403
+ return { status: "uploaded", versionName: version, versionCode: buildVersion };
404
+ }
405
+
406
+ export async function submit(creds, opts, deadline) {
407
+ requireCreds(creds);
408
+ if (!opts.version || !opts.build) {
409
+ throw new CliError(ExitCode.USAGE, "ios submit 需要 --version(营销版本)与 --build(CFBundleVersion)");
410
+ }
411
+ if (opts.releaseType && !["MANUAL", "AFTER_APPROVAL"].includes(opts.releaseType)) {
412
+ throw new CliError(ExitCode.USAGE, `--release-type 仅支持 MANUAL | AFTER_APPROVAL(收到 ${opts.releaseType})`);
413
+ }
414
+ if (opts.dryRun) {
415
+ log(`[dry-run] 将提审 version=${opts.version} build=${opts.build}`);
416
+ return { status: "skipped", message: "dry-run" };
417
+ }
418
+ const client = new Asc(creds, deadline);
419
+ const appId = await client.resolveAppId(creds.app_id, creds.bundle_id);
420
+ const flow = await runSubmitFlow(client, {
421
+ appId,
422
+ version: opts.version,
423
+ buildVersion: opts.build,
424
+ localeMap: buildLocaleMap(opts, creds._baseDir),
425
+ releaseType: opts.releaseType,
426
+ });
427
+ return { ...flow, versionName: opts.version, versionCode: opts.build };
428
+ }
429
+
430
+ export async function status(creds, opts, deadline) {
431
+ requireCreds(creds);
432
+ if (!opts.version) throw new CliError(ExitCode.USAGE, "ios status 需要 --version");
433
+ if (opts.dryRun) return { status: "skipped", message: "dry-run" };
434
+ const client = new Asc(creds, deadline);
435
+ const appId = await client.resolveAppId(creds.app_id, creds.bundle_id);
436
+ const v = await client.getAppStoreVersion(appId, opts.version);
437
+ const state = v?.attributes?.appStoreState ?? null;
438
+ let message = state ?? "version not found";
439
+ if (v && (state === "READY_FOR_SALE" || state === "PENDING_DEVELOPER_RELEASE")) {
440
+ try {
441
+ const phased = await client.getPhasedRelease(v.id);
442
+ const ps = phased?.attributes?.phasedReleaseState;
443
+ if (ps) message += ` | phased=${ps} day=${phased.attributes.currentDayNumber ?? "-"}`;
444
+ } catch { /* 灰度查询失败不影响主状态 */ }
445
+ }
446
+ log(`appStoreState=${state ?? "未找到该版本"}`);
447
+ return { status: mapAppStoreState(state), message, versionName: v?.attributes?.versionString };
448
+ }
449
+
450
+ export async function release(creds, opts, deadline) {
451
+ requireCreds(creds);
452
+ if (!opts.version) throw new CliError(ExitCode.USAGE, "ios release 需要 --version");
453
+ if (opts.dryRun) return { status: "skipped", message: "dry-run" };
454
+ const client = new Asc(creds, deadline);
455
+ const appId = await client.resolveAppId(creds.app_id, creds.bundle_id);
456
+ const v = await client.getAppStoreVersion(appId, opts.version);
457
+ if (!v) throw new CliError(ExitCode.FAIL, `未找到 appStoreVersion: ${opts.version}`, "not_found");
458
+
459
+ let status = "published";
460
+ let message;
461
+ if (opts.complete || opts.pause || opts.resume) {
462
+ const phased = await client.getPhasedRelease(v.id);
463
+ if (!phased) throw new CliError(ExitCode.FAIL, "当前版本未开启灰度发布", "no_phased");
464
+ const state = opts.complete ? "COMPLETE" : opts.pause ? "PAUSED" : "ACTIVE";
465
+ await client.setPhasedReleaseState(phased.id, state);
466
+ message = `phasedReleaseState=${state}`;
467
+ status = opts.complete ? "published" : "approved";
468
+ } else if (opts.phased) {
469
+ await client.startPhasedRelease(v.id);
470
+ message = "phased release started";
471
+ } else {
472
+ await client.requestRelease(v.id);
473
+ message = "release requested";
474
+ }
475
+ log(`✓ ${message}`);
476
+ return { status, message, versionName: opts.version };
477
+ }
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "shipup",
3
+ "version": "0.2.0",
4
+ "description": "Cross-platform release CLI for App Store Connect, AppGallery Connect, and Android app markets",
5
+ "type": "module",
6
+ "bin": {
7
+ "shipup": "./shipup.mjs"
8
+ },
9
+ "files": [
10
+ "shipup.mjs",
11
+ "dist/",
12
+ "lib/",
13
+ "docs/",
14
+ "creds.example.yaml",
15
+ "README.md",
16
+ "README.zh-CN.md",
17
+ "CHANGELOG.md",
18
+ "LICENSE",
19
+ "SECURITY.md"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.json",
23
+ "typecheck": "tsc -p tsconfig.json --noEmit",
24
+ "check": "npm run typecheck && node --check shipup.mjs && node --check lib/common.mjs && node --check lib/harmony.mjs && node --check lib/huawei.mjs && node --check lib/ios.mjs && node --check scripts/security-scan.mjs",
25
+ "security": "node scripts/security-scan.mjs",
26
+ "test": "npm run build && node --test test/*.test.mjs",
27
+ "test:coverage": "npm run build && node --test --experimental-test-coverage test/*.test.mjs",
28
+ "pack:check": "npm pack --dry-run",
29
+ "prepack": "npm run build",
30
+ "prepare": "npm run build"
31
+ },
32
+ "engines": {
33
+ "node": ">=18.17"
34
+ },
35
+ "keywords": [
36
+ "app-store-connect",
37
+ "appgallery-connect",
38
+ "harmonyos",
39
+ "android",
40
+ "app-market",
41
+ "ios",
42
+ "release",
43
+ "cli"
44
+ ],
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/HenryHaoson/shipup.git"
48
+ },
49
+ "bugs": {
50
+ "url": "https://github.com/HenryHaoson/shipup/issues"
51
+ },
52
+ "homepage": "https://github.com/HenryHaoson/shipup#readme",
53
+ "license": "0BSD",
54
+ "publishConfig": {
55
+ "access": "public"
56
+ },
57
+ "dependencies": {
58
+ "adm-zip": "^0.5.16",
59
+ "app-info-parser": "^1.0.1",
60
+ "commander": "^12.1.0",
61
+ "jimp": "^1.6.1",
62
+ "p-limit": "^6.1.0",
63
+ "yaml": "^2.5.1"
64
+ },
65
+ "devDependencies": {
66
+ "@types/adm-zip": "^0.5.5",
67
+ "@types/node": "^22.7.0",
68
+ "typescript": "^5.6.2"
69
+ }
70
+ }