publish-apk 0.0.1-alpha.10
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/LICENSE +21 -0
- package/README.md +795 -0
- package/package.json +40 -0
- package/src/apk.js +694 -0
- package/src/cert.js +1468 -0
- package/src/cli.js +185 -0
- package/src/google.js +1884 -0
- package/src/hap.js +939 -0
- package/src/harmony-material.js +174 -0
- package/src/help.js +50 -0
- package/src/huawei-signing.js +1279 -0
- package/src/huawei.js +3295 -0
- package/src/jwt.js +161 -0
- package/src/profile.js +753 -0
- package/src/publish-apk.js +161 -0
package/src/google.js
ADDED
|
@@ -0,0 +1,1884 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Play 命令(`publish-apk google ...`)。
|
|
3
|
+
*
|
|
4
|
+
* 厂商隔离:所有 Google Play 相关的东西都在本模块——
|
|
5
|
+
* - 命令与帮助文档(google / google access-token / google upload-aab)
|
|
6
|
+
* - 服务账号鉴权:用完整 JSON 或 client_email/private_key 分项字段本地签发 RS256 JWT,
|
|
7
|
+
* 再换取 OAuth 2.0 access_token
|
|
8
|
+
* - Android Publisher API v3 的 edits / bundles / tracks 调用
|
|
9
|
+
*
|
|
10
|
+
* 与华为最大的不同是**修改模型**:Android Publisher 的每次发布都发生在一次 edit 里
|
|
11
|
+
* (创建 edit → 上传 bundle → 更新轨道 → commit),只有 commit 之后修改才真正生效。
|
|
12
|
+
* 中途失败时本模块会尽力 delete 这个 edit,避免留下半成品修改。
|
|
13
|
+
*
|
|
14
|
+
* 已支持的行为:
|
|
15
|
+
* 1. node src/publish-apk.js google
|
|
16
|
+
* 输出 google 帮助文档
|
|
17
|
+
* 2. node src/publish-apk.js google access-token [选项]
|
|
18
|
+
* 用服务账号 JSON 换取 access_token,写 stdout,退出代码 0
|
|
19
|
+
* 3. node src/publish-apk.js google upload-aab [选项]
|
|
20
|
+
* 创建 edit → 上传 .aab → (给了 --track 时)更新轨道 → commit,
|
|
21
|
+
* 成功时把 bundle 的 versionCode 写 stdout,退出代码 0
|
|
22
|
+
*
|
|
23
|
+
* 环境变量:每个带值的选项都有同名环境变量兜底(选项优先),见各命令帮助文档;
|
|
24
|
+
* 开关型选项 --verbose 也有环境变量 GOOGLE_VERBOSE(1/true/yes/on 开启、0/false/no/off 关闭)。
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
EXIT_FAILURE,
|
|
30
|
+
EXIT_OK,
|
|
31
|
+
PROGRAM,
|
|
32
|
+
UsageError,
|
|
33
|
+
isHelpToken,
|
|
34
|
+
parseOptions,
|
|
35
|
+
readEnv,
|
|
36
|
+
stderr,
|
|
37
|
+
stdout,
|
|
38
|
+
} from './cli.js'
|
|
39
|
+
import { renderHelp } from './help.js'
|
|
40
|
+
import {
|
|
41
|
+
currentUnixSeconds,
|
|
42
|
+
maskValue,
|
|
43
|
+
normalizePemText,
|
|
44
|
+
signJwt,
|
|
45
|
+
} from './jwt.js'
|
|
46
|
+
|
|
47
|
+
// ------------------------------------------------------------- Google 固定值
|
|
48
|
+
|
|
49
|
+
/** Android Publisher API 域名;可用 GOOGLE_API_BASE 覆盖(本地联调或代理) */
|
|
50
|
+
export const API_BASE = 'https://androidpublisher.googleapis.com'
|
|
51
|
+
/** 业务接口路径前缀 */
|
|
52
|
+
const API_PATH_PREFIX = '/androidpublisher/v3'
|
|
53
|
+
/** 上传接口路径前缀(Google 的媒体上传走 /upload 前缀) */
|
|
54
|
+
const UPLOAD_PATH_PREFIX = '/upload/androidpublisher/v3'
|
|
55
|
+
/** Android Publisher 需要的 OAuth 2.0 scope */
|
|
56
|
+
export const ANDROID_PUBLISHER_SCOPE = 'https://www.googleapis.com/auth/androidpublisher'
|
|
57
|
+
/** 服务账号 JSON 里 token_uri 缺失时的默认值 */
|
|
58
|
+
export const DEFAULT_TOKEN_URI = 'https://oauth2.googleapis.com/token'
|
|
59
|
+
/** 服务账号 JWT 换取 access_token 的 grant_type */
|
|
60
|
+
const JWT_BEARER_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
|
|
61
|
+
/** 服务账号 JWT 的有效期(秒):Google 允许最长 1 小时 */
|
|
62
|
+
const JWT_LIFETIME_SECONDS = 3600
|
|
63
|
+
|
|
64
|
+
/** 换取 access_token 的超时(Google 令牌接口通常很快) */
|
|
65
|
+
const TOKEN_TIMEOUT_MS = 60_000
|
|
66
|
+
/** 普通业务接口的超时 */
|
|
67
|
+
const REQUEST_TIMEOUT_MS = 60_000
|
|
68
|
+
/** 上传 .aab 的超时:官方建议至少 2 分钟,这里放宽到 10 分钟 */
|
|
69
|
+
const UPLOAD_TIMEOUT_MS = 10 * 60_000
|
|
70
|
+
|
|
71
|
+
/** 应用包名的合法格式:至少两段,每段以字母开头,只含字母、数字、下划线 */
|
|
72
|
+
const PACKAGE_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/
|
|
73
|
+
|
|
74
|
+
/** 轨道发布状态(Google Release.status 的全部取值) */
|
|
75
|
+
const RELEASE_STATUSES = ['draft', 'inProgress', 'halted', 'completed']
|
|
76
|
+
const RELEASE_STATUS_SET = new Set(RELEASE_STATUSES)
|
|
77
|
+
|
|
78
|
+
// --------------------------------------------------------- 选项名与环境变量名
|
|
79
|
+
|
|
80
|
+
/** 应用包名(如 com.example.app) */
|
|
81
|
+
const OPTION_PACKAGE_NAME = '--package-name'
|
|
82
|
+
/** 本地 .aab 文件路径 */
|
|
83
|
+
const OPTION_FILE = '--file'
|
|
84
|
+
/** 服务账号 JSON 密钥文件路径 */
|
|
85
|
+
const OPTION_SERVICE_ACCOUNT = '--service-account'
|
|
86
|
+
/** 服务账号 JSON 数据(与文件二选一) */
|
|
87
|
+
const OPTION_SERVICE_ACCOUNT_DATA = '--service-account-data'
|
|
88
|
+
/** 服务账号 JSON 的 client_email(分项参数,与 --service-account / --service-account-data 互斥) */
|
|
89
|
+
const OPTION_CLIENT_EMAIL = '--client-email'
|
|
90
|
+
/** 服务账号私钥 PEM 文件路径(分项参数,与 --private-key-data 二选一) */
|
|
91
|
+
const OPTION_PRIVATE_KEY = '--private-key'
|
|
92
|
+
/** 服务账号私钥 PEM 数据(分项参数,与 --private-key 二选一) */
|
|
93
|
+
const OPTION_PRIVATE_KEY_DATA = '--private-key-data'
|
|
94
|
+
/** 令牌接口地址(覆盖 JSON 的 token_uri / 默认地址) */
|
|
95
|
+
const OPTION_TOKEN_URI = '--token-uri'
|
|
96
|
+
/** 已换取的 access_token */
|
|
97
|
+
const OPTION_TOKEN = '--token'
|
|
98
|
+
/** 发布到哪个轨道(不给则只上传 bundle) */
|
|
99
|
+
const OPTION_TRACK = '--track'
|
|
100
|
+
/** 轨道发布状态 */
|
|
101
|
+
const OPTION_RELEASE_STATUS = '--release-status'
|
|
102
|
+
/** 轨道发布名称 */
|
|
103
|
+
const OPTION_RELEASE_NAME = '--release-name'
|
|
104
|
+
/** 轨道发布说明 */
|
|
105
|
+
const OPTION_RELEASE_NOTES = '--release-notes'
|
|
106
|
+
/** 发布说明语言(BCP-47,如 zh-CN、en-US) */
|
|
107
|
+
const OPTION_RELEASE_NOTES_LANGUAGE = '--release-notes-language'
|
|
108
|
+
/** 分阶段发布比例(0 与 1 之间,不含两端) */
|
|
109
|
+
const OPTION_USER_FRACTION = '--user-fraction'
|
|
110
|
+
/** 开关:提交时 changesNotSentForReview=true(托管发布 / 变更待人工送审) */
|
|
111
|
+
const OPTION_CHANGES_NOT_SENT_FOR_REVIEW = '--changes-not-sent-for-review'
|
|
112
|
+
/** 开关:输出运行日志 */
|
|
113
|
+
const OPTION_VERBOSE = '--verbose'
|
|
114
|
+
|
|
115
|
+
/** 应用包名,等价于 --package-name */
|
|
116
|
+
const ENV_PACKAGE_NAME = 'GOOGLE_PACKAGE_NAME'
|
|
117
|
+
/** 本地 .aab 文件路径,等价于 --file */
|
|
118
|
+
const ENV_FILE = 'GOOGLE_FILE'
|
|
119
|
+
/** 服务账号 JSON 文件路径,等价于 --service-account */
|
|
120
|
+
const ENV_SERVICE_ACCOUNT = 'GOOGLE_SERVICE_ACCOUNT'
|
|
121
|
+
/** 服务账号 JSON 数据,等价于 --service-account-data */
|
|
122
|
+
const ENV_SERVICE_ACCOUNT_DATA = 'GOOGLE_SERVICE_ACCOUNT_DATA'
|
|
123
|
+
/** 服务账号 JSON 的 client_email,等价于 --client-email */
|
|
124
|
+
const ENV_CLIENT_EMAIL = 'GOOGLE_CLIENT_EMAIL'
|
|
125
|
+
/** 服务账号私钥 PEM 文件路径,等价于 --private-key */
|
|
126
|
+
const ENV_PRIVATE_KEY = 'GOOGLE_PRIVATE_KEY'
|
|
127
|
+
/** 服务账号私钥 PEM 数据,等价于 --private-key-data */
|
|
128
|
+
const ENV_PRIVATE_KEY_DATA = 'GOOGLE_PRIVATE_KEY_DATA'
|
|
129
|
+
/** access_token,等价于 --token */
|
|
130
|
+
const ENV_TOKEN = 'GOOGLE_TOKEN'
|
|
131
|
+
/** 轨道名,等价于 --track */
|
|
132
|
+
const ENV_TRACK = 'GOOGLE_TRACK'
|
|
133
|
+
/** 轨道发布状态,等价于 --release-status */
|
|
134
|
+
const ENV_RELEASE_STATUS = 'GOOGLE_RELEASE_STATUS'
|
|
135
|
+
/** 轨道发布名称,等价于 --release-name */
|
|
136
|
+
const ENV_RELEASE_NAME = 'GOOGLE_RELEASE_NAME'
|
|
137
|
+
/** 轨道发布说明,等价于 --release-notes */
|
|
138
|
+
const ENV_RELEASE_NOTES = 'GOOGLE_RELEASE_NOTES'
|
|
139
|
+
/** 发布说明语言,等价于 --release-notes-language */
|
|
140
|
+
const ENV_RELEASE_NOTES_LANGUAGE = 'GOOGLE_RELEASE_NOTES_LANGUAGE'
|
|
141
|
+
/** 分阶段发布比例,等价于 --user-fraction */
|
|
142
|
+
const ENV_USER_FRACTION = 'GOOGLE_USER_FRACTION'
|
|
143
|
+
/** 开关:提交时 changesNotSentForReview=true,等价于 --changes-not-sent-for-review */
|
|
144
|
+
const ENV_CHANGES_NOT_SENT_FOR_REVIEW = 'GOOGLE_CHANGES_NOT_SENT_FOR_REVIEW'
|
|
145
|
+
/** 开关:输出运行日志,等价于 --verbose */
|
|
146
|
+
const ENV_VERBOSE = 'GOOGLE_VERBOSE'
|
|
147
|
+
/** 令牌接口地址覆盖项,等价于 --token-uri(默认取服务账号 JSON 的 token_uri) */
|
|
148
|
+
const ENV_TOKEN_URI = 'GOOGLE_TOKEN_URI'
|
|
149
|
+
/** 接口域名覆盖项(默认 https://androidpublisher.googleapis.com) */
|
|
150
|
+
const ENV_API_BASE = 'GOOGLE_API_BASE'
|
|
151
|
+
|
|
152
|
+
/** google access-token 支持的全部选项 */
|
|
153
|
+
const ACCESS_TOKEN_OPTIONS = new Set([
|
|
154
|
+
OPTION_SERVICE_ACCOUNT,
|
|
155
|
+
OPTION_SERVICE_ACCOUNT_DATA,
|
|
156
|
+
OPTION_CLIENT_EMAIL,
|
|
157
|
+
OPTION_PRIVATE_KEY,
|
|
158
|
+
OPTION_PRIVATE_KEY_DATA,
|
|
159
|
+
OPTION_TOKEN_URI,
|
|
160
|
+
OPTION_VERBOSE,
|
|
161
|
+
])
|
|
162
|
+
/** google access-token 中的开关型选项 */
|
|
163
|
+
const ACCESS_TOKEN_FLAGS = new Set([OPTION_VERBOSE])
|
|
164
|
+
|
|
165
|
+
/** google upload-aab 支持的全部选项 */
|
|
166
|
+
const UPLOAD_AAB_OPTIONS = new Set([
|
|
167
|
+
OPTION_PACKAGE_NAME,
|
|
168
|
+
OPTION_FILE,
|
|
169
|
+
OPTION_SERVICE_ACCOUNT,
|
|
170
|
+
OPTION_SERVICE_ACCOUNT_DATA,
|
|
171
|
+
OPTION_CLIENT_EMAIL,
|
|
172
|
+
OPTION_PRIVATE_KEY,
|
|
173
|
+
OPTION_PRIVATE_KEY_DATA,
|
|
174
|
+
OPTION_TOKEN_URI,
|
|
175
|
+
OPTION_TOKEN,
|
|
176
|
+
OPTION_TRACK,
|
|
177
|
+
OPTION_RELEASE_STATUS,
|
|
178
|
+
OPTION_RELEASE_NAME,
|
|
179
|
+
OPTION_RELEASE_NOTES,
|
|
180
|
+
OPTION_RELEASE_NOTES_LANGUAGE,
|
|
181
|
+
OPTION_USER_FRACTION,
|
|
182
|
+
OPTION_CHANGES_NOT_SENT_FOR_REVIEW,
|
|
183
|
+
OPTION_VERBOSE,
|
|
184
|
+
])
|
|
185
|
+
/** google upload-aab 中的开关型选项 */
|
|
186
|
+
const UPLOAD_AAB_FLAGS = new Set([
|
|
187
|
+
OPTION_CHANGES_NOT_SENT_FOR_REVIEW,
|
|
188
|
+
OPTION_VERBOSE,
|
|
189
|
+
])
|
|
190
|
+
|
|
191
|
+
// -------------------------------------------------------------- 开关型参数
|
|
192
|
+
|
|
193
|
+
/** 开关型环境变量里代表「开启」的取值(大小写、首尾空白不敏感) */
|
|
194
|
+
const SWITCH_TRUE = new Set(['1', 'true', 'yes', 'on'])
|
|
195
|
+
/** 开关型环境变量里代表「关闭」的取值;空串由 readEnv 归为「未设置」 */
|
|
196
|
+
const SWITCH_FALSE = new Set(['0', 'false', 'no', 'off'])
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 开关型选项的取值来源:命令行选项优先,其次同名环境变量。
|
|
200
|
+
*
|
|
201
|
+
* 环境变量的值只认白名单:真值开启、假值关闭、其它值报用法错误。
|
|
202
|
+
* 这样 `GOOGLE_VERBOSE=0` 会真的关闭日志,而无法识别的值不会悄悄吞掉排查信息。
|
|
203
|
+
*/
|
|
204
|
+
function resolveSwitch(options, env, option, envName, help) {
|
|
205
|
+
if (options.has(option)) {
|
|
206
|
+
return true
|
|
207
|
+
}
|
|
208
|
+
const raw = readEnv(env, envName)
|
|
209
|
+
if (raw === undefined) {
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
const value = raw.trim().toLowerCase()
|
|
213
|
+
if (SWITCH_TRUE.has(value)) {
|
|
214
|
+
return true
|
|
215
|
+
}
|
|
216
|
+
if (SWITCH_FALSE.has(value)) {
|
|
217
|
+
return false
|
|
218
|
+
}
|
|
219
|
+
throw new UsageError(
|
|
220
|
+
`环境变量 ${envName} 的取值无法识别: "${raw}"(可用 1/true/yes/on 或 0/false/no/off)`,
|
|
221
|
+
help(),
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** --verbose / GOOGLE_VERBOSE */
|
|
226
|
+
function resolveVerbose(options, env, help) {
|
|
227
|
+
return resolveSwitch(options, env, OPTION_VERBOSE, ENV_VERBOSE, help)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// -------------------------------------------------------------- 服务账号读取
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 服务账号来源在错误信息与日志里的展示文本。
|
|
234
|
+
*
|
|
235
|
+
* JSON 数据本身就是凭证:绝不打字面量、也不做「几千个星号」的掩码,只保留字符数;
|
|
236
|
+
* 文件路径同样属于敏感信息,用 maskValue 掩码(如 /Use*****.json)。
|
|
237
|
+
*/
|
|
238
|
+
function describeServiceAccountSource(value) {
|
|
239
|
+
if (/[{[\r\n]/.test(value) || value.length > 120) {
|
|
240
|
+
return `(已省略,共 ${value.length} 字符)`
|
|
241
|
+
}
|
|
242
|
+
return maskValue(value)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 分项参数:与服务账号 JSON 二选一(--token-uri 是独立覆盖项,不参与互斥判断) */
|
|
246
|
+
const SERVICE_ACCOUNT_FIELD_OPTIONS = [
|
|
247
|
+
OPTION_CLIENT_EMAIL,
|
|
248
|
+
OPTION_PRIVATE_KEY,
|
|
249
|
+
OPTION_PRIVATE_KEY_DATA,
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
/** 服务账号分项参数在错误信息与日志里的展示文本(私钥数据整体省略) */
|
|
253
|
+
function describePrivateKeySource(value) {
|
|
254
|
+
if (value.includes('-----BEGIN') || /[\r\n]/.test(value) || value.length > 120) {
|
|
255
|
+
return `(已省略,共 ${value.length} 字符)`
|
|
256
|
+
}
|
|
257
|
+
return maskValue(value)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** 命令行是否显式给出了服务账号 JSON 参数 */
|
|
261
|
+
function hasServiceAccountJsonOptions(options) {
|
|
262
|
+
return options.has(OPTION_SERVICE_ACCOUNT) || options.has(OPTION_SERVICE_ACCOUNT_DATA)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** 命令行是否显式给出了服务账号分项参数 */
|
|
266
|
+
function hasServiceAccountFieldOptions(options) {
|
|
267
|
+
return SERVICE_ACCOUNT_FIELD_OPTIONS.some((name) => options.has(name))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** 命令行是否显式给出了任一种服务账号凭据(JSON 或分项) */
|
|
271
|
+
function hasServiceAccountOptions(options) {
|
|
272
|
+
return hasServiceAccountJsonOptions(options) || hasServiceAccountFieldOptions(options)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** 环境变量里是否已有服务账号凭据(JSON 或分项,任一) */
|
|
276
|
+
function hasEnvServiceAccount(env) {
|
|
277
|
+
return (
|
|
278
|
+
readEnv(env, ENV_SERVICE_ACCOUNT) !== undefined ||
|
|
279
|
+
readEnv(env, ENV_SERVICE_ACCOUNT_DATA) !== undefined ||
|
|
280
|
+
readEnv(env, ENV_CLIENT_EMAIL) !== undefined ||
|
|
281
|
+
readEnv(env, ENV_PRIVATE_KEY) !== undefined ||
|
|
282
|
+
readEnv(env, ENV_PRIVATE_KEY_DATA) !== undefined
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 「需要哪些凭据」的统一提示文案(access-token 与 upload-aab 共用) */
|
|
287
|
+
function serviceAccountMissingHint() {
|
|
288
|
+
return (
|
|
289
|
+
`${OPTION_SERVICE_ACCOUNT}(JSON 文件)、${OPTION_SERVICE_ACCOUNT_DATA}(JSON 数据),` +
|
|
290
|
+
`或分项参数 ${OPTION_CLIENT_EMAIL} + ${OPTION_PRIVATE_KEY}/${OPTION_PRIVATE_KEY_DATA};` +
|
|
291
|
+
`可用环境变量 ${ENV_SERVICE_ACCOUNT} / ${ENV_SERVICE_ACCOUNT_DATA} / ${ENV_CLIENT_EMAIL} / ` +
|
|
292
|
+
`${ENV_PRIVATE_KEY} / ${ENV_PRIVATE_KEY_DATA}`
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* 收集命令行/环境变量里的分项凭据(client_email + 私钥)。
|
|
298
|
+
*
|
|
299
|
+
* 规则与 huawei 模块一致:
|
|
300
|
+
* - 命令行给了私钥来源(--private-key 或 --private-key-data)时,完全忽略环境变量里的私钥来源;
|
|
301
|
+
* 同时给出两者属于明确误用,报错。
|
|
302
|
+
* - 命令行没给私钥时用环境变量;环境变量层两者都在时「数据优先」并提示
|
|
303
|
+
* (变量改名后旧的 .env 可能只留下其中一个变量,硬报错会让命令直接不可用)。
|
|
304
|
+
* - client_email 单独按「选项优先、环境变量兜底」处理,便于与私钥来自不同来源。
|
|
305
|
+
*/
|
|
306
|
+
function collectServiceAccountFields(options, env) {
|
|
307
|
+
const clientEmail = options.get(OPTION_CLIENT_EMAIL) ?? readEnv(env, ENV_CLIENT_EMAIL)
|
|
308
|
+
const clientEmailOrigin = options.has(OPTION_CLIENT_EMAIL)
|
|
309
|
+
? OPTION_CLIENT_EMAIL
|
|
310
|
+
: `${ENV_CLIENT_EMAIL} 环境变量`
|
|
311
|
+
|
|
312
|
+
const pathOption = options.get(OPTION_PRIVATE_KEY)
|
|
313
|
+
const dataOption = options.get(OPTION_PRIVATE_KEY_DATA)
|
|
314
|
+
if (pathOption !== undefined && dataOption !== undefined) {
|
|
315
|
+
return {
|
|
316
|
+
clientEmail,
|
|
317
|
+
clientEmailOrigin,
|
|
318
|
+
conflict: `选项 "${OPTION_PRIVATE_KEY}" 与 "${OPTION_PRIVATE_KEY_DATA}" 不能同时使用`,
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (pathOption !== undefined || dataOption !== undefined) {
|
|
323
|
+
return {
|
|
324
|
+
clientEmail,
|
|
325
|
+
clientEmailOrigin,
|
|
326
|
+
privateKeyPath: pathOption,
|
|
327
|
+
privateKeyPathOrigin: OPTION_PRIVATE_KEY,
|
|
328
|
+
privateKeyData: dataOption,
|
|
329
|
+
privateKeyDataOrigin: OPTION_PRIVATE_KEY_DATA,
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const envPath = readEnv(env, ENV_PRIVATE_KEY)
|
|
334
|
+
const envData = readEnv(env, ENV_PRIVATE_KEY_DATA)
|
|
335
|
+
if (envPath !== undefined && envData !== undefined) {
|
|
336
|
+
stderr(
|
|
337
|
+
`提示: 环境变量 ${ENV_PRIVATE_KEY} 与 ${ENV_PRIVATE_KEY_DATA} 同时存在,本次使用 ` +
|
|
338
|
+
`${ENV_PRIVATE_KEY_DATA}(私钥数据);如需改用私钥文件请传 ${OPTION_PRIVATE_KEY},` +
|
|
339
|
+
`或执行 unset ${ENV_PRIVATE_KEY}`,
|
|
340
|
+
)
|
|
341
|
+
return {
|
|
342
|
+
clientEmail,
|
|
343
|
+
clientEmailOrigin,
|
|
344
|
+
privateKeyPathOrigin: `${ENV_PRIVATE_KEY} 环境变量`,
|
|
345
|
+
privateKeyData: envData,
|
|
346
|
+
privateKeyDataOrigin: `${ENV_PRIVATE_KEY_DATA} 环境变量`,
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
clientEmail,
|
|
352
|
+
clientEmailOrigin,
|
|
353
|
+
privateKeyPath: envPath,
|
|
354
|
+
privateKeyPathOrigin: `${ENV_PRIVATE_KEY} 环境变量`,
|
|
355
|
+
privateKeyData: envData,
|
|
356
|
+
privateKeyDataOrigin: `${ENV_PRIVATE_KEY_DATA} 环境变量`,
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* 收集服务账号来源(只定位来源,不读取、不解析,便于把「缺失」与其它必填项合并成一条提示)。
|
|
362
|
+
*
|
|
363
|
+
* 优先级(高 → 低):
|
|
364
|
+
* 1. 命令行 JSON 参数(--service-account / --service-account-data)—— 完整 JSON
|
|
365
|
+
* 2. 命令行分项参数(--client-email + --private-key/--private-key-data)
|
|
366
|
+
* 3. 环境变量 JSON(GOOGLE_SERVICE_ACCOUNT / GOOGLE_SERVICE_ACCOUNT_DATA)
|
|
367
|
+
* 4. 环境变量分项(GOOGLE_CLIENT_EMAIL + GOOGLE_PRIVATE_KEY/GOOGLE_PRIVATE_KEY_DATA)
|
|
368
|
+
*
|
|
369
|
+
* 命令行同时给出 JSON 与分项参数属于明确误用,报错;命令行给了 JSON 时不再读取环境变量。
|
|
370
|
+
* 环境变量层 JSON 与分项同时存在时「JSON 优先」并提示。
|
|
371
|
+
*
|
|
372
|
+
* @returns {null | { kind: 'json', path?: string, pathOrigin: string, data?: string, dataOrigin: string }
|
|
373
|
+
* | { kind: 'fields', clientEmail?: string, clientEmailOrigin: string, privateKeyPath?: string,
|
|
374
|
+
* privateKeyPathOrigin: string, privateKeyData?: string, privateKeyDataOrigin: string }}
|
|
375
|
+
*/
|
|
376
|
+
function collectServiceAccountSource(options, env, help) {
|
|
377
|
+
const usesJsonOptions = hasServiceAccountJsonOptions(options)
|
|
378
|
+
const usesFieldOptions = hasServiceAccountFieldOptions(options)
|
|
379
|
+
|
|
380
|
+
if (usesJsonOptions && usesFieldOptions) {
|
|
381
|
+
throw new UsageError(
|
|
382
|
+
`选项 "${OPTION_SERVICE_ACCOUNT}" / "${OPTION_SERVICE_ACCOUNT_DATA}" 与分项参数 ` +
|
|
383
|
+
`"${OPTION_CLIENT_EMAIL}" / "${OPTION_PRIVATE_KEY}" / "${OPTION_PRIVATE_KEY_DATA}" 不能同时使用`,
|
|
384
|
+
help(),
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (usesJsonOptions) {
|
|
389
|
+
const pathOption = options.get(OPTION_SERVICE_ACCOUNT)
|
|
390
|
+
const dataOption = options.get(OPTION_SERVICE_ACCOUNT_DATA)
|
|
391
|
+
if (pathOption !== undefined && dataOption !== undefined) {
|
|
392
|
+
throw new UsageError(
|
|
393
|
+
`选项 "${OPTION_SERVICE_ACCOUNT}" 与 "${OPTION_SERVICE_ACCOUNT_DATA}" 不能同时使用`,
|
|
394
|
+
help(),
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
kind: 'json',
|
|
399
|
+
path: pathOption,
|
|
400
|
+
pathOrigin: OPTION_SERVICE_ACCOUNT,
|
|
401
|
+
data: dataOption,
|
|
402
|
+
dataOrigin: OPTION_SERVICE_ACCOUNT_DATA,
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (usesFieldOptions) {
|
|
407
|
+
const fields = collectServiceAccountFields(options, env)
|
|
408
|
+
if (fields.conflict !== undefined) {
|
|
409
|
+
throw new UsageError(fields.conflict, help())
|
|
410
|
+
}
|
|
411
|
+
return { kind: 'fields', ...fields }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const path = readEnv(env, ENV_SERVICE_ACCOUNT)
|
|
415
|
+
const data = readEnv(env, ENV_SERVICE_ACCOUNT_DATA)
|
|
416
|
+
const envFieldsPresent = [
|
|
417
|
+
ENV_CLIENT_EMAIL,
|
|
418
|
+
ENV_PRIVATE_KEY,
|
|
419
|
+
ENV_PRIVATE_KEY_DATA,
|
|
420
|
+
].some((name) => readEnv(env, name) !== undefined)
|
|
421
|
+
|
|
422
|
+
if (path !== undefined || data !== undefined) {
|
|
423
|
+
if (path !== undefined && data !== undefined) {
|
|
424
|
+
stderr(
|
|
425
|
+
`提示: 环境变量 ${ENV_SERVICE_ACCOUNT} 与 ${ENV_SERVICE_ACCOUNT_DATA} 同时存在,本次使用 ` +
|
|
426
|
+
`${ENV_SERVICE_ACCOUNT_DATA}(JSON 数据);如需改用密钥文件请传 ${OPTION_SERVICE_ACCOUNT},` +
|
|
427
|
+
`或执行 unset ${ENV_SERVICE_ACCOUNT}`,
|
|
428
|
+
)
|
|
429
|
+
} else if (envFieldsPresent) {
|
|
430
|
+
stderr(
|
|
431
|
+
`提示: 环境变量同时提供了服务账号 JSON 与分项字段,本次使用完整 JSON;` +
|
|
432
|
+
`如需改用分项字段请执行 unset ${ENV_SERVICE_ACCOUNT} ${ENV_SERVICE_ACCOUNT_DATA}`,
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
kind: 'json',
|
|
437
|
+
path: data === undefined ? path : undefined,
|
|
438
|
+
pathOrigin: `${ENV_SERVICE_ACCOUNT} 环境变量`,
|
|
439
|
+
data,
|
|
440
|
+
dataOrigin: `${ENV_SERVICE_ACCOUNT_DATA} 环境变量`,
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (envFieldsPresent) {
|
|
445
|
+
return { kind: 'fields', ...collectServiceAccountFields(options, env) }
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return null
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* 读取并校验服务账号 JSON,返回换取令牌需要的字段。
|
|
453
|
+
*
|
|
454
|
+
* 校验失败一律是用法错误(附帮助文档),错误信息里只含字段名与路径的掩码,
|
|
455
|
+
* 绝不回显 JSON 内容或 private_key。
|
|
456
|
+
*
|
|
457
|
+
* @returns {{ clientEmail: string, privateKey: string, tokenUri: string, projectId?: string, privateKeyId?: string, description: string }}
|
|
458
|
+
*/
|
|
459
|
+
function loadServiceAccountJson({ path, pathOrigin, data, dataOrigin }, help) {
|
|
460
|
+
let text
|
|
461
|
+
let description
|
|
462
|
+
if (data !== undefined) {
|
|
463
|
+
text = data
|
|
464
|
+
description = dataOrigin
|
|
465
|
+
} else {
|
|
466
|
+
const shown = describeServiceAccountSource(path)
|
|
467
|
+
if (path.trimStart().startsWith('{')) {
|
|
468
|
+
// JSON 数据被当成文件路径:只提示该改用哪个来源,绝不回显数据
|
|
469
|
+
throw new UsageError(
|
|
470
|
+
`服务账号文件路径无效: ${shown}(来自 ${pathOrigin});该值是 JSON 数据,请改用 ` +
|
|
471
|
+
`${OPTION_SERVICE_ACCOUNT_DATA} 或环境变量 ${ENV_SERVICE_ACCOUNT_DATA}`,
|
|
472
|
+
help(),
|
|
473
|
+
)
|
|
474
|
+
}
|
|
475
|
+
if (!existsSync(path)) {
|
|
476
|
+
throw new UsageError(`服务账号文件不存在: ${shown}(来自 ${pathOrigin})`, help())
|
|
477
|
+
}
|
|
478
|
+
let stats
|
|
479
|
+
try {
|
|
480
|
+
stats = statSync(path)
|
|
481
|
+
} catch (error) {
|
|
482
|
+
throw new UsageError(
|
|
483
|
+
`读取服务账号文件失败: ${shown}(${error.code ?? '未知错误'})`,
|
|
484
|
+
help(),
|
|
485
|
+
)
|
|
486
|
+
}
|
|
487
|
+
if (!stats.isFile()) {
|
|
488
|
+
throw new UsageError(`不是普通文件: ${shown}(来自 ${pathOrigin})`, help())
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
text = readFileSync(path, 'utf8')
|
|
492
|
+
} catch (error) {
|
|
493
|
+
throw new UsageError(
|
|
494
|
+
`读取服务账号文件失败: ${shown}(${error.code ?? '未知错误'})`,
|
|
495
|
+
help(),
|
|
496
|
+
)
|
|
497
|
+
}
|
|
498
|
+
description = `文件 ${shown}(来自 ${pathOrigin})`
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
let json
|
|
502
|
+
try {
|
|
503
|
+
json = JSON.parse(text)
|
|
504
|
+
} catch {
|
|
505
|
+
// 故意不展示 JSON.parse 的错误信息:新版 Node 会在错误里带上输入片段,
|
|
506
|
+
// 而输入是服务账号 JSON(含私钥),只能整体省略,只提示怎么自查。
|
|
507
|
+
throw new UsageError(
|
|
508
|
+
`服务账号 JSON 解析失败(${description}): 不是合法 JSON,` +
|
|
509
|
+
`请用 jq . 校验密钥文件是否完整`,
|
|
510
|
+
help(),
|
|
511
|
+
)
|
|
512
|
+
}
|
|
513
|
+
if (json === null || typeof json !== 'object' || Array.isArray(json)) {
|
|
514
|
+
throw new UsageError(`服务账号 JSON 内容无效(${description}): 顶层必须是对象`, help())
|
|
515
|
+
}
|
|
516
|
+
if (json.type !== undefined && json.type !== 'service_account') {
|
|
517
|
+
throw new UsageError(
|
|
518
|
+
`服务账号 JSON 的 type 不是 "service_account"(实际为 "${json.type}"): ${description}`,
|
|
519
|
+
help(),
|
|
520
|
+
)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const clientEmail = json.client_email
|
|
524
|
+
if (typeof clientEmail !== 'string' || clientEmail.trim() === '') {
|
|
525
|
+
throw new UsageError(
|
|
526
|
+
`服务账号 JSON 缺少 client_email 字段(${description});请在 Google Cloud 控制台重新下载 JSON 密钥`,
|
|
527
|
+
help(),
|
|
528
|
+
)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const privateKeyText = json.private_key
|
|
532
|
+
if (typeof privateKeyText !== 'string' || privateKeyText === '') {
|
|
533
|
+
throw new UsageError(
|
|
534
|
+
`服务账号 JSON 缺少 private_key 字段(${description});请在 Google Cloud 控制台重新下载 JSON 密钥`,
|
|
535
|
+
help(),
|
|
536
|
+
)
|
|
537
|
+
}
|
|
538
|
+
// JSON 里的 \n 会被 JSON.parse 还原成真实换行;这里再兜底处理被双重转义的 PEM
|
|
539
|
+
const privateKey = normalizePemText(privateKeyText)
|
|
540
|
+
if (!privateKey.includes('-----BEGIN')) {
|
|
541
|
+
throw new UsageError(
|
|
542
|
+
`服务账号 JSON 的 private_key 不是 PEM 数据(${description},缺少 "-----BEGIN")`,
|
|
543
|
+
help(),
|
|
544
|
+
)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const tokenUri =
|
|
548
|
+
typeof json.token_uri === 'string' && json.token_uri !== '' ? json.token_uri : DEFAULT_TOKEN_URI
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
clientEmail,
|
|
552
|
+
privateKey,
|
|
553
|
+
tokenUri,
|
|
554
|
+
projectId: typeof json.project_id === 'string' ? json.project_id : undefined,
|
|
555
|
+
privateKeyId: typeof json.private_key_id === 'string' ? json.private_key_id : undefined,
|
|
556
|
+
description,
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* 读取并校验分项凭据(client_email + PEM 私钥)。
|
|
562
|
+
*
|
|
563
|
+
* 这是「把服务账号 JSON 里的属性拆开传」的入口,字段与 JSON 的对应关系:
|
|
564
|
+
* client_email → --client-email / GOOGLE_CLIENT_EMAIL
|
|
565
|
+
* private_key → --private-key-data / GOOGLE_PRIVATE_KEY_DATA(或存成 PEM 文件用 --private-key)
|
|
566
|
+
* token_uri → --token-uri / GOOGLE_TOKEN_URI(可选,默认 oauth2.googleapis.com)
|
|
567
|
+
* project_id、private_key_id 不参与 RS256 JWT 鉴权,因此不需要传。
|
|
568
|
+
*
|
|
569
|
+
* 校验失败一律是用法错误(附帮助文档);错误信息只含路径的掩码,私钥数据永不回显。
|
|
570
|
+
*
|
|
571
|
+
* @returns {{ clientEmail: string, privateKey: string, tokenUri: string, description: string }}
|
|
572
|
+
*/
|
|
573
|
+
function loadServiceAccountFields(
|
|
574
|
+
{
|
|
575
|
+
clientEmail,
|
|
576
|
+
clientEmailOrigin,
|
|
577
|
+
privateKeyPath,
|
|
578
|
+
privateKeyPathOrigin,
|
|
579
|
+
privateKeyData,
|
|
580
|
+
privateKeyDataOrigin,
|
|
581
|
+
},
|
|
582
|
+
help,
|
|
583
|
+
) {
|
|
584
|
+
const missing = []
|
|
585
|
+
if (clientEmail === undefined || clientEmail.trim() === '') {
|
|
586
|
+
missing.push(`${OPTION_CLIENT_EMAIL}(client_email),可用环境变量 ${ENV_CLIENT_EMAIL}`)
|
|
587
|
+
}
|
|
588
|
+
if (privateKeyPath === undefined && privateKeyData === undefined) {
|
|
589
|
+
missing.push(
|
|
590
|
+
`${OPTION_PRIVATE_KEY}(PEM 文件)或 ${OPTION_PRIVATE_KEY_DATA}(PEM 数据),` +
|
|
591
|
+
`可用环境变量 ${ENV_PRIVATE_KEY} / ${ENV_PRIVATE_KEY_DATA}`,
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
if (missing.length > 0) {
|
|
595
|
+
throw new UsageError(`缺少必填参数: ${missing.join(';')}`, help())
|
|
596
|
+
}
|
|
597
|
+
if (privateKeyPath !== undefined && privateKeyData !== undefined) {
|
|
598
|
+
throw new UsageError(
|
|
599
|
+
`选项 "${OPTION_PRIVATE_KEY}" 与 "${OPTION_PRIVATE_KEY_DATA}" 不能同时使用`,
|
|
600
|
+
help(),
|
|
601
|
+
)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
let privateKeyText
|
|
605
|
+
let privateKeyDescription
|
|
606
|
+
if (privateKeyData !== undefined) {
|
|
607
|
+
privateKeyText = privateKeyData
|
|
608
|
+
privateKeyDescription = privateKeyDataOrigin
|
|
609
|
+
} else {
|
|
610
|
+
const shown = describePrivateKeySource(privateKeyPath)
|
|
611
|
+
if (privateKeyPath.includes('-----BEGIN')) {
|
|
612
|
+
throw new UsageError(
|
|
613
|
+
`私钥路径无效: ${shown}(来自 ${privateKeyPathOrigin});该值是 PEM 数据,请改用 ` +
|
|
614
|
+
`${OPTION_PRIVATE_KEY_DATA} 或环境变量 ${ENV_PRIVATE_KEY_DATA}`,
|
|
615
|
+
help(),
|
|
616
|
+
)
|
|
617
|
+
}
|
|
618
|
+
if (!existsSync(privateKeyPath)) {
|
|
619
|
+
throw new UsageError(`私钥文件不存在: ${shown}(来自 ${privateKeyPathOrigin})`, help())
|
|
620
|
+
}
|
|
621
|
+
let stats
|
|
622
|
+
try {
|
|
623
|
+
stats = statSync(privateKeyPath)
|
|
624
|
+
} catch (error) {
|
|
625
|
+
throw new UsageError(
|
|
626
|
+
`读取私钥文件失败: ${shown}(${error.code ?? '未知错误'})`,
|
|
627
|
+
help(),
|
|
628
|
+
)
|
|
629
|
+
}
|
|
630
|
+
if (!stats.isFile()) {
|
|
631
|
+
throw new UsageError(`不是普通文件: ${shown}(来自 ${privateKeyPathOrigin})`, help())
|
|
632
|
+
}
|
|
633
|
+
try {
|
|
634
|
+
privateKeyText = readFileSync(privateKeyPath, 'utf8')
|
|
635
|
+
} catch (error) {
|
|
636
|
+
throw new UsageError(
|
|
637
|
+
`读取私钥文件失败: ${shown}(${error.code ?? '未知错误'})`,
|
|
638
|
+
help(),
|
|
639
|
+
)
|
|
640
|
+
}
|
|
641
|
+
privateKeyDescription = `文件 ${shown}(来自 ${privateKeyPathOrigin})`
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// 文件与环境变量都可能因 shell 转义留下字面量 \n,这里统一还原
|
|
645
|
+
const privateKey = normalizePemText(privateKeyText)
|
|
646
|
+
if (!privateKey.includes('-----BEGIN')) {
|
|
647
|
+
throw new UsageError(
|
|
648
|
+
`私钥内容无效: ${privateKeyDescription}(缺少 PEM 头 "-----BEGIN")`,
|
|
649
|
+
help(),
|
|
650
|
+
)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
return {
|
|
654
|
+
clientEmail,
|
|
655
|
+
privateKey,
|
|
656
|
+
tokenUri: DEFAULT_TOKEN_URI,
|
|
657
|
+
description: `分项参数(client_email 来自 ${clientEmailOrigin},私钥来自 ${privateKeyDescription})`,
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** 按来源类型读取并校验服务账号凭据 */
|
|
662
|
+
function loadServiceAccount(source, help) {
|
|
663
|
+
return source.kind === 'json'
|
|
664
|
+
? loadServiceAccountJson(source, help)
|
|
665
|
+
: loadServiceAccountFields(source, help)
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// ---------------------------------------------------------- Google 响应处理
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* 从 Google 的错误响应里整理出可读信息。
|
|
672
|
+
*
|
|
673
|
+
* 两类错误体:
|
|
674
|
+
* - OAuth 令牌接口:{ "error": "invalid_grant", "error_description": "…" }
|
|
675
|
+
* - 业务接口:{ "error": { "code": 403, "message": "…", "status": "PERMISSION_DENIED",
|
|
676
|
+
* "errors": [{ "reason": "…", "message": "…" }] } }
|
|
677
|
+
* 解析不出来时退回响应原文(截断),至少保留状态码与一小段响应体便于排查。
|
|
678
|
+
*/
|
|
679
|
+
function describeGoogleError(text) {
|
|
680
|
+
if (text === '') {
|
|
681
|
+
return '(响应体为空)'
|
|
682
|
+
}
|
|
683
|
+
let parsed
|
|
684
|
+
try {
|
|
685
|
+
parsed = JSON.parse(text)
|
|
686
|
+
} catch {
|
|
687
|
+
return text.slice(0, 300)
|
|
688
|
+
}
|
|
689
|
+
const error = parsed?.error
|
|
690
|
+
if (typeof error === 'string') {
|
|
691
|
+
const description = parsed?.error_description
|
|
692
|
+
return typeof description === 'string' && description !== ''
|
|
693
|
+
? `${error}: ${description}`
|
|
694
|
+
: error
|
|
695
|
+
}
|
|
696
|
+
if (error !== null && typeof error === 'object') {
|
|
697
|
+
const reason = Array.isArray(error.errors)
|
|
698
|
+
? error.errors.find((item) => typeof item?.reason === 'string')?.reason
|
|
699
|
+
: undefined
|
|
700
|
+
const message = typeof error.message === 'string' ? error.message : error.status
|
|
701
|
+
const detail = [message, reason === undefined ? undefined : `reason=${reason}`]
|
|
702
|
+
.filter((item) => typeof item === 'string' && item !== '')
|
|
703
|
+
.join(';')
|
|
704
|
+
return detail === '' ? text.slice(0, 300) : detail
|
|
705
|
+
}
|
|
706
|
+
if (typeof parsed?.message === 'string') {
|
|
707
|
+
return parsed.message
|
|
708
|
+
}
|
|
709
|
+
return text.slice(0, 300)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* 解析 Google 接口响应,统一处理三类失败(各接口共用)。
|
|
714
|
+
*
|
|
715
|
+
* 1. HTTP 非 2xx —— 网关或服务层拒绝
|
|
716
|
+
* 2. HTTP 2xx 但响应体非 JSON —— 正常业务响应一定是 JSON
|
|
717
|
+
* 3. 成功响应体为空 —— DELETE edit 返回 204,属于正常情况,回传 {}
|
|
718
|
+
*
|
|
719
|
+
* @param {Response} response fetch 响应
|
|
720
|
+
* @param {string} text 已读取的响应体
|
|
721
|
+
* @param {string} target 请求地址(含域名),用于错误信息里定位
|
|
722
|
+
*/
|
|
723
|
+
function parseGoogleResponse(response, text, target) {
|
|
724
|
+
if (!response.ok) {
|
|
725
|
+
throw new Error(
|
|
726
|
+
`HTTP ${response.status} ${response.statusText} @ ${target}: ${describeGoogleError(text)}`,
|
|
727
|
+
)
|
|
728
|
+
}
|
|
729
|
+
if (text === '') {
|
|
730
|
+
return {}
|
|
731
|
+
}
|
|
732
|
+
try {
|
|
733
|
+
return JSON.parse(text)
|
|
734
|
+
} catch {
|
|
735
|
+
throw new Error(`响应不是合法 JSON(HTTP ${response.status}): ${text.slice(0, 200)}`)
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** 请求地址:失败信息里带上完整地址,便于按域名/路径排查 */
|
|
740
|
+
function apiUrl(apiBase, prefix, path) {
|
|
741
|
+
return `${apiBase}${prefix}${path}`
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* 调 Android Publisher 的普通(非上传)接口。
|
|
746
|
+
*
|
|
747
|
+
* @param {{ token: string, apiBase: string, method: string, path: string, body?: object, timeoutMs?: number }} params
|
|
748
|
+
* @returns {Promise<object>} 解析后的 JSON;空响应体回传 {}
|
|
749
|
+
*/
|
|
750
|
+
async function callAndroidPublisher({
|
|
751
|
+
token,
|
|
752
|
+
apiBase,
|
|
753
|
+
method,
|
|
754
|
+
path,
|
|
755
|
+
body,
|
|
756
|
+
timeoutMs = REQUEST_TIMEOUT_MS,
|
|
757
|
+
}) {
|
|
758
|
+
const url = apiUrl(apiBase, API_PATH_PREFIX, path)
|
|
759
|
+
const headers = {
|
|
760
|
+
Authorization: `Bearer ${token}`,
|
|
761
|
+
Accept: 'application/json',
|
|
762
|
+
}
|
|
763
|
+
if (body !== undefined) {
|
|
764
|
+
headers['Content-Type'] = 'application/json'
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
let response
|
|
768
|
+
try {
|
|
769
|
+
response = await fetch(url, {
|
|
770
|
+
method,
|
|
771
|
+
headers,
|
|
772
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
773
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
774
|
+
})
|
|
775
|
+
} catch (error) {
|
|
776
|
+
throw new Error(`请求 ${url} 失败: ${error.message}`)
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const text = await response.text()
|
|
780
|
+
return parseGoogleResponse(response, text, url)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* 用服务账号换取 access_token(OAuth 2.0 JWT Bearer 流程)。
|
|
785
|
+
*
|
|
786
|
+
* POST {token_uri}
|
|
787
|
+
* Content-Type: application/x-www-form-urlencoded
|
|
788
|
+
* grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=<RS256 JWT>
|
|
789
|
+
*
|
|
790
|
+
* JWT 载荷固定为 { iss: client_email, scope, aud: token_uri, iat, exp },
|
|
791
|
+
* 这是 Google 服务账号「无用户交互」换取令牌的标准写法,不需要 kid(由私钥签名定位密钥)。
|
|
792
|
+
*
|
|
793
|
+
* @param {{ clientEmail: string, privateKey: string, tokenUri?: string, scope?: string, timeoutMs?: number }} params
|
|
794
|
+
* @returns {Promise<object>} 令牌接口原始响应:{ access_token, token_type, expires_in, … }
|
|
795
|
+
*/
|
|
796
|
+
export async function fetchServiceAccountAccessToken({
|
|
797
|
+
clientEmail,
|
|
798
|
+
privateKey,
|
|
799
|
+
tokenUri = DEFAULT_TOKEN_URI,
|
|
800
|
+
scope = ANDROID_PUBLISHER_SCOPE,
|
|
801
|
+
timeoutMs = TOKEN_TIMEOUT_MS,
|
|
802
|
+
}) {
|
|
803
|
+
const iat = currentUnixSeconds()
|
|
804
|
+
const assertion = signJwt({
|
|
805
|
+
header: { alg: 'RS256', typ: 'JWT' },
|
|
806
|
+
claims: {
|
|
807
|
+
iss: clientEmail,
|
|
808
|
+
scope,
|
|
809
|
+
aud: tokenUri,
|
|
810
|
+
iat,
|
|
811
|
+
exp: iat + JWT_LIFETIME_SECONDS,
|
|
812
|
+
},
|
|
813
|
+
privateKey,
|
|
814
|
+
algorithm: 'RS256',
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
const form = new URLSearchParams({
|
|
818
|
+
grant_type: JWT_BEARER_GRANT_TYPE,
|
|
819
|
+
assertion,
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
let response
|
|
823
|
+
try {
|
|
824
|
+
response = await fetch(tokenUri, {
|
|
825
|
+
method: 'POST',
|
|
826
|
+
headers: {
|
|
827
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
828
|
+
Accept: 'application/json',
|
|
829
|
+
},
|
|
830
|
+
body: form,
|
|
831
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
832
|
+
})
|
|
833
|
+
} catch (error) {
|
|
834
|
+
throw new Error(`请求 ${tokenUri} 失败: ${error.message}`)
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const text = await response.text()
|
|
838
|
+
return parseGoogleResponse(response, text, tokenUri)
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// ------------------------------------------------------------ Token 解析
|
|
842
|
+
|
|
843
|
+
/** --token 模式下日志里展示的来源名(区分命令行与环境变量) */
|
|
844
|
+
function tokenOptionSource(options) {
|
|
845
|
+
return options.get(OPTION_TOKEN) !== undefined ? OPTION_TOKEN : `${ENV_TOKEN} 环境变量`
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* 决定这次用哪种 Token 来源(不发起任何网络请求,便于先与其他必填项一起报缺失)。
|
|
850
|
+
*
|
|
851
|
+
* 来源优先级(高 → 低)与 huawei 模块保持一致:
|
|
852
|
+
* 1. 命令行显式给出的服务账号参数(--service-account / --service-account-data)
|
|
853
|
+
* —— 本地换取 access_token
|
|
854
|
+
* 2. --token(或环境变量 GOOGLE_TOKEN)—— 直接使用已换取的 access_token
|
|
855
|
+
* 3. 环境变量里已有的服务账号凭证 —— 本地换取 access_token
|
|
856
|
+
*
|
|
857
|
+
* @returns {{ missing: string[] } | { kind: 'token', token: string, source: string } | { kind: 'service-account', source: string }}
|
|
858
|
+
*/
|
|
859
|
+
function planAccessToken(options, env) {
|
|
860
|
+
const token = options.get(OPTION_TOKEN) ?? readEnv(env, ENV_TOKEN)
|
|
861
|
+
const usesOptions = hasServiceAccountOptions(options)
|
|
862
|
+
|
|
863
|
+
if (!usesOptions && token === undefined && !hasEnvServiceAccount(env)) {
|
|
864
|
+
return {
|
|
865
|
+
missing: [
|
|
866
|
+
`${OPTION_TOKEN}(access_token),或服务账号凭据(本地换取 access_token):` +
|
|
867
|
+
serviceAccountMissingHint(),
|
|
868
|
+
],
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (usesOptions || token === undefined) {
|
|
873
|
+
return {
|
|
874
|
+
kind: 'service-account',
|
|
875
|
+
source: usesOptions ? 'service-account 参数(本地换取)' : '环境变量凭证(本地换取)',
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
return { kind: 'token', token, source: tokenOptionSource(options) }
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** --verbose 时打印令牌换取结果(Token、client_email 只打印掩码,私钥永不写入日志) */
|
|
883
|
+
function logAccessTokenDetails({ tokenUri, account, data, token }) {
|
|
884
|
+
stderr('已换取 access_token')
|
|
885
|
+
stderr(` 请求地址: POST ${tokenUri}`)
|
|
886
|
+
stderr(` 服务账号: ${maskValue(account.clientEmail)}`)
|
|
887
|
+
if (account.privateKeyId !== undefined) {
|
|
888
|
+
stderr(` 私钥 ID: ${maskValue(account.privateKeyId)}`)
|
|
889
|
+
}
|
|
890
|
+
if (account.projectId !== undefined) {
|
|
891
|
+
stderr(` 项目: ${account.projectId}`)
|
|
892
|
+
}
|
|
893
|
+
stderr(` scope: ${ANDROID_PUBLISHER_SCOPE}`)
|
|
894
|
+
stderr(` 服务账号来源: ${account.description}`)
|
|
895
|
+
stderr(` Token: ${maskValue(token)}`)
|
|
896
|
+
// 这两个字段 Google 不一定都返回,缺哪个就不打印哪个,避免噪音
|
|
897
|
+
if (data.token_type !== undefined) {
|
|
898
|
+
stderr(` token_type: ${data.token_type}`)
|
|
899
|
+
}
|
|
900
|
+
if (data.expires_in !== undefined) {
|
|
901
|
+
stderr(` expires_in: ${data.expires_in} 秒`)
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* 用服务账号换取 access_token,失败时打印错误日志并回传 null。
|
|
907
|
+
*
|
|
908
|
+
* @returns {Promise<{ token: string, account: object } | null>}
|
|
909
|
+
*/
|
|
910
|
+
async function exchangeAccessToken({ options, env, help, verbose }) {
|
|
911
|
+
const source = collectServiceAccountSource(options, env, help)
|
|
912
|
+
if (source === null) {
|
|
913
|
+
// 调用方已检查过凭据是否存在;这里兜底,避免空来源进入加载器
|
|
914
|
+
throw new UsageError(`缺少必填参数: ${serviceAccountMissingHint()}`, help())
|
|
915
|
+
}
|
|
916
|
+
const account = loadServiceAccount(source, help)
|
|
917
|
+
// 地址优先级:--token-uri > GOOGLE_TOKEN_URI > JSON 的 token_uri > 默认地址
|
|
918
|
+
const tokenUri =
|
|
919
|
+
options.get(OPTION_TOKEN_URI) ?? readEnv(env, ENV_TOKEN_URI) ?? account.tokenUri
|
|
920
|
+
|
|
921
|
+
let data
|
|
922
|
+
try {
|
|
923
|
+
data = await fetchServiceAccountAccessToken({
|
|
924
|
+
clientEmail: account.clientEmail,
|
|
925
|
+
privateKey: account.privateKey,
|
|
926
|
+
tokenUri,
|
|
927
|
+
})
|
|
928
|
+
} catch (error) {
|
|
929
|
+
// 网络/接口失败属于运行期问题,不是用法问题,所以只报错不打印帮助文档
|
|
930
|
+
stderr(`错误: 换取 access_token 失败: ${error.message}`)
|
|
931
|
+
return null
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const token = data?.access_token
|
|
935
|
+
if (typeof token !== 'string' || token === '') {
|
|
936
|
+
stderr('错误: 换取 access_token 失败: 响应中未包含 access_token')
|
|
937
|
+
return null
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if (verbose) {
|
|
941
|
+
logAccessTokenDetails({ tokenUri, account, data, token })
|
|
942
|
+
}
|
|
943
|
+
return { token, account }
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// --------------------------------------------------------- 文件与参数校验
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* 本地文件大小(字节),同时承担路径校验。
|
|
950
|
+
*
|
|
951
|
+
* 与 huawei 模块的规则一致:不存在、不是普通文件、大小超出安全整数范围都抛用法错误。
|
|
952
|
+
* 待上传的文件路径本身不是凭证,错误信息里完整给出,便于定位。
|
|
953
|
+
*/
|
|
954
|
+
function localFileSize(path, origin, help) {
|
|
955
|
+
if (!existsSync(path)) {
|
|
956
|
+
throw new UsageError(`文件不存在: ${path}(来自 ${origin})`, help())
|
|
957
|
+
}
|
|
958
|
+
const stats = statSync(path)
|
|
959
|
+
if (!stats.isFile()) {
|
|
960
|
+
throw new UsageError(`不是普通文件: ${path}(来自 ${origin})`, help())
|
|
961
|
+
}
|
|
962
|
+
if (!Number.isSafeInteger(stats.size)) {
|
|
963
|
+
throw new UsageError(`文件大小超出可表示范围: ${path}(来自 ${origin})`, help())
|
|
964
|
+
}
|
|
965
|
+
return stats.size
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/** 取文件名的后缀(最后一个 "." 之后的部分,大小写不敏感);没有 "." 时返回空串 */
|
|
969
|
+
function fileSuffix(fileName) {
|
|
970
|
+
const index = fileName.lastIndexOf('.')
|
|
971
|
+
return index === -1 ? '' : fileName.slice(index + 1).toLowerCase()
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* 收集 google upload-aab 的参数(选项优先,其次环境变量)。
|
|
976
|
+
*
|
|
977
|
+
* 缺失项不在这里抛错,而是回传 missing:调用方要把它与「Token 来源缺失」合并成一条错误信息。
|
|
978
|
+
*
|
|
979
|
+
* @returns {{
|
|
980
|
+
* packageName?: string, file?: string, fileOrigin: string, track?: string, releaseStatus?: string,
|
|
981
|
+
* releaseName?: string, releaseNotes?: string, releaseNotesLanguage?: string,
|
|
982
|
+
* userFraction?: string, missing: string[]
|
|
983
|
+
* }}
|
|
984
|
+
*/
|
|
985
|
+
function collectUploadAabParams(options, env) {
|
|
986
|
+
const packageName = options.get(OPTION_PACKAGE_NAME) ?? readEnv(env, ENV_PACKAGE_NAME)
|
|
987
|
+
const file = options.get(OPTION_FILE) ?? readEnv(env, ENV_FILE)
|
|
988
|
+
// 供文件相关的错误信息标注来源:命令行给出时是 --file,否则是环境变量
|
|
989
|
+
const fileOrigin = options.has(OPTION_FILE) ? OPTION_FILE : `${ENV_FILE} 环境变量`
|
|
990
|
+
const track = options.get(OPTION_TRACK) ?? readEnv(env, ENV_TRACK)
|
|
991
|
+
const releaseStatus = options.get(OPTION_RELEASE_STATUS) ?? readEnv(env, ENV_RELEASE_STATUS)
|
|
992
|
+
const releaseName = options.get(OPTION_RELEASE_NAME) ?? readEnv(env, ENV_RELEASE_NAME)
|
|
993
|
+
const releaseNotes = options.get(OPTION_RELEASE_NOTES) ?? readEnv(env, ENV_RELEASE_NOTES)
|
|
994
|
+
const releaseNotesLanguage =
|
|
995
|
+
options.get(OPTION_RELEASE_NOTES_LANGUAGE) ?? readEnv(env, ENV_RELEASE_NOTES_LANGUAGE)
|
|
996
|
+
const userFraction = options.get(OPTION_USER_FRACTION) ?? readEnv(env, ENV_USER_FRACTION)
|
|
997
|
+
|
|
998
|
+
const missing = []
|
|
999
|
+
if (packageName === undefined) {
|
|
1000
|
+
missing.push(`${OPTION_PACKAGE_NAME}(应用包名),可用环境变量 ${ENV_PACKAGE_NAME}`)
|
|
1001
|
+
}
|
|
1002
|
+
if (file === undefined) {
|
|
1003
|
+
missing.push(`${OPTION_FILE}(.aab 文件路径),可用环境变量 ${ENV_FILE}`)
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
return {
|
|
1007
|
+
packageName,
|
|
1008
|
+
file,
|
|
1009
|
+
fileOrigin,
|
|
1010
|
+
track,
|
|
1011
|
+
releaseStatus,
|
|
1012
|
+
releaseName,
|
|
1013
|
+
releaseNotes,
|
|
1014
|
+
releaseNotesLanguage,
|
|
1015
|
+
userFraction,
|
|
1016
|
+
missing,
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/** 校验小数比例:必须满足 0 < 值 < 1(Google 的 userFraction 不含两端) */
|
|
1021
|
+
function parseUserFraction(value, help) {
|
|
1022
|
+
const fraction = Number(value)
|
|
1023
|
+
if (!Number.isFinite(fraction) || fraction <= 0 || fraction >= 1) {
|
|
1024
|
+
throw new UsageError(
|
|
1025
|
+
`选项 "${OPTION_USER_FRACTION}" 需要 0 与 1 之间的小数(不含 0 和 1),实际收到 "${value}"`,
|
|
1026
|
+
help(),
|
|
1027
|
+
)
|
|
1028
|
+
}
|
|
1029
|
+
return fraction
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* 校验并规范化 upload-aab 的本地参数(不发起网络请求)。
|
|
1034
|
+
*
|
|
1035
|
+
* 这些规则在 Google 侧都只会换回一个语焉不详的 400,先在本地拦下能给出更直接的提示。
|
|
1036
|
+
*
|
|
1037
|
+
* @returns {{ packageName: string, file: string, size: number, track?: string,
|
|
1038
|
+
* releaseStatus?: string, releaseName?: string, releaseNotes?: string,
|
|
1039
|
+
* releaseNotesLanguage?: string, userFraction?: number }}
|
|
1040
|
+
*/
|
|
1041
|
+
function validateUploadAabParams(params, help) {
|
|
1042
|
+
const { packageName, file, track } = params
|
|
1043
|
+
|
|
1044
|
+
if (!PACKAGE_NAME_PATTERN.test(packageName)) {
|
|
1045
|
+
throw new UsageError(
|
|
1046
|
+
`选项 "${OPTION_PACKAGE_NAME}" 需要合法的应用包名(至少两段、每段以字母开头,` +
|
|
1047
|
+
`只能含字母/数字/下划线,如 com.example.app),实际收到 "${packageName}"`,
|
|
1048
|
+
help(),
|
|
1049
|
+
)
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
if (fileSuffix(file) !== 'aab') {
|
|
1053
|
+
throw new UsageError(
|
|
1054
|
+
`选项 "${OPTION_FILE}" 需要 .aab 文件(Android App Bundle),实际收到 "${file}"`,
|
|
1055
|
+
help(),
|
|
1056
|
+
)
|
|
1057
|
+
}
|
|
1058
|
+
const origin = params.fileOrigin
|
|
1059
|
+
const size = localFileSize(file, origin, help)
|
|
1060
|
+
|
|
1061
|
+
if (track !== undefined && (track.trim() === '' || /[\s/]/.test(track))) {
|
|
1062
|
+
throw new UsageError(
|
|
1063
|
+
`选项 "${OPTION_TRACK}" 需要合法的轨道名(不能含空白或 "/",如 production、beta、internal),` +
|
|
1064
|
+
`实际收到 "${track}"`,
|
|
1065
|
+
help(),
|
|
1066
|
+
)
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const hasReleaseOptions = [
|
|
1070
|
+
params.releaseStatus,
|
|
1071
|
+
params.releaseName,
|
|
1072
|
+
params.releaseNotes,
|
|
1073
|
+
params.releaseNotesLanguage,
|
|
1074
|
+
params.userFraction,
|
|
1075
|
+
].some((value) => value !== undefined)
|
|
1076
|
+
if (track === undefined && hasReleaseOptions) {
|
|
1077
|
+
throw new UsageError(
|
|
1078
|
+
`选项 ${OPTION_RELEASE_STATUS} / ${OPTION_RELEASE_NAME} / ${OPTION_RELEASE_NOTES} / ` +
|
|
1079
|
+
`${OPTION_RELEASE_NOTES_LANGUAGE} / ${OPTION_USER_FRACTION} 只在给出 ${OPTION_TRACK} 时可用`,
|
|
1080
|
+
help(),
|
|
1081
|
+
)
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (track === undefined) {
|
|
1085
|
+
return { packageName, file, size }
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const releaseStatus = params.releaseStatus ?? 'completed'
|
|
1089
|
+
if (!RELEASE_STATUS_SET.has(releaseStatus)) {
|
|
1090
|
+
throw new UsageError(
|
|
1091
|
+
`选项 "${OPTION_RELEASE_STATUS}" 需要以下状态之一: ${RELEASE_STATUSES.join(' / ')},` +
|
|
1092
|
+
`实际收到 "${releaseStatus}"`,
|
|
1093
|
+
help(),
|
|
1094
|
+
)
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
if (params.releaseNotesLanguage !== undefined && params.releaseNotes === undefined) {
|
|
1098
|
+
throw new UsageError(
|
|
1099
|
+
`选项 "${OPTION_RELEASE_NOTES_LANGUAGE}" 需要与 "${OPTION_RELEASE_NOTES}" 一起使用`,
|
|
1100
|
+
help(),
|
|
1101
|
+
)
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
let userFraction
|
|
1105
|
+
if (params.userFraction !== undefined) {
|
|
1106
|
+
if (releaseStatus !== 'inProgress' && releaseStatus !== 'halted') {
|
|
1107
|
+
throw new UsageError(
|
|
1108
|
+
`选项 "${OPTION_USER_FRACTION}" 只适用于 ${OPTION_RELEASE_STATUS} 为 inProgress / halted 时`,
|
|
1109
|
+
help(),
|
|
1110
|
+
)
|
|
1111
|
+
}
|
|
1112
|
+
userFraction = parseUserFraction(params.userFraction, help)
|
|
1113
|
+
} else if (releaseStatus === 'inProgress') {
|
|
1114
|
+
throw new UsageError(
|
|
1115
|
+
`发布状态为 inProgress 时必须给出 "${OPTION_USER_FRACTION}"(0 与 1 之间的小数)`,
|
|
1116
|
+
help(),
|
|
1117
|
+
)
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
return {
|
|
1121
|
+
packageName,
|
|
1122
|
+
file,
|
|
1123
|
+
size,
|
|
1124
|
+
track,
|
|
1125
|
+
releaseStatus,
|
|
1126
|
+
releaseName: params.releaseName,
|
|
1127
|
+
releaseNotes: params.releaseNotes,
|
|
1128
|
+
// 只在真的有发布说明时才带语言;默认 zh-CN(本项目主要面向中文应用)
|
|
1129
|
+
releaseNotesLanguage:
|
|
1130
|
+
params.releaseNotes === undefined ? undefined : (params.releaseNotesLanguage ?? 'zh-CN'),
|
|
1131
|
+
userFraction,
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// ------------------------------------------------------------- 上传与提交
|
|
1136
|
+
|
|
1137
|
+
/** 应用级接口路径:/applications/{packageName} */
|
|
1138
|
+
function applicationPath(packageName) {
|
|
1139
|
+
return `/applications/${encodeURIComponent(packageName)}`
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/** edit 级接口路径:/applications/{packageName}/edits/{editId} */
|
|
1143
|
+
function editPath(packageName, editId) {
|
|
1144
|
+
return `${applicationPath(packageName)}/edits/${encodeURIComponent(editId)}`
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* 从上传响应里取 versionCode。
|
|
1149
|
+
*
|
|
1150
|
+
* Google 的 Bundle.versionCode 是 int64:JSON 里既可能是数字,也可能按 protobuf JSON 约定
|
|
1151
|
+
* 编码成字符串。两种都接受,统一转成十进制字符串,因为它要作为 Track.release.versionCodes
|
|
1152
|
+
* 的元素(该字段是 string 数组)。
|
|
1153
|
+
*/
|
|
1154
|
+
function normalizeVersionCode(value) {
|
|
1155
|
+
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) {
|
|
1156
|
+
return String(value)
|
|
1157
|
+
}
|
|
1158
|
+
if (typeof value === 'string' && /^\d+$/.test(value) && value !== '0') {
|
|
1159
|
+
return value
|
|
1160
|
+
}
|
|
1161
|
+
return undefined
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* 发起 resumable 上传,拿到会话地址。
|
|
1166
|
+
*
|
|
1167
|
+
* POST {apiBase}/upload/androidpublisher/v3/applications/{packageName}/edits/{editId}/bundles
|
|
1168
|
+
* ?uploadType=resumable
|
|
1169
|
+
* Authorization: Bearer <access_token>
|
|
1170
|
+
* Content-Type: application/json; charset=UTF-8
|
|
1171
|
+
* X-Upload-Content-Type: application/octet-stream
|
|
1172
|
+
* X-Upload-Content-Length: <文件字节数>
|
|
1173
|
+
* Body: {}(本次上传没有额外的 metadata)
|
|
1174
|
+
*
|
|
1175
|
+
* 响应成功时 Body 为空,真正有用的信息在 **Location 头**里:那是随后 PUT 文件内容的会话地址。
|
|
1176
|
+
*
|
|
1177
|
+
* @returns {Promise<string>} resumable 会话地址
|
|
1178
|
+
*/
|
|
1179
|
+
async function initiateBundleUpload({ token, apiBase, packageName, editId, size }) {
|
|
1180
|
+
const url = `${apiUrl(apiBase, UPLOAD_PATH_PREFIX, editPath(packageName, editId))}/bundles?uploadType=resumable`
|
|
1181
|
+
|
|
1182
|
+
let response
|
|
1183
|
+
try {
|
|
1184
|
+
response = await fetch(url, {
|
|
1185
|
+
method: 'POST',
|
|
1186
|
+
headers: {
|
|
1187
|
+
Authorization: `Bearer ${token}`,
|
|
1188
|
+
'Content-Type': 'application/json; charset=UTF-8',
|
|
1189
|
+
'X-Upload-Content-Type': 'application/octet-stream',
|
|
1190
|
+
'X-Upload-Content-Length': String(size),
|
|
1191
|
+
Accept: 'application/json',
|
|
1192
|
+
},
|
|
1193
|
+
body: '{}',
|
|
1194
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1195
|
+
})
|
|
1196
|
+
} catch (error) {
|
|
1197
|
+
throw new Error(`请求 ${url} 失败: ${error.message}`)
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
const text = await response.text()
|
|
1201
|
+
// 只为错误处理解析响应;成功时响应体可能是空的(见 parseGoogleResponse)
|
|
1202
|
+
parseGoogleResponse(response, text, url)
|
|
1203
|
+
|
|
1204
|
+
const sessionUri = response.headers.get('location')
|
|
1205
|
+
if (typeof sessionUri !== 'string' || sessionUri === '') {
|
|
1206
|
+
throw new Error('发起上传失败: 响应中未包含 Location 头(resumable 会话地址)')
|
|
1207
|
+
}
|
|
1208
|
+
return sessionUri
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* 上传 .aab 到已有 edit(Google 的 resumable media upload)。
|
|
1213
|
+
*
|
|
1214
|
+
* 1. POST .../bundles?uploadType=resumable → 取 Location 会话地址(见 initiateBundleUpload)
|
|
1215
|
+
* 2. PUT <会话地址>,Content-Type: application/octet-stream,Body 为文件内容 → 返回 Bundle
|
|
1216
|
+
*
|
|
1217
|
+
* 为什么用 resumable 而不是 simple(uploadType=media):Android App Bundle 通常有几十 MB,
|
|
1218
|
+
* 超过 Google 对 simple upload 的建议上限(约 5 MB);resumable 是官方对大文件的做法,
|
|
1219
|
+
* 也是 fastlane / gradle-play-publisher 采用的路径。本次实现一次 PUT 传完整文件(不再分块),
|
|
1220
|
+
* 会话地址本身已含授权信息,因此 PUT 时不再带 Authorization。
|
|
1221
|
+
*
|
|
1222
|
+
* 大文件说明:文件一次性读入内存(见 uploadAabAndCommit),可上传大小受 Node 堆上限约束;
|
|
1223
|
+
* 官方建议上传接口的超时至少 2 分钟,这里用 UPLOAD_TIMEOUT_MS。
|
|
1224
|
+
*
|
|
1225
|
+
* @returns {Promise<object>} Bundle:{ versionCode, sha1, sha256, size }
|
|
1226
|
+
*/
|
|
1227
|
+
async function uploadBundle({ token, apiBase, packageName, editId, body }) {
|
|
1228
|
+
const sessionUri = await initiateBundleUpload({
|
|
1229
|
+
token,
|
|
1230
|
+
apiBase,
|
|
1231
|
+
packageName,
|
|
1232
|
+
editId,
|
|
1233
|
+
size: body.length,
|
|
1234
|
+
})
|
|
1235
|
+
|
|
1236
|
+
let response
|
|
1237
|
+
try {
|
|
1238
|
+
response = await fetch(sessionUri, {
|
|
1239
|
+
method: 'PUT',
|
|
1240
|
+
headers: {
|
|
1241
|
+
'Content-Type': 'application/octet-stream',
|
|
1242
|
+
'Content-Length': String(body.length),
|
|
1243
|
+
},
|
|
1244
|
+
body,
|
|
1245
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
|
1246
|
+
})
|
|
1247
|
+
} catch (error) {
|
|
1248
|
+
throw new Error(`上传会话 ${sessionUri} 请求失败: ${error.message}`)
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const text = await response.text()
|
|
1252
|
+
return parseGoogleResponse(response, text, sessionUri)
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/** --verbose 时打印本次发布计划(Token、服务账号只打印掩码;本地路径按原样打印) */
|
|
1256
|
+
function logUploadPlan({ plan, params }) {
|
|
1257
|
+
stderr('开始发布到 Google Play')
|
|
1258
|
+
stderr(` 应用包名: ${params.packageName}`)
|
|
1259
|
+
stderr(` 文件: ${params.file}`)
|
|
1260
|
+
stderr(` 文件大小: ${params.size} 字节`)
|
|
1261
|
+
stderr(` Token 来源: ${plan.source}`)
|
|
1262
|
+
stderr(` Token: ${maskValue(plan.token)}`)
|
|
1263
|
+
if (params.track === undefined) {
|
|
1264
|
+
stderr(' 轨道: (未指定,只上传 bundle,不发布到任何轨道)')
|
|
1265
|
+
} else {
|
|
1266
|
+
stderr(` 轨道: ${params.track}`)
|
|
1267
|
+
stderr(` 发布状态: ${params.releaseStatus}`)
|
|
1268
|
+
if (params.releaseName !== undefined) {
|
|
1269
|
+
stderr(` 发布名称: ${params.releaseName}`)
|
|
1270
|
+
}
|
|
1271
|
+
if (params.releaseNotes !== undefined) {
|
|
1272
|
+
stderr(` 发布说明: [${params.releaseNotesLanguage}] ${params.releaseNotes}`)
|
|
1273
|
+
}
|
|
1274
|
+
if (params.userFraction !== undefined) {
|
|
1275
|
+
stderr(` 分阶段比例: ${params.userFraction}`)
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* 完整的发布流程:创建 edit → 上传 .aab →(可选)更新轨道 → commit。
|
|
1282
|
+
*
|
|
1283
|
+
* 失败处理:只要 edit 还没 commit,就尽力 delete 它。这是 Google 推荐的做法——
|
|
1284
|
+
* 未提交的 edit 会把上传的 bundle 一并丢弃,避免「传了一半但没发布」的中间状态。
|
|
1285
|
+
*
|
|
1286
|
+
* @returns {Promise<number>} 退出代码(EXIT_OK / EXIT_FAILURE)
|
|
1287
|
+
*/
|
|
1288
|
+
async function uploadAabAndCommit({
|
|
1289
|
+
apiBase,
|
|
1290
|
+
token,
|
|
1291
|
+
plan,
|
|
1292
|
+
params,
|
|
1293
|
+
changesNotSentForReview,
|
|
1294
|
+
verbose,
|
|
1295
|
+
}) {
|
|
1296
|
+
if (verbose) {
|
|
1297
|
+
logUploadPlan({ plan, params })
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// 先把文件读进内存再创建 edit:本地读失败不该留下一个待清理的 edit
|
|
1301
|
+
let body
|
|
1302
|
+
try {
|
|
1303
|
+
body = readFileSync(params.file)
|
|
1304
|
+
} catch (error) {
|
|
1305
|
+
stderr(`错误: 读取文件失败: ${params.file}: ${error.message}`)
|
|
1306
|
+
return EXIT_FAILURE
|
|
1307
|
+
}
|
|
1308
|
+
if (body.length !== params.size) {
|
|
1309
|
+
stderr(
|
|
1310
|
+
`错误: 文件在大小校验之后发生变化: ${params.file},` +
|
|
1311
|
+
`校验时 ${params.size} 字节,读取时 ${body.length} 字节;请重试`,
|
|
1312
|
+
)
|
|
1313
|
+
return EXIT_FAILURE
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
let edit
|
|
1317
|
+
try {
|
|
1318
|
+
edit = await callAndroidPublisher({
|
|
1319
|
+
token,
|
|
1320
|
+
apiBase,
|
|
1321
|
+
method: 'POST',
|
|
1322
|
+
path: `${applicationPath(params.packageName)}/edits`,
|
|
1323
|
+
body: {},
|
|
1324
|
+
})
|
|
1325
|
+
} catch (error) {
|
|
1326
|
+
stderr(`错误: 创建 edit 失败: ${error.message}`)
|
|
1327
|
+
return EXIT_FAILURE
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
const editId = edit?.id
|
|
1331
|
+
if (typeof editId !== 'string' || editId === '') {
|
|
1332
|
+
stderr('错误: 创建 edit 失败: 响应中未包含 edit.id')
|
|
1333
|
+
return EXIT_FAILURE
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
if (verbose) {
|
|
1337
|
+
stderr('已创建 edit')
|
|
1338
|
+
stderr(` editId: ${editId}`)
|
|
1339
|
+
if (edit.expiryTime !== undefined) {
|
|
1340
|
+
stderr(` 过期时间: ${edit.expiryTime}`)
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* 打印错误、丢弃未提交的 edit,并返回失败退出代码。
|
|
1346
|
+
* 丢弃本身失败只作为警告:edit 会自行过期,真正的问题仍是上面那条错误。
|
|
1347
|
+
*/
|
|
1348
|
+
const fail = async (message) => {
|
|
1349
|
+
stderr(`错误: ${message}`)
|
|
1350
|
+
try {
|
|
1351
|
+
await callAndroidPublisher({
|
|
1352
|
+
token,
|
|
1353
|
+
apiBase,
|
|
1354
|
+
method: 'DELETE',
|
|
1355
|
+
path: editPath(params.packageName, editId),
|
|
1356
|
+
})
|
|
1357
|
+
if (verbose) {
|
|
1358
|
+
stderr('已丢弃未提交的 edit')
|
|
1359
|
+
}
|
|
1360
|
+
} catch (error) {
|
|
1361
|
+
stderr(`警告: 丢弃 edit 失败(若 commit 已生效可忽略): ${error.message}`)
|
|
1362
|
+
}
|
|
1363
|
+
return EXIT_FAILURE
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
if (verbose) {
|
|
1367
|
+
stderr('开始上传 .aab')
|
|
1368
|
+
const uploadPath = `${apiUrl(apiBase, UPLOAD_PATH_PREFIX, editPath(params.packageName, editId))}/bundles`
|
|
1369
|
+
stderr(` 发起地址: POST ${uploadPath}?uploadType=resumable`)
|
|
1370
|
+
stderr(` uploadType: resumable`)
|
|
1371
|
+
stderr(` Content-Type: application/octet-stream`)
|
|
1372
|
+
stderr(` 字节数: ${body.length}`)
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
let bundle
|
|
1376
|
+
try {
|
|
1377
|
+
bundle = await uploadBundle({
|
|
1378
|
+
token,
|
|
1379
|
+
apiBase,
|
|
1380
|
+
packageName: params.packageName,
|
|
1381
|
+
editId,
|
|
1382
|
+
body,
|
|
1383
|
+
})
|
|
1384
|
+
} catch (error) {
|
|
1385
|
+
return fail(`上传 .aab 失败: ${error.message}`)
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
const versionCode = normalizeVersionCode(bundle?.versionCode)
|
|
1389
|
+
if (verbose) {
|
|
1390
|
+
stderr('已上传 .aab')
|
|
1391
|
+
stderr(` versionCode: ${versionCode ?? '(响应未返回)'}`)
|
|
1392
|
+
if (bundle?.sha256 !== undefined) {
|
|
1393
|
+
stderr(` sha256: ${bundle.sha256}`)
|
|
1394
|
+
}
|
|
1395
|
+
if (bundle?.size !== undefined) {
|
|
1396
|
+
stderr(` 服务器大小: ${bundle.size} 字节`)
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
if (params.track !== undefined) {
|
|
1401
|
+
if (versionCode === undefined) {
|
|
1402
|
+
return fail(`上传 .aab 失败: 响应中未包含 versionCode,无法更新轨道 "${params.track}"`)
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
const release = {
|
|
1406
|
+
status: params.releaseStatus,
|
|
1407
|
+
versionCodes: [versionCode],
|
|
1408
|
+
}
|
|
1409
|
+
if (params.releaseName !== undefined) {
|
|
1410
|
+
release.name = params.releaseName
|
|
1411
|
+
}
|
|
1412
|
+
if (params.releaseNotes !== undefined) {
|
|
1413
|
+
release.releaseNotes = [
|
|
1414
|
+
{ language: params.releaseNotesLanguage, text: params.releaseNotes },
|
|
1415
|
+
]
|
|
1416
|
+
}
|
|
1417
|
+
if (params.userFraction !== undefined) {
|
|
1418
|
+
release.userFraction = params.userFraction
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
if (verbose) {
|
|
1422
|
+
stderr('开始更新轨道')
|
|
1423
|
+
const trackPath = `${apiUrl(apiBase, API_PATH_PREFIX, editPath(params.packageName, editId))}/tracks/${encodeURIComponent(params.track)}`
|
|
1424
|
+
stderr(` 请求地址: PUT ${trackPath}`)
|
|
1425
|
+
stderr(` 请求体: ${JSON.stringify({ track: params.track, releases: [release] })}`)
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
try {
|
|
1429
|
+
const track = await callAndroidPublisher({
|
|
1430
|
+
token,
|
|
1431
|
+
apiBase,
|
|
1432
|
+
method: 'PUT',
|
|
1433
|
+
path: `${editPath(params.packageName, editId)}/tracks/${encodeURIComponent(params.track)}`,
|
|
1434
|
+
body: { track: params.track, releases: [release] },
|
|
1435
|
+
})
|
|
1436
|
+
if (verbose) {
|
|
1437
|
+
stderr('已更新轨道')
|
|
1438
|
+
stderr(` track: ${track?.track ?? params.track}`)
|
|
1439
|
+
const codes = track?.releases?.[0]?.versionCodes
|
|
1440
|
+
if (Array.isArray(codes)) {
|
|
1441
|
+
stderr(` versionCodes: ${codes.join(', ')}`)
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
} catch (error) {
|
|
1445
|
+
return fail(`更新轨道 "${params.track}" 失败: ${error.message}`)
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
const commitQuery = changesNotSentForReview ? '?changesNotSentForReview=true' : ''
|
|
1450
|
+
if (verbose) {
|
|
1451
|
+
stderr('开始提交 edit')
|
|
1452
|
+
const commitPath = `${apiUrl(apiBase, API_PATH_PREFIX, editPath(params.packageName, editId))}:commit`
|
|
1453
|
+
stderr(` 请求地址: POST ${commitPath}${commitQuery}`)
|
|
1454
|
+
if (changesNotSentForReview) {
|
|
1455
|
+
stderr(' changesNotSentForReview: true(变更留在控制台,待人工送审)')
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
try {
|
|
1460
|
+
await callAndroidPublisher({
|
|
1461
|
+
token,
|
|
1462
|
+
apiBase,
|
|
1463
|
+
method: 'POST',
|
|
1464
|
+
// commit 的请求体必须为空(Google 文档),只通过查询参数控制行为
|
|
1465
|
+
path: `${editPath(params.packageName, editId)}:commit${commitQuery}`,
|
|
1466
|
+
})
|
|
1467
|
+
} catch (error) {
|
|
1468
|
+
if (!changesNotSentForReview && /changesNotSentForReview/i.test(error.message)) {
|
|
1469
|
+
stderr(
|
|
1470
|
+
`提示: 该应用可能启用了托管发布(managed publishing);可加 ${OPTION_CHANGES_NOT_SENT_FOR_REVIEW} ` +
|
|
1471
|
+
'重试,变更会留在 Play 控制台等待人工送审',
|
|
1472
|
+
)
|
|
1473
|
+
}
|
|
1474
|
+
return fail(`提交 edit 失败: ${error.message}`)
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
if (verbose) {
|
|
1478
|
+
stderr('已提交 edit,修改已生效')
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// stdout 只写结果:versionCode(字符串)是后续查询/发布要用的值。
|
|
1482
|
+
// 理论上 Bundle 一定返回 versionCode;真缺了就退回整个上传响应 JSON,避免输出空行。
|
|
1483
|
+
stdout(versionCode === undefined ? JSON.stringify(bundle) : versionCode)
|
|
1484
|
+
return EXIT_OK
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// --------------------------------------------------------- google access-token
|
|
1488
|
+
|
|
1489
|
+
/** google access-token:用服务账号 JSON 换取 access_token */
|
|
1490
|
+
async function runGoogleAccessToken(argv, env) {
|
|
1491
|
+
if (argv.some((token) => isHelpToken(token))) {
|
|
1492
|
+
stdout(renderGoogleAccessTokenHelp())
|
|
1493
|
+
return EXIT_OK
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
const options = parseOptions(
|
|
1497
|
+
argv,
|
|
1498
|
+
ACCESS_TOKEN_OPTIONS,
|
|
1499
|
+
renderGoogleAccessTokenHelp,
|
|
1500
|
+
ACCESS_TOKEN_FLAGS,
|
|
1501
|
+
)
|
|
1502
|
+
const verbose = resolveVerbose(options, env, renderGoogleAccessTokenHelp)
|
|
1503
|
+
|
|
1504
|
+
if (!hasServiceAccountOptions(options) && !hasEnvServiceAccount(env)) {
|
|
1505
|
+
throw new UsageError(
|
|
1506
|
+
`缺少必填参数: ${serviceAccountMissingHint()}`,
|
|
1507
|
+
renderGoogleAccessTokenHelp(),
|
|
1508
|
+
)
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
const result = await exchangeAccessToken({
|
|
1512
|
+
options,
|
|
1513
|
+
env,
|
|
1514
|
+
help: renderGoogleAccessTokenHelp,
|
|
1515
|
+
verbose,
|
|
1516
|
+
})
|
|
1517
|
+
if (result === null) {
|
|
1518
|
+
return EXIT_FAILURE
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
stdout(result.token)
|
|
1522
|
+
return EXIT_OK
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// ----------------------------------------------------------- google upload-aab
|
|
1526
|
+
|
|
1527
|
+
/** google upload-aab:上传 .aab,必要时发布到指定轨道 */
|
|
1528
|
+
async function runGoogleUploadAab(argv, env) {
|
|
1529
|
+
if (argv.some((token) => isHelpToken(token))) {
|
|
1530
|
+
stdout(renderGoogleUploadAabHelp())
|
|
1531
|
+
return EXIT_OK
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
const options = parseOptions(
|
|
1535
|
+
argv,
|
|
1536
|
+
UPLOAD_AAB_OPTIONS,
|
|
1537
|
+
renderGoogleUploadAabHelp,
|
|
1538
|
+
UPLOAD_AAB_FLAGS,
|
|
1539
|
+
)
|
|
1540
|
+
const verbose = resolveVerbose(options, env, renderGoogleUploadAabHelp)
|
|
1541
|
+
const changesNotSentForReview = resolveSwitch(
|
|
1542
|
+
options,
|
|
1543
|
+
env,
|
|
1544
|
+
OPTION_CHANGES_NOT_SENT_FOR_REVIEW,
|
|
1545
|
+
ENV_CHANGES_NOT_SENT_FOR_REVIEW,
|
|
1546
|
+
renderGoogleUploadAabHelp,
|
|
1547
|
+
)
|
|
1548
|
+
|
|
1549
|
+
const params = collectUploadAabParams(options, env)
|
|
1550
|
+
const plan = planAccessToken(options, env)
|
|
1551
|
+
if (plan.missing !== undefined) {
|
|
1552
|
+
params.missing.push(...plan.missing)
|
|
1553
|
+
}
|
|
1554
|
+
if (params.missing.length > 0) {
|
|
1555
|
+
throw new UsageError(
|
|
1556
|
+
`缺少必填参数: ${params.missing.join(';')}`,
|
|
1557
|
+
renderGoogleUploadAabHelp(),
|
|
1558
|
+
)
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
// 本地参数一次校验完再联网:包名、后缀、文件、轨道与发布参数都给了才继续
|
|
1562
|
+
const validated = validateUploadAabParams(params, renderGoogleUploadAabHelp)
|
|
1563
|
+
|
|
1564
|
+
const apiBase = (readEnv(env, ENV_API_BASE) ?? API_BASE).replace(/\/+$/, '')
|
|
1565
|
+
const auth =
|
|
1566
|
+
plan.kind === 'token'
|
|
1567
|
+
? plan
|
|
1568
|
+
: await exchangeAccessToken({
|
|
1569
|
+
options,
|
|
1570
|
+
env,
|
|
1571
|
+
help: renderGoogleUploadAabHelp,
|
|
1572
|
+
verbose,
|
|
1573
|
+
})
|
|
1574
|
+
if (auth === null) {
|
|
1575
|
+
return EXIT_FAILURE
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
return uploadAabAndCommit({
|
|
1579
|
+
apiBase,
|
|
1580
|
+
token: auth.token,
|
|
1581
|
+
// source 用「来源判定」阶段的描述:换取的来源在 plan 里已经确定,与 auth 内容无关
|
|
1582
|
+
plan: { token: auth.token, source: plan.source },
|
|
1583
|
+
params: validated,
|
|
1584
|
+
changesNotSentForReview,
|
|
1585
|
+
verbose,
|
|
1586
|
+
})
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// ------------------------------------------------------------------ 帮助文档
|
|
1590
|
+
|
|
1591
|
+
/** Google 原始文档:接入准备(创建项目、启用 API、服务账号授权) */
|
|
1592
|
+
const GETTING_STARTED_DOC_URL = 'https://developers.google.com/android-publisher/getting_started?hl=zh-cn'
|
|
1593
|
+
/** Google 原始文档:服务账号 OAuth 2.0(JWT Bearer 换取 access_token) */
|
|
1594
|
+
const SERVICE_ACCOUNT_DOC_URL =
|
|
1595
|
+
'https://developers.google.com/identity/protocols/oauth2/service-account'
|
|
1596
|
+
/** Google 原始文档:Edits(创建、提交、删除 edit) */
|
|
1597
|
+
const EDITS_DOC_URL = 'https://developers.google.com/android-publisher/edits?hl=zh-cn'
|
|
1598
|
+
/** Google 原始文档:上传 .aab 接口 */
|
|
1599
|
+
const BUNDLE_UPLOAD_DOC_URL =
|
|
1600
|
+
'https://developers.google.com/android-publisher/api-ref/rest/v3/edits.bundles/upload?hl=zh-cn'
|
|
1601
|
+
/** Google 原始文档:轨道与发布(Track / Release 字段、发布状态) */
|
|
1602
|
+
const TRACKS_DOC_URL = 'https://developers.google.com/android-publisher/tracks?hl=zh-cn'
|
|
1603
|
+
|
|
1604
|
+
/** google 帮助文档:google、google help、google --help */
|
|
1605
|
+
function renderGoogleHelp() {
|
|
1606
|
+
return renderHelp({
|
|
1607
|
+
title: `${PROGRAM} google - Google Play`,
|
|
1608
|
+
usage: [`${PROGRAM} google <命令> [选项]`],
|
|
1609
|
+
sections: [
|
|
1610
|
+
{
|
|
1611
|
+
title: '命令',
|
|
1612
|
+
entries: [
|
|
1613
|
+
['access-token', '用服务账号 JSON 换取 access_token'],
|
|
1614
|
+
['upload-aab', '上传 .aab(可选发布到轨道)'],
|
|
1615
|
+
['help', '显示帮助信息'],
|
|
1616
|
+
],
|
|
1617
|
+
},
|
|
1618
|
+
{
|
|
1619
|
+
title: '选项',
|
|
1620
|
+
entries: [['-h, --help', '显示帮助信息']],
|
|
1621
|
+
},
|
|
1622
|
+
],
|
|
1623
|
+
notes: [
|
|
1624
|
+
'示例:',
|
|
1625
|
+
` ${PROGRAM} google access-token --service-account ./google-play-service-account.json`,
|
|
1626
|
+
` ${PROGRAM} google upload-aab --package-name com.example.app --file ./app-release.aab \\`,
|
|
1627
|
+
` --service-account ./google-play-service-account.json`,
|
|
1628
|
+
` ${PROGRAM} google upload-aab --package-name com.example.app --file ./app-release.aab \\`,
|
|
1629
|
+
` --service-account ./google-play-service-account.json --track internal`,
|
|
1630
|
+
],
|
|
1631
|
+
})
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
/** google access-token 帮助文档:参数缺失时的提示输出、以及 --help */
|
|
1635
|
+
function renderGoogleAccessTokenHelp() {
|
|
1636
|
+
return renderHelp({
|
|
1637
|
+
title: `${PROGRAM} google access-token - 用服务账号换取 access_token`,
|
|
1638
|
+
usage: [`${PROGRAM} google access-token [选项]`],
|
|
1639
|
+
sections: [
|
|
1640
|
+
{
|
|
1641
|
+
title: '选项',
|
|
1642
|
+
entries: [
|
|
1643
|
+
['--service-account <文件>', '服务账号 JSON 密钥文件路径(与 --service-account-data 二选一)'],
|
|
1644
|
+
['--service-account-data <JSON>', '直接传入服务账号 JSON 数据(与 --service-account 二选一)'],
|
|
1645
|
+
['--client-email <邮箱>', '分项参数:服务账号 JSON 的 client_email(与 JSON 参数二选一)'],
|
|
1646
|
+
['--private-key <文件>', '分项参数:私钥 PEM 文件路径(与 --private-key-data 二选一)'],
|
|
1647
|
+
['--private-key-data <PEM>', '分项参数:私钥 PEM 数据(建议走环境变量,避免进入 ps)'],
|
|
1648
|
+
['--token-uri <地址>', `令牌接口地址,默认取 JSON 的 token_uri 或 ${DEFAULT_TOKEN_URI}`],
|
|
1649
|
+
['--verbose', `输出运行日志(默认只输出结果);命令行未给出时读取 ${ENV_VERBOSE}`],
|
|
1650
|
+
['-h, --help', '显示帮助信息'],
|
|
1651
|
+
],
|
|
1652
|
+
},
|
|
1653
|
+
{
|
|
1654
|
+
title: '环境变量',
|
|
1655
|
+
entries: [
|
|
1656
|
+
[ENV_SERVICE_ACCOUNT, '服务账号 JSON 文件路径,等价于 --service-account'],
|
|
1657
|
+
[ENV_SERVICE_ACCOUNT_DATA, '服务账号 JSON 数据,等价于 --service-account-data'],
|
|
1658
|
+
[ENV_CLIENT_EMAIL, 'client_email,等价于 --client-email'],
|
|
1659
|
+
[ENV_PRIVATE_KEY, '私钥 PEM 文件路径,等价于 --private-key'],
|
|
1660
|
+
[ENV_PRIVATE_KEY_DATA, '私钥 PEM 数据,等价于 --private-key-data'],
|
|
1661
|
+
[ENV_TOKEN_URI, '令牌接口地址,等价于 --token-uri'],
|
|
1662
|
+
[ENV_VERBOSE, '输出运行日志,1/true/yes/on 开启、0/false/no/off 关闭,等价于 --verbose'],
|
|
1663
|
+
],
|
|
1664
|
+
},
|
|
1665
|
+
{
|
|
1666
|
+
title: '凭据来源(优先级从高到低)',
|
|
1667
|
+
entries: [
|
|
1668
|
+
['1. 命令行凭据', '--service-account / --service-account-data(完整 JSON),或 --client-email + --private-key / --private-key-data(分项;两者互斥)'],
|
|
1669
|
+
['2. 环境变量 JSON', `${ENV_SERVICE_ACCOUNT} / ${ENV_SERVICE_ACCOUNT_DATA}`],
|
|
1670
|
+
['3. 环境变量分项', `${ENV_CLIENT_EMAIL} + ${ENV_PRIVATE_KEY} / ${ENV_PRIVATE_KEY_DATA}`],
|
|
1671
|
+
],
|
|
1672
|
+
},
|
|
1673
|
+
{
|
|
1674
|
+
title: '鉴权流程',
|
|
1675
|
+
entries: [
|
|
1676
|
+
['1. 本地签 JWT', '用服务账号私钥签发 RS256 JWT:iss=client_email、scope=androidpublisher、aud=token_uri、exp=iat+3600'],
|
|
1677
|
+
['2. 换取令牌', `POST token_uri,grant_type=${JWT_BEARER_GRANT_TYPE}`],
|
|
1678
|
+
['3. 输出', '把响应里的 access_token 写到 stdout'],
|
|
1679
|
+
],
|
|
1680
|
+
},
|
|
1681
|
+
{
|
|
1682
|
+
title: '输出',
|
|
1683
|
+
entries: [
|
|
1684
|
+
['stdout', 'access_token(可直接作为 Authorization: Bearer 令牌使用)'],
|
|
1685
|
+
['stderr', '--verbose 时的运行日志(Token、client_email 只打印掩码);默认不打印'],
|
|
1686
|
+
],
|
|
1687
|
+
},
|
|
1688
|
+
{
|
|
1689
|
+
title: '退出代码',
|
|
1690
|
+
entries: [
|
|
1691
|
+
['0', '成功'],
|
|
1692
|
+
['1', '参数缺失、参数错误或调用失败'],
|
|
1693
|
+
],
|
|
1694
|
+
},
|
|
1695
|
+
],
|
|
1696
|
+
notes: [
|
|
1697
|
+
'示例:',
|
|
1698
|
+
` ${PROGRAM} google access-token --service-account ./google-play-service-account.json`,
|
|
1699
|
+
` ${PROGRAM} google access-token --service-account-data "$(cat service-account.json)" --verbose`,
|
|
1700
|
+
' # 把 JSON 里的属性拆开传:client_email + 私钥 PEM',
|
|
1701
|
+
` ${PROGRAM} google access-token --client-email publisher@project.iam.gserviceaccount.com \\`,
|
|
1702
|
+
` --private-key ./service-account.pem`,
|
|
1703
|
+
` ${ENV_CLIENT_EMAIL}=publisher@project.iam.gserviceaccount.com \\`,
|
|
1704
|
+
` ${ENV_PRIVATE_KEY_DATA}="$(cat service-account.pem)" ${PROGRAM} google access-token`,
|
|
1705
|
+
` ${ENV_SERVICE_ACCOUNT}=./google-play-service-account.json ${PROGRAM} google access-token`,
|
|
1706
|
+
'',
|
|
1707
|
+
`说明: 分项参数对应 JSON 字段:client_email → ${OPTION_CLIENT_EMAIL},private_key → ${OPTION_PRIVATE_KEY_DATA}`,
|
|
1708
|
+
`说明: project_id、private_key_id 不参与 RS256 JWT 鉴权,不需要传递`,
|
|
1709
|
+
`说明: 私钥数据建议走环境变量 ${ENV_PRIVATE_KEY_DATA};命令行参数会出现在 ps 中`,
|
|
1710
|
+
`说明: access_token 有效期约 1 小时,CI 里每次发布重新换取即可,不需要缓存`,
|
|
1711
|
+
`说明: 服务账号 JSON / 私钥数据不会写入日志与错误信息;文件路径在日志里掩码显示(如 /Use*****.json)`,
|
|
1712
|
+
'说明: 只支持服务账号 JSON 密钥(type = service_account)或它的分项字段;Workload Identity Federation / ADC 凭据暂不支持',
|
|
1713
|
+
`说明: 命令行同时给出 ${OPTION_SERVICE_ACCOUNT} 与 ${OPTION_SERVICE_ACCOUNT_DATA} 会报错;` +
|
|
1714
|
+
`环境变量层两者都在时优先用 ${ENV_SERVICE_ACCOUNT_DATA} 并提示`,
|
|
1715
|
+
'说明: 命令行同时给出 JSON 参数与分项参数也会报错;环境变量层两者都在时优先用完整 JSON 并提示',
|
|
1716
|
+
`说明: 服务账号需在 Google Play 控制台「用户和权限」中被授予发布权限,否则换取令牌成功但业务接口返回 403`,
|
|
1717
|
+
`原始文档: ${SERVICE_ACCOUNT_DOC_URL}`,
|
|
1718
|
+
`原始文档: ${GETTING_STARTED_DOC_URL}`,
|
|
1719
|
+
],
|
|
1720
|
+
})
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/** google upload-aab 帮助文档:参数缺失时的提示输出、以及 --help */
|
|
1724
|
+
function renderGoogleUploadAabHelp() {
|
|
1725
|
+
return renderHelp({
|
|
1726
|
+
title: `${PROGRAM} google upload-aab - 上传 .aab 到 Google Play`,
|
|
1727
|
+
usage: [`${PROGRAM} google upload-aab [选项]`],
|
|
1728
|
+
sections: [
|
|
1729
|
+
{
|
|
1730
|
+
title: '选项',
|
|
1731
|
+
entries: [
|
|
1732
|
+
['--package-name <包名>', 'Google Play 应用包名(如 com.example.app),必填'],
|
|
1733
|
+
['--file <文件>', '.aab 文件路径(Android App Bundle),必填'],
|
|
1734
|
+
['--service-account <文件>', '服务账号 JSON 密钥文件路径(与 --service-account-data、分项参数互斥;未给 --token 时需要)'],
|
|
1735
|
+
['--service-account-data <JSON>', '直接传入服务账号 JSON 数据(与 --service-account、分项参数互斥)'],
|
|
1736
|
+
['--client-email <邮箱>', '分项参数:服务账号 JSON 的 client_email(与 JSON 参数二选一)'],
|
|
1737
|
+
['--private-key <文件>', '分项参数:私钥 PEM 文件路径(与 --private-key-data 二选一)'],
|
|
1738
|
+
['--private-key-data <PEM>', '分项参数:私钥 PEM 数据(建议走环境变量,避免进入 ps)'],
|
|
1739
|
+
['--token-uri <地址>', `令牌接口地址,默认取 JSON 的 token_uri 或 ${DEFAULT_TOKEN_URI}`],
|
|
1740
|
+
['--token <access_token>', '已换取的 access_token;未给出服务账号凭据时使用'],
|
|
1741
|
+
['--track <轨道>', '发布到哪个轨道(production / beta / alpha / internal / 自定义轨道名);不给则只上传 bundle'],
|
|
1742
|
+
['--release-status <状态>', `--track 时的发布状态:completed(默认)/ draft / inProgress / halted`],
|
|
1743
|
+
['--release-name <名称>', '--track 时的发布名称(可选)'],
|
|
1744
|
+
['--release-notes <文本>', '--track 时的发布说明(可选;较长时可用 "$(cat notes.txt)")'],
|
|
1745
|
+
['--release-notes-language <语言>', '发布说明语言(BCP-47,默认 zh-CN,如 en-US)'],
|
|
1746
|
+
['--user-fraction <比例>', `分阶段发布比例,0 与 1 之间的小数;仅 --release-status 为 inProgress / halted 时使用`],
|
|
1747
|
+
['--changes-not-sent-for-review', '提交时设置 changesNotSentForReview=true:变更留在 Play 控制台,需人工送审(托管发布)'],
|
|
1748
|
+
['--verbose', `输出运行日志(默认只输出 versionCode);命令行未给出时读取 ${ENV_VERBOSE}`],
|
|
1749
|
+
['-h, --help', '显示帮助信息'],
|
|
1750
|
+
],
|
|
1751
|
+
},
|
|
1752
|
+
{
|
|
1753
|
+
title: '环境变量',
|
|
1754
|
+
entries: [
|
|
1755
|
+
[ENV_PACKAGE_NAME, '应用包名,等价于 --package-name'],
|
|
1756
|
+
[ENV_FILE, '.aab 文件路径,等价于 --file'],
|
|
1757
|
+
[ENV_SERVICE_ACCOUNT, '服务账号 JSON 文件路径,等价于 --service-account'],
|
|
1758
|
+
[ENV_SERVICE_ACCOUNT_DATA, '服务账号 JSON 数据,等价于 --service-account-data'],
|
|
1759
|
+
[ENV_CLIENT_EMAIL, 'client_email,等价于 --client-email'],
|
|
1760
|
+
[ENV_PRIVATE_KEY, '私钥 PEM 文件路径,等价于 --private-key'],
|
|
1761
|
+
[ENV_PRIVATE_KEY_DATA, '私钥 PEM 数据,等价于 --private-key-data'],
|
|
1762
|
+
[ENV_TOKEN, 'access_token,等价于 --token'],
|
|
1763
|
+
[ENV_TRACK, '轨道名,等价于 --track'],
|
|
1764
|
+
[ENV_RELEASE_STATUS, '发布状态,等价于 --release-status'],
|
|
1765
|
+
[ENV_RELEASE_NAME, '发布名称,等价于 --release-name'],
|
|
1766
|
+
[ENV_RELEASE_NOTES, '发布说明,等价于 --release-notes'],
|
|
1767
|
+
[ENV_RELEASE_NOTES_LANGUAGE, '发布说明语言,等价于 --release-notes-language'],
|
|
1768
|
+
[ENV_USER_FRACTION, '分阶段发布比例,等价于 --user-fraction'],
|
|
1769
|
+
[ENV_CHANGES_NOT_SENT_FOR_REVIEW, '提交时使用 changesNotSentForReview=true,1/true/yes/on 开启、0/false/no/off 关闭'],
|
|
1770
|
+
[ENV_VERBOSE, '输出运行日志,1/true/yes/on 开启、0/false/no/off 关闭,等价于 --verbose'],
|
|
1771
|
+
[ENV_TOKEN_URI, '令牌接口地址,等价于 --token-uri'],
|
|
1772
|
+
[ENV_API_BASE, `接口域名,默认 ${API_BASE}(本地联调或代理时覆盖)`],
|
|
1773
|
+
],
|
|
1774
|
+
},
|
|
1775
|
+
{
|
|
1776
|
+
title: 'Token 来源(优先级从高到低)',
|
|
1777
|
+
entries: [
|
|
1778
|
+
['1. 命令行凭据', '--service-account / --service-account-data(完整 JSON),或 --client-email + --private-key / --private-key-data(分项;两者互斥)'],
|
|
1779
|
+
['2. --token', `使用给定的 access_token,也可用环境变量 ${ENV_TOKEN}`],
|
|
1780
|
+
['3. 环境变量 JSON', `以上都没有、但 ${ENV_SERVICE_ACCOUNT} 或 ${ENV_SERVICE_ACCOUNT_DATA} 已设置时,本地换取 access_token`],
|
|
1781
|
+
['4. 环境变量分项', `以上都没有、但 ${ENV_CLIENT_EMAIL} 与 ${ENV_PRIVATE_KEY} / ${ENV_PRIVATE_KEY_DATA} 已设置时,本地换取 access_token`],
|
|
1782
|
+
],
|
|
1783
|
+
},
|
|
1784
|
+
{
|
|
1785
|
+
title: '执行步骤',
|
|
1786
|
+
entries: [
|
|
1787
|
+
['1. 创建 edit', `POST ${API_BASE}${API_PATH_PREFIX}/applications/{packageName}/edits`],
|
|
1788
|
+
['2. 上传 bundle', `POST ${API_BASE}${UPLOAD_PATH_PREFIX}/applications/{packageName}/edits/{editId}/bundles?uploadType=resumable 取会话地址,再 PUT 会话地址上传文件内容`],
|
|
1789
|
+
['3. 更新轨道', `PUT ${API_BASE}${API_PATH_PREFIX}/applications/{packageName}/edits/{editId}/tracks/{track}(仅给了 --track 时)`],
|
|
1790
|
+
['4. 提交 edit', `POST ${API_BASE}${API_PATH_PREFIX}/applications/{packageName}/edits/{editId}:commit`],
|
|
1791
|
+
],
|
|
1792
|
+
},
|
|
1793
|
+
{
|
|
1794
|
+
title: '发布状态(--release-status)',
|
|
1795
|
+
entries: [
|
|
1796
|
+
['completed', '全部用户可更新(默认)'],
|
|
1797
|
+
['draft', '仅保存为草稿,需要人工在控制台发起发布'],
|
|
1798
|
+
['inProgress', `分阶段发布,必须同时给出 --user-fraction(如 0.1 表示 10% 用户)`],
|
|
1799
|
+
['halted', '暂停分阶段发布,可配合 --user-fraction 调整比例'],
|
|
1800
|
+
],
|
|
1801
|
+
},
|
|
1802
|
+
{
|
|
1803
|
+
title: '输出',
|
|
1804
|
+
entries: [
|
|
1805
|
+
['stdout', 'bundle 的 versionCode(十进制字符串);响应异常缺失时退回整个上传响应 JSON'],
|
|
1806
|
+
['stderr', '--verbose 时的运行日志(Token、client_email 只打印掩码);默认不打印'],
|
|
1807
|
+
],
|
|
1808
|
+
},
|
|
1809
|
+
{
|
|
1810
|
+
title: '退出代码',
|
|
1811
|
+
entries: [
|
|
1812
|
+
['0', '成功(edit 已 commit,修改已生效)'],
|
|
1813
|
+
['1', '参数缺失、参数错误或调用失败(未提交的 edit 会被尽力删除)'],
|
|
1814
|
+
],
|
|
1815
|
+
},
|
|
1816
|
+
],
|
|
1817
|
+
notes: [
|
|
1818
|
+
'示例:',
|
|
1819
|
+
` ${PROGRAM} google upload-aab --package-name com.example.app --file ./app-release.aab \\`,
|
|
1820
|
+
` --service-account ./google-play-service-account.json`,
|
|
1821
|
+
' # 上传后发布到内部测试轨道',
|
|
1822
|
+
` ${PROGRAM} google upload-aab --package-name com.example.app --file ./app-release.aab \\`,
|
|
1823
|
+
` --service-account ./google-play-service-account.json --track internal`,
|
|
1824
|
+
' # 把 JSON 属性拆开传(client_email + PEM 私钥)',
|
|
1825
|
+
` ${PROGRAM} google upload-aab --package-name com.example.app --file ./app-release.aab \\`,
|
|
1826
|
+
` --client-email publisher@project.iam.gserviceaccount.com --private-key ./service-account.pem \\`,
|
|
1827
|
+
` --track internal`,
|
|
1828
|
+
' # 正式轨道 + 发布说明;直接使用已换取的 access_token',
|
|
1829
|
+
` ${PROGRAM} google upload-aab --package-name com.example.app --file ./app-release.aab \\`,
|
|
1830
|
+
` --token <access_token> --track production --release-notes "修复若干问题"`,
|
|
1831
|
+
' # 10% 分阶段发布',
|
|
1832
|
+
` ${PROGRAM} google upload-aab --package-name com.example.app --file ./app-release.aab \\`,
|
|
1833
|
+
` --token <access_token> --track production --release-status inProgress --user-fraction 0.1`,
|
|
1834
|
+
'',
|
|
1835
|
+
'说明: 应用的第一个版本仍需在 Google Play 控制台手动上传,之后才能用 API 发布',
|
|
1836
|
+
'说明: 服务账号需在 Play 控制台「用户和权限」中被邀请并授予对应轨道/正式版的发布权限',
|
|
1837
|
+
'说明: 只有 commit 之后修改才生效;任一步失败都会尽力删除未提交的 edit(已上传的 bundle 一并丢弃)',
|
|
1838
|
+
'说明: 不给 --track 时只把 .aab 上传到应用的 bundle 库并 commit,不会进入任何轨道',
|
|
1839
|
+
'说明: 上传用 resumable 协议(先 POST 取会话地址,再 PUT 文件内容),适配大文件',
|
|
1840
|
+
`说明: ${OPTION_USER_FRACTION} 只用于分阶段发布;inProgress 必须给出,且取值必须大于 0 小于 1`,
|
|
1841
|
+
`说明: 提交返回 changesNotSentForReview 时(例如应用使用托管发布),` +
|
|
1842
|
+
`加 ${OPTION_CHANGES_NOT_SENT_FOR_REVIEW}(或 ${ENV_CHANGES_NOT_SENT_FOR_REVIEW}=1)后变更留在控制台等待人工送审`,
|
|
1843
|
+
'说明: 自定义轨道名直接传入即可(如 wear:production、automotive:production 等外形(form factor)轨道前缀)',
|
|
1844
|
+
'说明: .aab 会一次性读入内存,数 GB 的包需要给 Node 更多内存;上传接口超时为 10 分钟',
|
|
1845
|
+
`说明: 分项参数对应 JSON 字段:client_email → ${OPTION_CLIENT_EMAIL},private_key → ${OPTION_PRIVATE_KEY_DATA}`,
|
|
1846
|
+
`说明: 私钥数据建议走环境变量 ${ENV_PRIVATE_KEY_DATA};命令行参数会出现在 ps 中`,
|
|
1847
|
+
`说明: 命令行同时给出 JSON 参数与分项参数会报错;` +
|
|
1848
|
+
`环境变量层 JSON 与分项都在时优先用 JSON;服务账号 JSON / 私钥数据不会写入日志`,
|
|
1849
|
+
`原始文档: ${EDITS_DOC_URL}`,
|
|
1850
|
+
`原始文档: ${BUNDLE_UPLOAD_DOC_URL}`,
|
|
1851
|
+
`原始文档: ${TRACKS_DOC_URL}`,
|
|
1852
|
+
`原始文档: ${GETTING_STARTED_DOC_URL}`,
|
|
1853
|
+
],
|
|
1854
|
+
})
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// ------------------------------------------------------------------ 命令分发
|
|
1858
|
+
|
|
1859
|
+
/** google 子命令分发 */
|
|
1860
|
+
async function runGoogle(argv, env) {
|
|
1861
|
+
const [command, ...rest] = argv
|
|
1862
|
+
if (command === undefined || isHelpToken(command)) {
|
|
1863
|
+
stdout(renderGoogleHelp())
|
|
1864
|
+
return EXIT_OK
|
|
1865
|
+
}
|
|
1866
|
+
if (command === 'access-token') {
|
|
1867
|
+
return runGoogleAccessToken(rest, env)
|
|
1868
|
+
}
|
|
1869
|
+
if (command === 'upload-aab') {
|
|
1870
|
+
return runGoogleUploadAab(rest, env)
|
|
1871
|
+
}
|
|
1872
|
+
throw new UsageError(`未知命令 "google ${command}"`, renderGoogleHelp())
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
/**
|
|
1876
|
+
* Google Play 命令描述,由 publish-apk.js 注册到厂商表中。
|
|
1877
|
+
*
|
|
1878
|
+
* 命令名与摘要是 Google 自己的信息,因此放在本模块而不是根入口。
|
|
1879
|
+
*/
|
|
1880
|
+
export const googleCommand = {
|
|
1881
|
+
name: 'google',
|
|
1882
|
+
summary: 'Google Play 相关命令',
|
|
1883
|
+
run: runGoogle,
|
|
1884
|
+
}
|