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
@@ -0,0 +1,190 @@
1
+ # 应用市场「素材 / 元数据」可更新能力与规格要求
2
+
3
+ > **目的**:梳理各 Android 应用市场(华为 / 荣耀 / OPPO / vivo / 小米 / 三星 / 应用宝 / 魅族)通过**开放平台 API** 能更新哪些素材与元数据,以及每个渠道的**素材规格**(图标、截图、视频的格式 / 尺寸 / 大小)与**文案规则**(长度限制、能否用 emoji),为传包工具 shipup 规划「通用元数据更新」能力提供依据。
4
+ >
5
+ > **来源与可信度标注**:`[官]` = 渠道开放平台官方文档/审核规范;`[转]` = 第三方明确转述官方规范、多源一致;`[三]` = 第三方上架指南经验值;`[代]` = 现有对接代码实证;`[?]` = 各源冲突或未找到权威依据,需登录控制台实测。
6
+ >
7
+ > **字段名/规格已对 8 家官方文档逐一核实**(浏览器渲染直读)。素材的精确数值(标 `[三]` / `[?]`)仍以各渠道控制台上传表单为准。
8
+ >
9
+ > **实现状态(shipup 0.2.0)**:截图 / 应用名 / 一句话简介 / 长描述更新 + 图标按渠道压缩**已落地**(扩展 `upload`,按需生效)。各渠道字段名均已官方确认(详见第七节)。分类 / 年龄分级 / 视频 / 不换包更新 **暂未实现**。尚未做真实凭证沙箱联调。
10
+
11
+ ---
12
+
13
+ ## 一、各渠道「可通过 API 更新」能力矩阵
14
+
15
+ 下表为**各渠道 API 能力**:`✅` 支持 `❌` 不支持(需后台网页维护) `❓` 待核实。「shipup 实现状态」见表下注。
16
+
17
+ | 素材 / 元数据 | 华为 | 荣耀 | OPPO | vivo | 小米 | 三星 | 应用宝 | 魅族 |
18
+ |---|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|
19
+ | APK 安装包 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
20
+ | 应用图标 | ✅ | ✅ | ✅ | ✅ | ✅必传 | ✅ | ✅ | ✅必传 |
21
+ | 截图 / 介绍图 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
22
+ | 更新说明 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
23
+ | 应用名称 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅限改 | ✅ |
24
+ | 一句话简介 / 推荐语 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
25
+ | 应用简介 / 长描述 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
26
+ | 隐私政策 URL | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❓ | ✅ |
27
+ | 分类 | ❌ | ❓ | ✅ | ✅ | ✅ | ❓ | ✅ | ✅ |
28
+ | 年龄分级 | ❌后台 | ❓ | ✅ | ✅ | ❌后台 | ✅ | ❓ | ✅ |
29
+ | 关键词 / 标签 | ❌ | ❌ | ❌ | ❌ | ✅keyWords | ❓ | ❓ | ✅keyword |
30
+ | 宣传视频 | ✅ | ✅ | ✅ | ✅ | ✅后台 | △仅YouTube | ✅ | ❌ |
31
+ | **不换包仅改元数据** | ✅* | ❓ | ✅ updm | ❌ | ❓ | ❓ | ✅ | ❌ |
32
+
33
+ \* 华为:更新基本信息 / 语言描述 / 文件信息是各自独立的接口,理论上可不带新包再 `app-submit` 提审,但「无新包能否提审」官方未逐字确认,建议实测。OPPO 有独立的「更新资料」端点 `app/updm`(不新增版本);应用宝 `update_app` 文档明确「也可仅更新基础信息,不更新文件」。vivo / 魅族的发布接口强制带新包。
34
+
35
+ > **shipup 0.2.0 实现状态**:**已实现**(随包按需更新)—— 图标(按渠道压缩)、截图、应用名、一句话简介、长描述、更新说明,覆盖全部 8 渠道;荣耀曾因文档误判暂缺,现已补齐。**暂未实现** —— 分类 / 年龄分级 / 关键词 / 视频 / 隐私(除已有渠道)/「不换包仅改元数据」。
36
+
37
+ **三条关键观察**:
38
+ 1. **截图 / 介绍图**:8 个渠道 API 全部支持,**shipup 0.2.0 已实现**(各渠道经各自上传接口换图,按需生效;不传则沿用线上)。
39
+ 2. **应用宝远比预期能打**:官方 `update_app` 支持图标、截图、简介、分类、视频,且支持不换包只改基础信息。此前"应用宝不用图标/截图"的判断是错的——**已在 shipup 0.2.0 补全**(图标 / 截图 / 应用名 / 简介)。
40
+
41
+ ---
42
+
43
+ ## 二、图标规格
44
+
45
+ | 渠道 | 格式 | 尺寸(px) | 大小上限 | 形状 | API 是否必传 | 来源 |
46
+ |---|---|---|---|---|---|---|
47
+ | 华为 | PNG / WebP(部分 JPG) | **216×216**(也支持 512×512) | PNG ≤2MB / WebP ≤100KB | 正方形直角 | 可选 | [官] |
48
+ | 荣耀 | PNG / JPG / JPEG | **512×512** | **≤200KB** | 直角 | 可选 | [官](doc 101359 文件类型表) |
49
+ | OPPO | PNG | 512×512 | <1MB | 直角,无圆角无黑边 | 可选 | [三] |
50
+ | vivo | JPG / PNG | 256² 或 512² | **≤50KB**(审核口径,控制台或更宽) | 直角 | 可选 | [转] |
51
+ | 小米 | PNG | 512×512 | <1MB | 清晰完整 | **必传** | [官]+[代] |
52
+ | 三星 | PNG | 512×512 | ≤1MB(1024KB) | 正方形,可透明 | 可选 | [官] |
53
+ | 应用宝 | PNG | 512×512 | **≤200KB** | 直角 | 可选 | **[官]** |
54
+ | 魅族 | PNG | 512×512 | ≤1MB | 正方形,透明背景 | **必传** | [三]+[代] |
55
+
56
+ > ⚠️ **尺寸/大小差异大**:华为 216² / 其余 512²(荣耀亦 512²,官方 doc 101359 已确认);**应用宝、荣耀 ≤200KB、vivo ≤50KB** 比一般 1MB 严很多。shipup 已对图标按渠道大小上限自动压缩(超限逐步降尺寸)。形状都要求**直角原图**(圆角由系统裁切)。
57
+
58
+ ---
59
+
60
+ ## 三、截图 / 介绍图规格
61
+
62
+ | 渠道 | 格式 | 尺寸(px) | 数量 | 单张大小 | 横竖屏 | 来源 |
63
+ |---|---|---|---|---|---|---|
64
+ | 华为 | PNG/JPG/JPEG(WebP) | 800×450 或 450×800(单边 320–3840) | 3–8 | ≤2MB(WebP ≤100KB) | 横/竖均可 | [官] |
65
+ | 荣耀 | PNG/JPG/JPEG | 竖 1080×1920(fileType=3) / 横 1920×1080(fileType=2) | **3–5** | **≤5MB** | 横/竖(order 0-4) | [官] doc 101359 |
66
+ | OPPO | JPG/PNG | 竖 1080×1920 / 横 1915×1080 | **3–5** | ≤1MB | 横竖均可(横版选填) | [三] |
67
+ | vivo | JPG/PNG | 竖 1080×1920 / 横 1280×720·1920×1080 | **3–5** | ≤2MB | **必须全横或全竖** | [转] |
68
+ | 小米 | PNG/JPG/JPEG/GIF/BMP/WEBP | 竖 1080×1920 / 横 1920×1080 | **≥3**(游戏 ≥4) | ≤5MB | 横/竖;平板另传一套 | [官]+[三] |
69
+ | 三星 | JPG/PNG | 每边 320–3840,长宽比 ≤2:1 | **≥4**(最多展示 8) | ❓(移动端未明确) | 横/竖 | [官] |
70
+ | 应用宝 | JPG/PNG | 建议 1080×1920,最小 320×480 | **4–5** | ≤1M | 所有图宽高须一致 | **[官]** |
71
+ | 魅族 | JPG(也支持 PNG) | 800×1280 / 1080×1800 / 1152×1920 / 1536×2560 等(比例 1:1.6~1.7) | ❓ | **≤5MB** | 竖/横 | [三] |
72
+
73
+ > 共性:截图须为真机/自制效果图,**不得含他牌手机外观、他家商店水印、状态栏通知图标、黑白边、"敬请期待"占位**。
74
+
75
+ ---
76
+
77
+ ## 四、文案长度限制
78
+
79
+ | 渠道 | 应用名称 | 一句话简介 / 推荐语 | 应用简介 / 长描述 | 更新说明 | 来源 |
80
+ |---|---|---|---|---|---|
81
+ | 华为 | ≤15 汉字 / 30 字符 | briefInfo **≤80 字符**(建议 ≤25) | appDesc **≤8000 字符** | newFeatures **≤500 字符** | [官] |
82
+ | 荣耀 | ≤15 汉字 / 30 字符 | briefIntro **≤80 字符** | intro **≤8000 字符** | newFeature **≤500 字符** | [官] doc 101359 |
83
+ | OPPO | ≤15 汉字 / 30 英文(副标题 ≤10 字) | summary **≤13 字符,禁标点/空格** | detail_desc **≥20 字** | update_desc **≥5 字** | [三] |
84
+ | vivo | 名称+副标题 ≤15 汉字 / 30 英文 | simpleDesc **5–16 汉字** | detailDesc **≥50 字** | updateDesc **≥5 字** | [转] |
85
+ | 小米 | 网页 ≤20 字符 / 手机端显示名 ≤8 汉字 | brief **≤17 汉字 / 34 字符,句末勿标点** | desc 无明确上限 | updateDesc 不得为空 / 不与上版相同 | [官] |
86
+ | 三星 | appTitle **100 字节**(中国区 ≤33 汉字) | shortDescription **40 字节(约 13 汉字)** | longDescription **4000 字节** | newFeature **4000 字节** | [官] |
87
+ | 应用宝 | ≤20 汉字 / 40 字符(**1 年限改 2 次**,改后需重传软著) | ≤15 字 | 60–500 字 | feature ≤500 字符(存疑) | [官]限改 / [三]字数 |
88
+ | 魅族 | **≤10 中文 / 20 英文** | recommendDesc ❓ | appDesc **100–1000 字符** | verDesc ❓ | [官] |
89
+
90
+ > ⚠️ **三星按 UTF-8 字节计**(1 汉字 = 3 字节、1 emoji = 4 字节),`shortDescription` 仅 40 字节≈13 个汉字,很短。
91
+ > ⚠️ **长描述上限差异巨大**:华为 8000 字符 vs 魅族 1000 字符 vs OPPO/vivo 只给下限。做通用更新时同一份长文案不能无脑发所有渠道,需按渠道截断/校验。
92
+
93
+ ---
94
+
95
+ ## 五、emoji 与文案格式规则
96
+
97
+ **结论:应用名称几乎所有渠道都明确禁止 emoji / 特殊符号;正文(简介 / 更新说明)普遍"无明确允许",建议一律不用 emoji。**
98
+
99
+ | 渠道 | 应用名称 | 简介 / 更新说明正文 | 来源 |
100
+ |---|---|---|---|
101
+ | 华为 | 禁 emoji / 特殊符号(`* & - ( )` 等) | 无明文;建议纯文本,禁极限词,不支持 HTML | [官]+[三] |
102
+ | 荣耀 | 禁特殊字符 `# * & - ( )`、占位符、乱码(emoji 视同禁止) | 无明文;建议不用 emoji | [官] |
103
+ | OPPO | 禁破折号/空格/星号等特殊符号 | summary 禁标点空格→等于禁 emoji;正文无明文,建议不用 | [三] |
104
+ | vivo | **仅限汉字/数字/字母 + "+"**;禁 emoji/特殊符号 | 一句话简介标点受限(仅逗号/感叹/顿号/问号/空格);正文不建议 emoji | [转] |
105
+ | 小米 | 禁特殊符号(`# * &` 等) | 无明文;建议避免 emoji 与 `#*&` | [官] |
106
+ | 三星 | 中国区**仅允许** `+ ( ) _ : -`;其余符号/emoji 禁 | 无明文;注意 emoji 占 4 字节快速吃额度 | [官] |
107
+ | 应用宝 | 禁特殊符号 / 空格 | 简介禁装饰性符号(★、- 等);emoji 无官方允许,建议禁用 | [三] |
108
+ | 魅族 | 禁 `@ # ¥ % & *` 等特殊字符(emoji 基本不可用) | 无明文;建议规避 | [官] |
109
+
110
+ ---
111
+
112
+ ## 六、宣传视频规格
113
+
114
+ | 渠道 | 是否支持 | 格式 | 大小 | 时长 | 尺寸 | 来源 |
115
+ |---|---|---|---|---|---|---|
116
+ | 华为 | ✅(1–3 个) | MOV / MP4 | ≤500MB | 15s–2min | 横 1280×720 / 竖 720×1280 | [官] |
117
+ | 荣耀 | ✅(弱确认) | ❓ | ❓ | ❓ | 16:9 / 9:16 | [官弱] |
118
+ | OPPO | ✅(选填) | MP4 | <500MB | ❓ | ❓ | [三] |
119
+ | vivo | ✅ | MP4 / MOV | ≤500MB | 15s–2min | 1080×1920(9:16);封面 ≥450×800 ≤2MB | [三] |
120
+ | 小米 | ✅(后台手动) | 多格式(mp4/mov/mkv/avi…) | ❓ | 应用 15–30s / 游戏 15–90s | 横版(禁竖屏黑边) | [官]+[三] |
121
+ | 三星 | △ 仅 YouTube 外链 | — | — | — | — | [官](中国区不可用) |
122
+ | 应用宝 | ✅ | MP4(存疑) | ❓ | ~20–30s(存疑) | 竖 1080×1920(存疑) | [三] |
123
+ | 魅族 | ❌ 发布接口无视频字段 | — | — | — | — | [代] |
124
+
125
+ ---
126
+
127
+ ## 七、官方核实结果与字段落地
128
+
129
+ 各渠道字段名均已对官方文档逐一核实(浏览器渲染),并落到 shipup 0.2.0:
130
+
131
+ | 渠道 | 截图 | 文案字段(应用名 / 一句话 / 长描述) | 核实来源 |
132
+ |---|---|---|---|
133
+ | 应用宝 | `snapshots_file_serial_number`(`get_file_upload_info` file_type=`img`,COS 上传,`|` 分隔) | `app_name`(+`modify_app_name_reason`) / `one_word_summary` / `introduce` | 官方 API `iwiki/4015262492` |
134
+ | 华为 | `app-file-info` fileType=2 + `imgShowType`(整组替换) | `appName` / `briefInfo`(≤80) / `appDesc`(≤8000),走 `app-language-info` | 官方 Publishing API |
135
+ | 荣耀 | `fileType=3`(纵向 1080×1920),3–5 张 ≤5MB,按 `order 0-4` 绑定 | `appName` / `briefIntro`(≤80) / `intro`(≤8000),走 `update-language-info` | 官方 doc 101359 |
136
+ | 小米 | `screenshot_1..4`(multipart,SIG 同序加 md5) | `appName` / `brief`(≤17汉字) / `desc`,走 `dev/push.appInfo` | 官方 dev.mi.com |
137
+ | vivo | `app.upload.screenshot` → `screenshot`(流水号逗号分隔,3-5 张) | `app_name` / `simpleDesc` / `detailDesc`(50-1000) | 官方 doc/343 |
138
+ | 三星 | `screenshots`=`[{screenshotKey: fileKey, reuseYn:false}]` | `appTitle` / `shortDescription`(40字节) / `longDescription`(4000字节) | 官方 Content Publish API |
139
+ | 魅族 | `screenShots`=`List<String>`(`/image/upload` 得 destFileName) | `appName` / `recommendDesc` / `appDesc`(100-1000) | 官方 doc/333 |
140
+ | OPPO | `pic_url`(`get-upload-url` type=photo 上传,竖版) | `app_name` / `summary`(≤13) / `detail_desc`(≥20) | working 集成 + 官方端点 |
141
+
142
+ ### 图标行为(已修 + 压缩)
143
+ - 官方语义:华为/OPPO/vivo 图标可选(不传 = 保留线上);小米/魅族每次必传。
144
+ - shipup 0.2.0:可选渠道默认不动图标、小米/魅族要求 `--icon`,且**按渠道大小上限自动压缩**(vivo ≤50KB、应用宝/荣耀 ≤200KB)→ 与官方语义一致、不再超限。
145
+
146
+ ### 暂未实现(API 支持,后续可补)
147
+ - **分类 / 年龄分级 / 关键词 / 视频**:部分渠道支持(见矩阵),本期未接。
148
+ - **不换包仅改元数据**:✅ 应用宝(仅基础信息)、OPPO(`app/updm`)、华为(结构上支持,待实测);❌ vivo / 魅族(发布接口必带新包);❓ 小米 / 三星 / 荣耀。本期统一走「随包更新」。
149
+
150
+ ---
151
+
152
+ ## 八、落地情况(shipup 0.2.0)与后续
153
+
154
+ **已落地**:
155
+ 1. ✅ **扩展 `upload`**:新增 `--screenshot`(多张)/`--app-name`/`--summary`/`--description`,随包按需更新,8 渠道分别调各自接口。
156
+ 2. ✅ **应用宝补全**:从「仅 apk+feature」扩到图标 / 截图 / 应用名 / 简介。
157
+ 3. ✅ **图标按渠道压缩**:尺寸 + 大小上限内置(`src/adapters/android/specs.ts`),超限自动降尺寸。
158
+ 4. ✅ **规格校验**:文案长度 `warnIfTooLong` 告警(注意三星按字节)。
159
+
160
+ **后续可补**:
161
+ - 分类 / 年龄分级 / 关键词 / 视频更新(部分渠道 API 支持)。
162
+ - 「不换包仅改元数据」(应用宝 / OPPO `app/updm` / 华为)。
163
+ - emoji 过滤开关 + 应用名「禁特殊符号」硬校验(当前仅告警)。
164
+ - **真实凭证沙箱联调**:现有实现按官方文档「最佳努力」,未经真机回包验证(整个 shipup 含 iOS 都未做)。
165
+
166
+ ---
167
+
168
+ ## 附:调研来源与可信度
169
+
170
+ **字段名/接口结构**:8 家均已**官方文档直读核实**(浏览器渲染):
171
+
172
+ | 渠道 | 官方来源(浏览器直读) |
173
+ |---|---|
174
+ | 应用宝 | API 文档 `wikinew.open.qq.com/iwiki/4015262492` |
175
+ | 华为 | Publishing API `app-file-info` / `app-language-info` |
176
+ | 三星 | Content Publish API reference(screenshots 结构) |
177
+ | 小米 | dev.mi.com 自动发布接口 `pId=1134`(appInfo 字段) |
178
+ | vivo | dev.vivo.com.cn `doc/343`(app.sync.update.app 字段) |
179
+ | 荣耀 | developer.honor.com `doc/guides/101359`(PubLanguageInfo + 文件类型表) |
180
+ | 魅族 | open.flyme.cn `docs?id=333`(publish 字段 + screenShots 类型) |
181
+ | OPPO | open.oppomobile.com 官方能力说明与公开端点 |
182
+
183
+ **素材精确数值**:标 `[官]` 已核实;标 `[三]`/`[转]`/`[?]` 仍为第三方/转述,以渠道控制台为准。
184
+
185
+ ### 仍待实测 / 未定的项
186
+ - **真实凭证沙箱联调**(最关键):所有渠道的请求/签名/字段均未经真机回包验证。
187
+ - OPPO `pic_url` 多图序列化格式(逗号 vs JSON 数组)、视频时长/尺寸。
188
+ - vivo 图标大小上限(审核规范 50KB vs 控制台或更宽)。
189
+ - 应用宝 / 小米 改 `app_name` 是否触发改名审核流程;emoji 明确政策。
190
+ - 「不换包仅改元数据」各渠道是否真能提审(应用宝 / OPPO / 华为)。
@@ -0,0 +1,72 @@
1
+ # Credentials
2
+
3
+ The default credential file is `~/.config/shipup/credentials.yaml`. Override it
4
+ with `SHIPUP_CREDS` or `--creds <path>`. A single file may contain `android`,
5
+ `harmony`, `huawei`, and `ios` sections; a provider-only file without the outer
6
+ section is also accepted by Android and iOS commands.
7
+
8
+ Values can be literal strings, `${ENVIRONMENT_VARIABLE}` references, or
9
+ `@relative/file` references resolved from the credential file directory.
10
+
11
+ ## Android multi-market
12
+
13
+ ```yaml
14
+ android:
15
+ package_name: com.example.app
16
+ channels:
17
+ huawei: { app_id: "...", client_id: ${HW_ID}, client_secret: ${HW_SECRET} }
18
+ honor: { app_id: "...", client_id: ${HONOR_ID}, client_secret: ${HONOR_SECRET} }
19
+ oppo: { app_id: "...", client_id: ${OPPO_ID}, client_secret: ${OPPO_SECRET} }
20
+ vivo: { app_id: "...", access_key: ${VIVO_AK}, access_secret: ${VIVO_SK} }
21
+ xiaomi: { user_name: ${MI_USER}, password: ${MI_PASSWORD}, rsa_modulus: ${MI_RSA} }
22
+ samsung: { app_id: "...", service_account: ${SS_ACCOUNT}, private_key: "@./samsung.pem" }
23
+ qq: { app_id: "...", user_id: "...", access_secret: ${QQ_SECRET} }
24
+ meizu: { access_key: ${MZ_AK}, access_secret: ${MZ_SK} }
25
+ ```
26
+
27
+ Only channels selected by `--upload` or `--channel` are validated.
28
+
29
+ ## HarmonyOS and Huawei compatibility commands
30
+
31
+ ```yaml
32
+ harmony:
33
+ app_id: "..."
34
+ package_name: com.example.app
35
+ service_account: "@./agc-service-account.json"
36
+
37
+ huawei:
38
+ app_id: "..."
39
+ package_name: com.example.app
40
+ service_account: "@./agc-service-account.json"
41
+ ```
42
+
43
+ These commands accept an AppGallery Connect service account or API client
44
+ credentials. The `shipup android ... huawei` adapter instead uses the Android
45
+ market credentials under `android.channels.huawei`.
46
+
47
+ ## App Store Connect
48
+
49
+ ```yaml
50
+ ios:
51
+ app_id: "..."
52
+ bundle_id: com.example.app
53
+ issuer_id: ${ASC_ISSUER_ID}
54
+ key_id: ${ASC_KEY_ID}
55
+ private_key: "@./AuthKey_XXXXXXXXXX.p8"
56
+ team_id: ${APPLE_TEAM_ID}
57
+ ```
58
+
59
+ `app_id` is optional when commands receive `--bundle-id`. `team_id` is used by
60
+ upload workflows that require it.
61
+
62
+ ## Storage rules
63
+
64
+ Keep credentials and referenced key files outside application repositories:
65
+
66
+ ```bash
67
+ chmod 600 ~/.config/shipup/credentials.yaml
68
+ chmod 600 ~/.config/shipup/*.pem ~/.config/shipup/*.p8
69
+ ```
70
+
71
+ `shipup` warns about group/world-readable credential files on Unix systems.
72
+ Use the minimum provider role required for the selected command.
@@ -0,0 +1,60 @@
1
+ # Provider behavior
2
+
3
+ ## HarmonyOS AppGallery
4
+
5
+ `harmony upload` validates `pack.info` when present, obtains an OBS upload URL,
6
+ streams the `.app`, registers it, and normally waits for compilation analysis.
7
+ `harmony submit` supports immediate, scheduled, and phased submission.
8
+ `harmony status` normalizes AppGallery release states.
9
+
10
+ ## Android multi-market
11
+
12
+ `android upload` accepts one or more `--upload channel=apk` values and isolates
13
+ failures per channel. The supported channel names are:
14
+
15
+ | Channel | Store | Upload | Status | Optional metadata |
16
+ |---|---|---:|---:|---|
17
+ | `huawei` | Huawei AppGallery | yes | yes | icon, screenshots, name, summary, description, release notes |
18
+ | `honor` | Honor App Market | yes | yes | icon, screenshots, name, summary, description, release notes |
19
+ | `oppo` | OPPO App Market | yes | yes | icon, screenshots, name, summary, description, release notes |
20
+ | `vivo` | vivo App Store | yes | yes | icon, screenshots, name, summary, description, release notes |
21
+ | `xiaomi` | Xiaomi App Store | yes | yes | icon, screenshots, name, summary, description, release notes |
22
+ | `samsung` | Samsung Galaxy Store | yes | yes | icon, screenshots, name, summary, description, release notes |
23
+ | `qq` | Tencent MyApp | yes | yes | icon, screenshots, name, summary, description, release notes |
24
+ | `meizu` | Meizu App Store | yes | yes | icon, screenshots, name, summary, description, release notes |
25
+
26
+ Most Android market upload APIs submit or publish as part of upload; they do
27
+ not provide a universal upload-only draft operation. `--submit-review` is an
28
+ explicit intent marker, and the CLI prints a warning when it is omitted.
29
+
30
+ Package name and version metadata are read from the APK unless supplied with
31
+ `--version-name`, `--version-code`, and credentials. Native ABI contents are
32
+ inspected for channels that distinguish 32-bit and 64-bit packages.
33
+
34
+ Icons use `--icon` when supplied. Otherwise, channels that require a new icon
35
+ use `aapt`/`aapt2` to select the highest-density raster launcher icon from the
36
+ APK. Images are resized and compressed to provider limits. Screenshots and text
37
+ metadata are updated only when their corresponding options are present.
38
+ Detailed constraints are listed in [Android market capabilities](android-markets.md).
39
+
40
+ ## Huawei compatibility command
41
+
42
+ `huawei upload|status` preserves the original service-account-oriented Huawei
43
+ workflow. New multi-market automation should normally use
44
+ `shipup android ... --upload huawei=...` so it can share orchestration and
45
+ metadata options with other Android markets.
46
+
47
+ ## App Store Connect
48
+
49
+ `ios upload` uploads through `xcrun altool` and can continue into submission.
50
+ `ios submit` waits for a valid existing build, creates or reuses the App Store
51
+ version, writes localizations, attaches the build, and submits the review.
52
+ `--bundle-id` can resolve the numeric App ID automatically.
53
+
54
+ `ios status` normalizes App Store state and includes phased-release state when
55
+ available. `ios release` supports manual release, starting phased release,
56
+ completing it immediately, pausing, and resuming. Repeated phased-release
57
+ commands are guarded for idempotency.
58
+
59
+ iOS upload requires macOS and Xcode. HTTP-only status, submission, and release
60
+ commands run wherever the supported Node.js runtime is available.
package/lib/common.mjs ADDED
@@ -0,0 +1,265 @@
1
+ // shipup 共享基础设施:错误/退出码、凭证加载(YAML 子集 + 三形态值)、
2
+ // HTTP(GET 幂等重试)、AGC 鉴权(Service Account PS256 JWT / API 客户端 token)、zip 读取。
3
+ import { createSign, constants as cryptoConstants } from "node:crypto";
4
+ import { readFileSync, existsSync, statSync } from "node:fs";
5
+ import { dirname, resolve } from "node:path";
6
+ import { inflateRawSync } from "node:zlib";
7
+
8
+ export const ExitCode = { OK: 0, FAIL: 2, USAGE: 3, CREDS: 4, MISSING_INPUT: 5, TIMEOUT: 124 };
9
+
10
+ export class CliError extends Error {
11
+ constructor(exitCode, message, errorCode = "") {
12
+ super(message);
13
+ this.exitCode = exitCode;
14
+ this.errorCode = errorCode;
15
+ }
16
+ }
17
+
18
+ export const log = (...a) => console.error(...a);
19
+ export const b64url = (buf) => Buffer.from(buf).toString("base64url");
20
+
21
+ /** Remove credentials and long opaque values before provider output reaches logs. */
22
+ export function redactText(value) {
23
+ return String(value ?? "")
24
+ .replace(/-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g, "<redacted-private-key>")
25
+ .replace(/(authorization\s*[:=]\s*)(?:bearer\s+)?[^\s,}\]]+/gi, "$1<redacted>")
26
+ .replace(/("?(?:access_token|client_secret|private_key|authCode|token)"?\s*[:=]\s*")([^"]+)(")/gi, "$1<redacted>$3")
27
+ .replace(/\b[A-Za-z0-9_-]{80,}\b/g, "<redacted-opaque-value>");
28
+ }
29
+
30
+ /** Provider URLs may contain app identifiers, signed query strings, or upload tokens. */
31
+ export function redactUrl(value) {
32
+ try {
33
+ const url = new URL(value);
34
+ return `${url.origin}${url.pathname}`;
35
+ } catch {
36
+ return "<redacted-url>";
37
+ }
38
+ }
39
+
40
+ // ---------- 凭证 ----------
41
+
42
+ // 值三形态:@文件 → 读文件内容;${ENV} → 环境变量;否则字面量
43
+ export function resolveValue(raw, baseDir) {
44
+ if (raw == null) return raw;
45
+ if (raw.startsWith("@")) {
46
+ const p = resolve(baseDir, raw.slice(1));
47
+ if (!existsSync(p)) throw new CliError(ExitCode.CREDS, `@文件不存在: ${p}`);
48
+ return readFileSync(p, "utf8").trim();
49
+ }
50
+ const m = raw.match(/^\$\{(\w+)\}$/);
51
+ if (m) {
52
+ const v = process.env[m[1]];
53
+ if (!v) throw new CliError(ExitCode.CREDS, `环境变量未设置: ${m[1]}`);
54
+ return v;
55
+ }
56
+ return raw;
57
+ }
58
+
59
+ // 极简 YAML 子集:两级嵌套(平台段 → key: value)+ 注释。够用即可,不引依赖。
60
+ function parseYamlSections(text) {
61
+ const sections = {};
62
+ let current = null;
63
+ for (const line of text.split("\n")) {
64
+ const noComment = line.replace(/(^|\s)#.*$/, "");
65
+ if (!noComment.trim()) continue;
66
+ const indented = /^\s/.test(noComment);
67
+ const m = noComment.trim().match(/^([\w.-]+)\s*:\s*(.*)$/);
68
+ if (!m) continue;
69
+ let value = m[2].trim();
70
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
71
+ value = value.slice(1, -1);
72
+ }
73
+ if (!indented && !value) {
74
+ current = m[1];
75
+ sections[current] = {};
76
+ } else if (indented && current) {
77
+ if (value) sections[current][m[1]] = value;
78
+ } else if (!indented && value) {
79
+ // 顶层散键:归入 _flat(单平台旧格式兼容)
80
+ (sections._flat ??= {})[m[1]] = value;
81
+ }
82
+ }
83
+ return sections;
84
+ }
85
+
86
+ /** 读 creds YAML 并取指定平台段,值全部按三形态解析。 */
87
+ export function loadPlatformCreds(credsPath, platform) {
88
+ if (!existsSync(credsPath)) throw new CliError(ExitCode.CREDS, `凭证文件不存在: ${credsPath}`);
89
+ if (process.platform !== "win32") {
90
+ const mode = statSync(credsPath).mode & 0o777;
91
+ if ((mode & 0o077) !== 0) log(`警告:凭证文件权限为 ${mode.toString(8)},建议 chmod 600 ${credsPath}`);
92
+ }
93
+ const baseDir = dirname(resolve(credsPath));
94
+ const sections = parseYamlSections(readFileSync(credsPath, "utf8"));
95
+ const section = sections[platform] ?? sections._flat;
96
+ if (!section || Object.keys(section).length === 0) {
97
+ throw new CliError(ExitCode.CREDS, `凭证文件缺少 ${platform}: 段(见 creds.example.yaml)`);
98
+ }
99
+ const creds = { _baseDir: baseDir };
100
+ for (const [k, v] of Object.entries(section)) creds[k] = resolveValue(v, baseDir);
101
+ return creds;
102
+ }
103
+
104
+ /** 解析 AGC Service Account 凭据(支持 JSON 内容或文件路径),校验必要字段。 */
105
+ export function loadServiceAccount(creds) {
106
+ let jsonText = creds.service_account;
107
+ if (!jsonText.trimStart().startsWith("{")) {
108
+ const p = resolve(creds._baseDir, jsonText);
109
+ if (!existsSync(p)) throw new CliError(ExitCode.CREDS, `service_account 文件不存在: ${p}`);
110
+ jsonText = readFileSync(p, "utf8");
111
+ }
112
+ let sa;
113
+ try {
114
+ sa = JSON.parse(jsonText);
115
+ } catch {
116
+ throw new CliError(ExitCode.CREDS, "service_account 不是合法 JSON(应为 AGC 下载的 *private.json 凭据文件)");
117
+ }
118
+ for (const field of ["key_id", "private_key", "sub_account"]) {
119
+ if (!sa[field]) throw new CliError(ExitCode.CREDS, `service_account JSON 缺少 ${field}`);
120
+ }
121
+ return sa;
122
+ }
123
+
124
+ // ---------- HTTP ----------
125
+
126
+ export function remainingMs(deadline) {
127
+ const left = deadline - Date.now();
128
+ if (left <= 0) throw new CliError(ExitCode.TIMEOUT, "总超时(--timeout)已到");
129
+ return left;
130
+ }
131
+
132
+ export function sleep(ms, deadline) {
133
+ return new Promise((r) => setTimeout(r, Math.min(ms, remainingMs(deadline))));
134
+ }
135
+
136
+ export async function fetchJson(url, init, deadline, { retries = 0 } = {}) {
137
+ for (let attempt = 0; ; attempt++) {
138
+ const controller = new AbortController();
139
+ const timer = setTimeout(() => controller.abort(), Math.min(remainingMs(deadline), 60_000));
140
+ try {
141
+ const res = await fetch(url, { ...init, signal: controller.signal });
142
+ const text = await res.text();
143
+ if (!res.ok && (res.status >= 500 || res.status === 429) && attempt < retries) {
144
+ await sleep(1000 * (attempt + 1), deadline);
145
+ continue;
146
+ }
147
+ if (!res.ok) {
148
+ throw new CliError(
149
+ ExitCode.FAIL,
150
+ `HTTP ${res.status} ${redactUrl(url)}\n${redactText(text).slice(0, 500)}`,
151
+ String(res.status),
152
+ );
153
+ }
154
+ try {
155
+ return text ? JSON.parse(text) : {};
156
+ } catch {
157
+ throw new CliError(ExitCode.FAIL, `非 JSON 响应 ${redactUrl(url)}: ${redactText(text).slice(0, 300)}`);
158
+ }
159
+ } catch (e) {
160
+ if (e instanceof CliError) throw e;
161
+ if (attempt < retries) {
162
+ await sleep(1000 * (attempt + 1), deadline);
163
+ continue;
164
+ }
165
+ throw new CliError(
166
+ e.name === "AbortError" ? ExitCode.TIMEOUT : ExitCode.FAIL,
167
+ `请求失败 ${redactUrl(url)}: ${redactText(e.message)}`,
168
+ );
169
+ } finally {
170
+ clearTimeout(timer);
171
+ }
172
+ }
173
+ }
174
+
175
+ // ---------- AGC 鉴权(harmony + huawei 共用)----------
176
+
177
+ export const AGC_API_BASE = "https://connect-api.cloud.huawei.com/api";
178
+ const SA_TOKEN_AUD = "https://oauth-login.cloud.huawei.com/oauth2/v3/token";
179
+
180
+ // Service Account:PS256 自签 JWT,直接作为 Bearer(无需 token 交换)
181
+ export function serviceAccountAuth(sa) {
182
+ const iat = Math.floor(Date.now() / 1000);
183
+ const header = b64url(JSON.stringify({ kid: sa.key_id, typ: "JWT", alg: "PS256" }));
184
+ const payload = b64url(JSON.stringify({ aud: SA_TOKEN_AUD, iss: sa.sub_account, exp: iat + 3600, iat }));
185
+ const signer = createSign("RSA-SHA256");
186
+ signer.update(`${header}.${payload}`);
187
+ const signature = signer.sign({
188
+ key: sa.private_key,
189
+ padding: cryptoConstants.RSA_PKCS1_PSS_PADDING,
190
+ saltLength: cryptoConstants.RSA_PSS_SALTLEN_DIGEST,
191
+ });
192
+ return { Authorization: `Bearer ${header}.${payload}.${b64url(signature)}` };
193
+ }
194
+
195
+ /** 返回 AGC 请求鉴权头:优先 Service Account,回落 API 客户端(48h token + client_id 头)。 */
196
+ export async function agcAuth(creds, deadline) {
197
+ if (creds.service_account) return serviceAccountAuth(loadServiceAccount(creds));
198
+ if (creds.client_id && creds.client_secret) {
199
+ const res = await fetchJson(`${AGC_API_BASE}/oauth2/v1/token`, {
200
+ method: "POST",
201
+ headers: { "Content-Type": "application/json" },
202
+ body: JSON.stringify({ grant_type: "client_credentials", client_id: creds.client_id, client_secret: creds.client_secret }),
203
+ }, deadline);
204
+ if (!res.access_token) {
205
+ throw new CliError(
206
+ ExitCode.CREDS,
207
+ `获取 token 失败: ${redactText(JSON.stringify(res.ret ?? { error: "missing access_token" }))}`,
208
+ String(res.ret?.code ?? ""),
209
+ );
210
+ }
211
+ return { Authorization: `Bearer ${res.access_token}`, client_id: creds.client_id };
212
+ }
213
+ throw new CliError(ExitCode.CREDS, "凭证需要 service_account(推荐)或 client_id + client_secret 二选一");
214
+ }
215
+
216
+ /** AGC 业务接口:拼 query、校验 ret.code===0;GET 幂等自动重试,写操作不重试。 */
217
+ export async function agc(method, path, { auth, query, body }, deadline) {
218
+ const qs = query ? "?" + new URLSearchParams(query).toString() : "";
219
+ const res = await fetchJson(`${AGC_API_BASE}${path}${qs}`, {
220
+ method,
221
+ headers: { ...auth, "Content-Type": "application/json" },
222
+ body: body ? JSON.stringify(body) : undefined,
223
+ }, deadline, { retries: method === "GET" ? 2 : 0 });
224
+ if (res.ret && Number(res.ret.code) !== 0) {
225
+ throw new CliError(ExitCode.FAIL, `AGC 接口错误 ${path}: [${res.ret.code}] ${res.ret.msg}`, String(res.ret.code));
226
+ }
227
+ return res;
228
+ }
229
+
230
+ // ---------- zip 读取(.app 的 pack.info 等)----------
231
+
232
+ /** 从 zip 字节中读取指定 entry 的内容(Buffer),找不到/不支持返回 null。 */
233
+ export function readZipEntry(buf, entryName) {
234
+ try {
235
+ let eocd = -1;
236
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 65536); i--) {
237
+ if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
238
+ }
239
+ if (eocd < 0) return null;
240
+ const total = buf.readUInt16LE(eocd + 10);
241
+ let off = buf.readUInt32LE(eocd + 16);
242
+ if (off === 0xffffffff) return null; // zip64 不支持,fail-soft
243
+ for (let n = 0; n < total; n++) {
244
+ if (buf.readUInt32LE(off) !== 0x02014b50) return null;
245
+ const method = buf.readUInt16LE(off + 10);
246
+ const compSize = buf.readUInt32LE(off + 20);
247
+ const nameLen = buf.readUInt16LE(off + 28);
248
+ const extraLen = buf.readUInt16LE(off + 30);
249
+ const commentLen = buf.readUInt16LE(off + 32);
250
+ const localOff = buf.readUInt32LE(off + 42);
251
+ const name = buf.toString("utf8", off + 46, off + 46 + nameLen);
252
+ if (name === entryName) {
253
+ const lNameLen = buf.readUInt16LE(localOff + 26);
254
+ const lExtraLen = buf.readUInt16LE(localOff + 28);
255
+ const dataStart = localOff + 30 + lNameLen + lExtraLen;
256
+ const raw = buf.subarray(dataStart, dataStart + compSize);
257
+ return method === 8 ? inflateRawSync(raw) : Buffer.from(raw);
258
+ }
259
+ off += 46 + nameLen + extraLen + commentLen;
260
+ }
261
+ return null;
262
+ } catch {
263
+ return null;
264
+ }
265
+ }