android-publisher-cli 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -4
- package/CONTRIBUTING.md +13 -25
- package/README.md +20 -16
- package/build/cli.js +42 -55
- package/build/credentials.js +12 -29
- package/build/engine.js +56 -8
- package/build/model.js +6 -2
- package/docs/architecture.md +17 -13
- package/docs/channels/protocol-evidence.md +9 -9
- package/docs/getting-started.md +62 -0
- package/docs/npm-package.md +36 -0
- package/docs/npm-release.md +33 -0
- package/docs/release-workflow.md +69 -0
- package/docs/status-and-recovery.md +73 -0
- package/package.json +1 -2
- package/CHANGELOG.md +0 -57
- package/docs/archive/public-beta-acceptance.md +0 -47
- package/docs/channels/automatic-observation.md +0 -37
- package/docs/colleague-quickstart.md +0 -69
- package/docs/daily-cli-workflow.md +0 -101
- package/docs/execution-plan.md +0 -14
- package/docs/mac-cli-delivery.md +0 -36
- package/docs/release-process.md +0 -51
package/AGENTS.md
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
- 开发、测试或提交 PR:先读 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
|
6
6
|
- 修改批次、执行、状态或恢复:先读 [架构](docs/architecture.md)。
|
|
7
|
-
- 修改渠道协议:先读
|
|
8
|
-
- 修改 npm 打包或版本交付:先读[
|
|
7
|
+
- 修改渠道协议:先读[渠道协议](docs/channels/protocol-evidence.md)。修改状态展示时,再读[状态查询与恢复](docs/status-and-recovery.md)。
|
|
8
|
+
- 修改 npm 打包或版本交付:先读[npm 包验收](docs/npm-package.md)和[CLI 版本发布](docs/npm-release.md)。
|
|
9
9
|
|
|
10
10
|
## 必须保持的约束
|
|
11
11
|
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
- `prepare` 和 `batch review` 离线执行。预检、观测和核对只读取发布数据。令牌申请与刷新经过账号状态记录。
|
|
14
14
|
- 分别记录执行、审核、上架和查询健康状态。未关联信息不能确认目标状态,也不能解除未决写入。
|
|
15
15
|
- 真实商店变更需要覆盖应用、产物和渠道范围的授权。初始化、测试、CI、PR 或 npm 打包均不构成提审授权。
|
|
16
|
-
-
|
|
17
|
-
-
|
|
16
|
+
- 渠道凭据经终端交互输入。JSON 和 PEM 材料文件必须属于当前用户,权限为 `0600`。令牌和上传授权只写入本机私有状态。真实凭据、APK、私钥、Cookie、签名 URL 和敏感响应不得进入 Git、issue、agent 提示词、包或普通日志。
|
|
17
|
+
- 本机状态位于 `~/Library/Application Support/AndroidPublisherCLI/v2/`。测试和 CI 使用合成数据。渠道协议独立实现;复用第三方代码前核对许可证。
|
|
18
18
|
|
|
19
19
|
有 `.codegraph/` 索引时,定位代码先用 `codegraph explore`。没有该目录就直接使用仓库搜索工具。
|
|
20
20
|
|
package/CONTRIBUTING.md
CHANGED
|
@@ -1,34 +1,34 @@
|
|
|
1
1
|
# 贡献指南
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
修改 CLI、测试或文档前,先按改动范围读取对应资料。操作者的安装和发布步骤从 [README](README.md) 进入。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 开发与测试
|
|
6
6
|
|
|
7
|
-
安装 [`.node-version`](.node-version) 指定的 Node.js
|
|
7
|
+
安装 [`.node-version`](.node-version) 指定的 Node.js。仓库用 `package-lock.json` 固定 npm 依赖:
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
npm ci --ignore-scripts
|
|
11
11
|
npm run check
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
`npm run check`
|
|
14
|
+
`npm run check` 依次检查 TypeScript 类型、构建 CLI 并运行 Node.js 测试。真实 APK 测试需要 Android SDK Build Tools 和 JDK;本机完整验收时设置:
|
|
15
15
|
|
|
16
16
|
```sh
|
|
17
17
|
PUBLISHER_REQUIRE_ANDROID_TESTS=1 npm run check
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
工具缺失时,完整验收应失败。普通本机测试会明确显示跳过的真实 APK 用例。合成渠道测试使用本地响应,不访问商店或提交应用。从源码试用 CLI 时,运行 `npm run build` 后可执行 `npm link`。
|
|
21
21
|
|
|
22
22
|
## 按改动检查
|
|
23
23
|
|
|
24
24
|
| 改动 | 先读 | 验收重点 |
|
|
25
25
|
| --- | --- | --- |
|
|
26
|
-
| 批次、执行、状态或恢复 | [
|
|
27
|
-
| 渠道请求或观测 | [
|
|
28
|
-
| npm
|
|
29
|
-
| 文档 |
|
|
26
|
+
| 批次、执行、状态或恢复 | [架构说明](docs/architecture.md) | 请求前保存意图;响应丢失后重启不能重复发送 |
|
|
27
|
+
| 渠道请求或观测 | [渠道协议](docs/channels/protocol-evidence.md)、[状态查询与恢复](docs/status-and-recovery.md) | 核对官方字段,覆盖成功、错误和无法关联的结果 |
|
|
28
|
+
| npm 打包与发布 | [npm 包验收](docs/npm-package.md)、[CLI 版本发布](docs/npm-release.md) | 安装实际 tarball,核对文件清单、命令和版本 |
|
|
29
|
+
| 文档 | [README](README.md) 的阅读入口 | 核对命令、条件、链接与验证程度 |
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
渠道协议由本仓库独立实现。复用第三方代码前,先确认许可证。测试、示例和截图使用 `com.example.app`、`example.invalid` 等合成内容。真实账号、APK、私钥、Cookie、签名 URL 和原始响应不能进入 Git、issue、构建产物或普通日志。
|
|
32
32
|
|
|
33
33
|
## 分支与 PR
|
|
34
34
|
|
|
@@ -36,20 +36,8 @@ PUBLISHER_REQUIRE_ANDROID_TESTS=1 npm run check
|
|
|
36
36
|
|
|
37
37
|
合并前,以待合并提交上的必需 CI `check` 为准。解决实质性审查意见后,使用 squash 合并。仓库规则禁止直接推送、强制推送或删除 `main`。PR 合并不授权 Android 应用提审。
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
[check.yml](.github/workflows/check.yml) 运行公开源码扫描、TypeScript 检查和测试。测试覆盖合成协议、真实 APK 签名、子进程崩溃恢复及 npm 安装。CI 只使用临时身份与合成材料。
|
|
40
40
|
|
|
41
|
-
CLI 版本通过 [Prepare npm release](.github/workflows/prepare-npm-release.yml) 创建 PR
|
|
41
|
+
CLI 版本通过 [Prepare npm release](.github/workflows/prepare-npm-release.yml) 创建 PR。维护者合并后发布对应 GitHub Release,触发 [Publish npm package](.github/workflows/publish-npm.yml)。操作顺序见 [CLI 版本发布](docs/npm-release.md)。
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
| 内容 | 主要位置 |
|
|
46
|
-
| --- | --- |
|
|
47
|
-
| 项目用途和阅读入口 | [README](README.md) |
|
|
48
|
-
| 安装、配置和凭据输入 | [首次使用](docs/colleague-quickstart.md) |
|
|
49
|
-
| 每次发布、查询和恢复 | [日常发布](docs/daily-cli-workflow.md) |
|
|
50
|
-
| 查询状态的含义 | [自动查询说明](docs/channels/automatic-observation.md) |
|
|
51
|
-
| 执行约束和本地状态 | [架构](docs/architecture.md) |
|
|
52
|
-
| 接口能力及来源 | [协议参考](docs/channels/protocol-evidence.md) |
|
|
53
|
-
| 打包和版本交付 | [构建与分发](docs/mac-cli-delivery.md)、[版本发布](docs/release-process.md) |
|
|
54
|
-
|
|
55
|
-
写操作步骤时,先列准备材料,再给命令。命令后说明成功的判断依据,以及失败时该做什么。审核、上架和查询健康状态分别描述,不能把“已受理”写成“已上架”。
|
|
43
|
+
写操作步骤时,先列准备材料,再给命令。命令后说明成功的判断依据和失败时的处理。分别描述执行、审核、上架与查询健康状态。
|
package/README.md
CHANGED
|
@@ -1,33 +1,37 @@
|
|
|
1
1
|
# Android 发布工具
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`android-release` 是运行在 Apple Silicon Mac 上的命令行工具。它为华为、荣耀、小米、OPPO、vivo 和应用宝提交已有应用的 Android 版本更新。发布策略固定为审核通过后立即发布。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
先用 `setup` 保存渠道公开信息。`prepare` 会显示 APK 的包名和版本,确认后冻结产物与批次。提交前,你需要审阅批准文件,并明确授权应用、产物和渠道范围。渠道受理提交不表示审核通过或公开上架。
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## 安装
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
安装 [`.node-version`](.node-version) 指定的 Node.js,以及 Android SDK Build Tools 和 JDK。然后安装 npm 包:
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
|
-
npm
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
npm install --global android-publisher-cli
|
|
13
|
+
android-release version --json
|
|
14
|
+
android-release doctor
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
`version` 应显示已安装的版本。`doctor` 的 `ready` 为 `true` 时,表示 CLI 找到了 APK 检查工具。工具缺失时,按[安装与配置](docs/getting-started.md)检查 SDK 和 JDK 路径。
|
|
18
18
|
|
|
19
|
-
##
|
|
19
|
+
## 文档
|
|
20
20
|
|
|
21
|
-
|
|
|
21
|
+
| 你要做的事 | 阅读 |
|
|
22
22
|
| --- | --- |
|
|
23
|
-
|
|
|
24
|
-
|
|
|
25
|
-
|
|
|
23
|
+
| 安装 CLI、配置应用、输入凭据 | [安装与配置](docs/getting-started.md) |
|
|
24
|
+
| 准备批次、预检、审阅并提交 | [发布应用更新](docs/release-workflow.md) |
|
|
25
|
+
| 查询审核与上架状态、处理中断 | [状态查询与恢复](docs/status-and-recovery.md) |
|
|
26
26
|
| 修改代码、测试或提交 PR | [贡献指南](CONTRIBUTING.md) |
|
|
27
|
-
|
|
|
28
|
-
|
|
|
27
|
+
| 理解执行约束和渠道接口 | [架构说明](docs/architecture.md)、[渠道协议](docs/channels/protocol-evidence.md) |
|
|
28
|
+
| 检查 npm 包或发布 CLI 版本 | [npm 包验收](docs/npm-package.md)、[CLI 版本发布](docs/npm-release.md) |
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
## 数据与验证范围
|
|
31
|
+
|
|
32
|
+
CLI 在终端读取渠道凭据。JSON 和 PEM 材料由终端提示读取本机私有文件;文件须属于当前用户,权限为 `0600`。项目、冻结产物、操作日志、短期令牌和上传授权保存在 `~/Library/Application Support/AndroidPublisherCLI/v2/`。
|
|
33
|
+
|
|
34
|
+
六个渠道通过了合成响应测试,真实账号仍需逐一验证。运行测试或安装 npm 包不授予渠道提审权限。真实提交前,先核对应用、APK 和渠道的授权范围。
|
|
31
35
|
|
|
32
36
|
## 许可证
|
|
33
37
|
|
package/build/cli.js
CHANGED
|
@@ -4,8 +4,8 @@ import path from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import readline from 'node:readline/promises';
|
|
6
6
|
import { AndroidTools } from './artifact.js';
|
|
7
|
-
import {
|
|
8
|
-
import { prepare, preflight, publicError, reconcile, report, review, saveProject, submit, refresh } from './engine.js';
|
|
7
|
+
import { PromptCredentials } from './credentials.js';
|
|
8
|
+
import { prepare, previewBatch, preflight, publicError, reconcile, report, review, saveProject, submit, refresh } from './engine.js';
|
|
9
9
|
import { CHANNELS, isChannel, validateBatchInput, validateProject, validAbsolute, validId } from './model.js';
|
|
10
10
|
import { readJSON, writePrivate } from './private-file.js';
|
|
11
11
|
import { Store } from './state.js';
|
|
@@ -45,14 +45,6 @@ function channelsFlag(value) {
|
|
|
45
45
|
throw new Error('unknown or duplicate channel');
|
|
46
46
|
return channels;
|
|
47
47
|
}
|
|
48
|
-
function credentialSource(f) {
|
|
49
|
-
const file = f['credentials-file'];
|
|
50
|
-
if (!file)
|
|
51
|
-
return new PromptCredentials();
|
|
52
|
-
if (typeof file !== 'string' || !validAbsolute(file))
|
|
53
|
-
throw new Error('credentials file must be an absolute path');
|
|
54
|
-
return new FileCredentials(file);
|
|
55
|
-
}
|
|
56
48
|
function output(value) { process.stdout.write(`${JSON.stringify(value)}\n`); }
|
|
57
49
|
function toolFlags(f) {
|
|
58
50
|
const sdk = typeof f['sdk-root'] === 'string' ? f['sdk-root'] : undefined;
|
|
@@ -63,22 +55,20 @@ function help() {
|
|
|
63
55
|
process.stdout.write(`Android Publisher CLI (TypeScript + npm)\n\n` +
|
|
64
56
|
`android-release doctor [--sdk-root DIR] [--java-home DIR]\n` +
|
|
65
57
|
`android-release inspect --apk /absolute/file.apk\n` +
|
|
66
|
-
`android-release setup --project ID --
|
|
67
|
-
`android-release setup --file /absolute/project.json --apk /absolute/trusted.apk [--replace]\n` +
|
|
58
|
+
`android-release setup --project ID [--replace]\n` +
|
|
68
59
|
`android-release prepare --file /absolute/batch.json\n` +
|
|
69
|
-
`android-release batch preflight --batch ID [--channels LIST]
|
|
60
|
+
`android-release batch preflight --batch ID [--channels LIST]\n` +
|
|
70
61
|
`android-release batch review --batch ID --file /absolute/approval.json [--channels LIST]\n` +
|
|
71
|
-
`android-release batch submit|resume --batch ID --approval FILE --approve-plan SHA256
|
|
72
|
-
`android-release batch status --batch ID [--refresh]
|
|
73
|
-
`android-release batch reconcile --batch ID
|
|
62
|
+
`android-release batch submit|resume --batch ID --approval FILE --approve-plan SHA256\n` +
|
|
63
|
+
`android-release batch status --batch ID [--refresh]\n` +
|
|
64
|
+
`android-release batch reconcile --batch ID\n` +
|
|
74
65
|
`android-release batch report --batch ID --file /absolute/report.md\n` +
|
|
75
66
|
`android-release history | protocols | version [--json]\n\n` +
|
|
76
|
-
`Credentials are requested in the terminal
|
|
67
|
+
`Credentials are requested in the terminal. Multiline JSON and PEM inputs use private files.\n`);
|
|
77
68
|
}
|
|
78
|
-
async function setupWizard(projectId
|
|
79
|
-
if (!validId(projectId)
|
|
80
|
-
throw new Error('project ID
|
|
81
|
-
const inspected = tools.inspect(apk);
|
|
69
|
+
async function setupWizard(projectId) {
|
|
70
|
+
if (!validId(projectId))
|
|
71
|
+
throw new Error('project ID is invalid');
|
|
82
72
|
const io = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
83
73
|
const ask = async (label, fallback = '') => {
|
|
84
74
|
const typed = (await io.question(`${label}${fallback ? ` [${fallback}]` : ''}:`)).trim();
|
|
@@ -87,15 +77,12 @@ async function setupWizard(projectId, apk, tools) {
|
|
|
87
77
|
return typed || fallback;
|
|
88
78
|
};
|
|
89
79
|
try {
|
|
90
|
-
process.stderr.write(`包名:${inspected.package_name}\n签名 SHA-256:${inspected.signer_sha256}\n`);
|
|
91
|
-
if ((await ask('确认信任这个包名和签名,输入 yes')) !== 'yes')
|
|
92
|
-
throw new Error('setup canceled');
|
|
93
80
|
const selection = channelsFlag(await ask('选择渠道,逗号分隔', CHANNELS.join(',')));
|
|
94
81
|
const rows = [];
|
|
95
82
|
for (const channel of selection) {
|
|
96
83
|
process.stderr.write(`${channel}:只输入公开账号和应用标识。凭据在执行命令时另行输入。\n`);
|
|
97
84
|
const account = await ask(channel === 'oppo' ? 'OPPO client_id(也作为本机账号名)' : '本机账号名', channel === 'oppo' ? '' : `${channel}-main`);
|
|
98
|
-
const appId = await ask('渠道应用 ID;小米、OPPO、vivo
|
|
85
|
+
const appId = await ask('渠道应用 ID;小米、OPPO、vivo 填包名');
|
|
99
86
|
const row = { channel, account, app_id: appId };
|
|
100
87
|
const clientLabels = { honor: 'Honor client_id', xiaomi: '小米 userName',
|
|
101
88
|
vivo: 'vivo access_key', tencent: '应用宝用户 uid' };
|
|
@@ -112,8 +99,7 @@ async function setupWizard(projectId, apk, tools) {
|
|
|
112
99
|
}
|
|
113
100
|
rows.push(row);
|
|
114
101
|
}
|
|
115
|
-
const project = { schema_version: 2, project_id: projectId,
|
|
116
|
-
signer_sha256: inspected.signer_sha256, channels: rows };
|
|
102
|
+
const project = { schema_version: 2, project_id: projectId, channels: rows };
|
|
117
103
|
validateProject(project);
|
|
118
104
|
process.stderr.write(`项目摘要:${JSON.stringify(project)}\n`);
|
|
119
105
|
if ((await ask('确认保存,输入 yes')) !== 'yes')
|
|
@@ -160,23 +146,11 @@ async function run(args) {
|
|
|
160
146
|
return 0;
|
|
161
147
|
}
|
|
162
148
|
if (command === 'setup') {
|
|
163
|
-
const f = flags(args.slice(1), ['
|
|
164
|
-
const
|
|
165
|
-
if (Boolean(f.file) === Boolean(f.project))
|
|
166
|
-
throw new Error('choose --file or --project');
|
|
167
|
-
const tools = toolFlags(f);
|
|
168
|
-
let project;
|
|
169
|
-
if (f.file) {
|
|
170
|
-
project = validateProject(readJSON(requiredFlag(f, 'file'), 64 * 1024));
|
|
171
|
-
const trusted = tools.inspect(apk);
|
|
172
|
-
if (project.package_name !== trusted.package_name || project.signer_sha256 !== trusted.signer_sha256)
|
|
173
|
-
throw new Error('project file differs from trusted APK');
|
|
174
|
-
}
|
|
175
|
-
else
|
|
176
|
-
project = await setupWizard(requiredFlag(f, 'project'), apk, tools);
|
|
149
|
+
const f = flags(args.slice(1), ['project'], ['replace']);
|
|
150
|
+
const project = await setupWizard(requiredFlag(f, 'project'));
|
|
177
151
|
saveProject(project, undefined, Boolean(f.replace));
|
|
178
|
-
output({ schema_version: 2, project_id: project.project_id,
|
|
179
|
-
|
|
152
|
+
output({ schema_version: 2, project_id: project.project_id,
|
|
153
|
+
channels: project.channels.map(row => row.channel), saved: true });
|
|
180
154
|
return 0;
|
|
181
155
|
}
|
|
182
156
|
if (command === 'prepare') {
|
|
@@ -184,7 +158,20 @@ async function run(args) {
|
|
|
184
158
|
const input = validateBatchInput(readJSON(requiredFlag(f, 'file'), 64 * 1024));
|
|
185
159
|
const store = new Store();
|
|
186
160
|
try {
|
|
187
|
-
const
|
|
161
|
+
const tools = toolFlags(f);
|
|
162
|
+
const preview = previewBatch(input, tools, store);
|
|
163
|
+
process.stderr.write(`包名:${preview.package_name}\n版本名(versionName):${preview.version_name}\n版本代码(versionCode):${preview.version_code}\n`);
|
|
164
|
+
if (!process.stdin.isTTY)
|
|
165
|
+
throw new Error('prepare confirmation requires a terminal');
|
|
166
|
+
const io = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
167
|
+
try {
|
|
168
|
+
if ((await io.question('确认包名、版本名和版本代码,输入 yes 保存批次:')).trim() !== 'yes')
|
|
169
|
+
throw new Error('prepare canceled');
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
io.close();
|
|
173
|
+
}
|
|
174
|
+
const batch = prepare(input, tools, store, preview);
|
|
188
175
|
output({ ...store.summary(batch.batch_id), effect: 'offline' });
|
|
189
176
|
return 0;
|
|
190
177
|
}
|
|
@@ -209,12 +196,12 @@ async function run(args) {
|
|
|
209
196
|
if (!action)
|
|
210
197
|
throw new Error('batch action is required');
|
|
211
198
|
const values = {
|
|
212
|
-
preflight: ['batch', 'channels'
|
|
199
|
+
preflight: ['batch', 'channels'],
|
|
213
200
|
review: ['batch', 'channels', 'file'],
|
|
214
|
-
submit: ['batch', 'approval', 'approve-plan', '
|
|
215
|
-
resume: ['batch', 'approval', 'approve-plan', '
|
|
216
|
-
status: ['batch', 'channels'
|
|
217
|
-
reconcile: ['batch', 'channels'
|
|
201
|
+
submit: ['batch', 'approval', 'approve-plan', 'sdk-root', 'java-home'],
|
|
202
|
+
resume: ['batch', 'approval', 'approve-plan', 'sdk-root', 'java-home'],
|
|
203
|
+
status: ['batch', 'channels'],
|
|
204
|
+
reconcile: ['batch', 'channels'],
|
|
218
205
|
report: ['batch', 'file'],
|
|
219
206
|
};
|
|
220
207
|
if (!values[action])
|
|
@@ -222,12 +209,12 @@ async function run(args) {
|
|
|
222
209
|
const f = flags(args.slice(2), values[action], action === 'status' ? ['refresh'] : []);
|
|
223
210
|
const batchId = requiredFlag(f, 'batch');
|
|
224
211
|
const selection = channelsFlag(f.channels);
|
|
225
|
-
if (action === 'status' && !f.refresh &&
|
|
226
|
-
throw new Error('cached status does not use channels
|
|
212
|
+
if (action === 'status' && !f.refresh && selection?.length)
|
|
213
|
+
throw new Error('cached status does not use channels');
|
|
227
214
|
const store = new Store();
|
|
228
215
|
try {
|
|
229
216
|
if (action === 'preflight') {
|
|
230
|
-
const result = await preflight(store, batchId,
|
|
217
|
+
const result = await preflight(store, batchId, new PromptCredentials(), selection);
|
|
231
218
|
output(result);
|
|
232
219
|
return Object.keys(result.errors).length ? 3 : 0;
|
|
233
220
|
}
|
|
@@ -239,17 +226,17 @@ async function run(args) {
|
|
|
239
226
|
if (action === 'submit' || action === 'resume') {
|
|
240
227
|
if (selection?.length)
|
|
241
228
|
throw new Error('channel selection is bound by the approval file');
|
|
242
|
-
const result = await submit(store, batchId, requiredFlag(f, 'approval'), requiredFlag(f, 'approve-plan'),
|
|
229
|
+
const result = await submit(store, batchId, requiredFlag(f, 'approval'), requiredFlag(f, 'approve-plan'), new PromptCredentials(), toolFlags(f), action === 'resume');
|
|
243
230
|
output(result);
|
|
244
231
|
return Object.keys(result.errors).length ? 3 : 0;
|
|
245
232
|
}
|
|
246
233
|
if (action === 'status') {
|
|
247
|
-
const result = f.refresh ? await refresh(store, batchId,
|
|
234
|
+
const result = f.refresh ? await refresh(store, batchId, new PromptCredentials(), selection) : { ...store.summary(batchId), effect: 'cached' };
|
|
248
235
|
output(result);
|
|
249
236
|
return 'errors' in result && Object.keys(result.errors).length ? 3 : 0;
|
|
250
237
|
}
|
|
251
238
|
if (action === 'reconcile') {
|
|
252
|
-
const result = await reconcile(store, batchId,
|
|
239
|
+
const result = await reconcile(store, batchId, new PromptCredentials(), selection);
|
|
253
240
|
output(result);
|
|
254
241
|
return Object.keys(result.errors).length ? 3 : 0;
|
|
255
242
|
}
|
package/build/credentials.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import tty from 'node:tty';
|
|
3
|
-
import { assertObject, parseStrictJSON } from './model.js';
|
|
4
3
|
import { regularFile } from './private-file.js';
|
|
5
4
|
export const credentialFields = {
|
|
6
5
|
huawei: ['service_account_json'],
|
|
@@ -10,26 +9,6 @@ export const credentialFields = {
|
|
|
10
9
|
vivo: ['access_secret'],
|
|
11
10
|
tencent: ['api_secret'],
|
|
12
11
|
};
|
|
13
|
-
export class FileCredentials {
|
|
14
|
-
document;
|
|
15
|
-
constructor(file) {
|
|
16
|
-
const input = regularFile(file, 256 * 1024, true).toString('utf8');
|
|
17
|
-
const parsed = parseStrictJSON(input);
|
|
18
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
19
|
-
throw new Error('credentials file must be an object');
|
|
20
|
-
this.document = parsed;
|
|
21
|
-
}
|
|
22
|
-
async forChannel(channel) {
|
|
23
|
-
const value = this.document[channel];
|
|
24
|
-
assertObject(value, credentialFields[channel], channel === 'xiaomi' ? ['test_account_json'] : []);
|
|
25
|
-
for (const field of credentialFields[channel])
|
|
26
|
-
if (typeof value[field] !== 'string' || !value[field] || Buffer.byteLength(value[field]) > 64 * 1024 || /[\u0000\u007f]/u.test(value[field]))
|
|
27
|
-
throw new Error(`${channel}: missing credential field ${field}`);
|
|
28
|
-
if (channel === 'xiaomi' && value.test_account_json !== undefined && (typeof value.test_account_json !== 'string' || Buffer.byteLength(value.test_account_json) > 64 * 1024))
|
|
29
|
-
throw new Error('Xiaomi test account input is invalid');
|
|
30
|
-
return value;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
12
|
function askHidden(label) {
|
|
34
13
|
return new Promise((resolve, reject) => {
|
|
35
14
|
let fd;
|
|
@@ -37,7 +16,7 @@ function askHidden(label) {
|
|
|
37
16
|
fd = fs.openSync('/dev/tty', 'r+');
|
|
38
17
|
}
|
|
39
18
|
catch {
|
|
40
|
-
reject(new Error('
|
|
19
|
+
reject(new Error('credential input requires a terminal'));
|
|
41
20
|
return;
|
|
42
21
|
}
|
|
43
22
|
const input = new tty.ReadStream(fd);
|
|
@@ -64,7 +43,7 @@ function askHidden(label) {
|
|
|
64
43
|
resolve(Buffer.from(bytes).toString('utf8'));
|
|
65
44
|
bytes.fill(0);
|
|
66
45
|
};
|
|
67
|
-
output(`${label}
|
|
46
|
+
output(`${label}:`);
|
|
68
47
|
input.setRawMode(true);
|
|
69
48
|
input.resume();
|
|
70
49
|
input.on('data', (chunk) => {
|
|
@@ -99,17 +78,21 @@ export class PromptCredentials {
|
|
|
99
78
|
return cached;
|
|
100
79
|
const credentials = {};
|
|
101
80
|
for (const field of credentialFields[channel]) {
|
|
102
|
-
const
|
|
103
|
-
const
|
|
81
|
+
const fileInput = field === 'service_account_json' || field === 'public_key_pem';
|
|
82
|
+
const typed = await askHidden(`${channel} ${field}${fileInput ? ' 文件绝对路径' : ''}`);
|
|
83
|
+
const value = fileInput ? regularFile(typed, 64 * 1024, true).toString('utf8').trimEnd() : typed;
|
|
104
84
|
if (!value || Buffer.byteLength(value) > 64 * 1024 || /[\u0000\u007f]/u.test(value))
|
|
105
85
|
throw new Error(`${channel}: credential input is invalid`);
|
|
106
86
|
credentials[field] = value;
|
|
107
87
|
}
|
|
108
88
|
if (channel === 'xiaomi') {
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
89
|
+
const file = await askHidden('xiaomi test_account_json 文件绝对路径(可留空)');
|
|
90
|
+
if (file) {
|
|
91
|
+
const value = regularFile(file, 64 * 1024, true).toString('utf8').trimEnd();
|
|
92
|
+
if (!value)
|
|
93
|
+
throw new Error('xiaomi: test account input is empty');
|
|
94
|
+
credentials.test_account_json = value;
|
|
95
|
+
}
|
|
113
96
|
}
|
|
114
97
|
this.cache.set(channel, credentials);
|
|
115
98
|
return credentials;
|
package/build/engine.js
CHANGED
|
@@ -16,7 +16,7 @@ export function saveProject(project, root = dataRoot(), replace = false) {
|
|
|
16
16
|
export function loadProject(id, root = dataRoot()) {
|
|
17
17
|
return validateProject(readJSON(projectPath(root, id), 64 * 1024, true));
|
|
18
18
|
}
|
|
19
|
-
|
|
19
|
+
function batchInputs(input, store) {
|
|
20
20
|
validateBatchInput(input);
|
|
21
21
|
const project = loadProject(input.project_id, store.root);
|
|
22
22
|
const selected = Object.keys(input.apks);
|
|
@@ -25,22 +25,67 @@ export function prepare(input, tools, store) {
|
|
|
25
25
|
const notes = new TextDecoder('utf-8', { fatal: true }).decode(regularFile(input.release_notes_file, 64 * 1024));
|
|
26
26
|
if ([...notes.trim()].length < 5 || [...notes].length > 500 || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(notes))
|
|
27
27
|
throw new Error('release notes must have 5–500 characters');
|
|
28
|
+
return { project, notes };
|
|
29
|
+
}
|
|
30
|
+
function checkPackageAppId(channel, appId, packageName) {
|
|
31
|
+
if (['xiaomi', 'oppo', 'vivo'].includes(channel) && appId !== packageName)
|
|
32
|
+
throw new Error(channel + ': configured app ID differs from APK package');
|
|
33
|
+
}
|
|
34
|
+
export function previewBatch(input, tools, store) {
|
|
35
|
+
const { project } = batchInputs(input, store);
|
|
36
|
+
let packageName = '';
|
|
37
|
+
let versionName = '';
|
|
38
|
+
let versionCode = '';
|
|
39
|
+
let signer = '';
|
|
40
|
+
const apkHashes = {};
|
|
41
|
+
for (const config of project.channels) {
|
|
42
|
+
const apkPath = input.apks[config.channel];
|
|
43
|
+
const before = hashFile(apkPath);
|
|
44
|
+
const inspected = tools.inspect(apkPath);
|
|
45
|
+
const after = hashFile(apkPath);
|
|
46
|
+
if (before.sha256 !== after.sha256 || before.size !== after.size)
|
|
47
|
+
throw new Error('APK changed while being inspected');
|
|
48
|
+
if (!packageName) {
|
|
49
|
+
packageName = inspected.package_name;
|
|
50
|
+
versionName = inspected.version_name;
|
|
51
|
+
versionCode = inspected.version_code;
|
|
52
|
+
signer = inspected.signer_sha256;
|
|
53
|
+
}
|
|
54
|
+
else if (packageName !== inspected.package_name || versionName !== inspected.version_name ||
|
|
55
|
+
versionCode !== inspected.version_code || signer !== inspected.signer_sha256) {
|
|
56
|
+
throw new Error('all APKs must have the same package, version and signer');
|
|
57
|
+
}
|
|
58
|
+
checkPackageAppId(config.channel, config.app_id, inspected.package_name);
|
|
59
|
+
apkHashes[config.channel] = before.sha256;
|
|
60
|
+
}
|
|
61
|
+
return { package_name: packageName, version_name: versionName, version_code: versionCode,
|
|
62
|
+
signer_sha256: signer, apk_sha256: apkHashes };
|
|
63
|
+
}
|
|
64
|
+
export function prepare(input, tools, store, expected) {
|
|
65
|
+
const { project, notes } = batchInputs(input, store);
|
|
28
66
|
const targets = [];
|
|
67
|
+
let packageName = '';
|
|
29
68
|
let versionName = '';
|
|
30
69
|
let versionCode = '';
|
|
70
|
+
let signer = '';
|
|
31
71
|
for (const config of project.channels) {
|
|
32
72
|
const apkPath = input.apks[config.channel];
|
|
33
73
|
if (!apkPath)
|
|
34
74
|
throw new Error(`${config.channel}: APK path missing`);
|
|
35
75
|
const artifact = inspectAndFreeze(tools, apkPath, store.root);
|
|
36
|
-
if (artifact.
|
|
37
|
-
throw new Error(
|
|
38
|
-
if (!
|
|
76
|
+
if (artifact.sha256 !== expected.apk_sha256[config.channel])
|
|
77
|
+
throw new Error('APK changed after confirmation');
|
|
78
|
+
if (!packageName) {
|
|
79
|
+
packageName = artifact.package_name;
|
|
39
80
|
versionName = artifact.version_name;
|
|
40
81
|
versionCode = artifact.version_code;
|
|
82
|
+
signer = artifact.signer_sha256;
|
|
83
|
+
}
|
|
84
|
+
else if (packageName !== artifact.package_name || versionName !== artifact.version_name ||
|
|
85
|
+
versionCode !== artifact.version_code || signer !== artifact.signer_sha256) {
|
|
86
|
+
throw new Error('all APKs must have the same package, version and signer');
|
|
41
87
|
}
|
|
42
|
-
|
|
43
|
-
throw new Error('all APKs must have the same version');
|
|
88
|
+
checkPackageAppId(config.channel, config.app_id, artifact.package_name);
|
|
44
89
|
const target = { channel: config.channel, account: config.account, app_id: config.app_id,
|
|
45
90
|
artifact, ...(config.client_id ? { client_id: config.client_id } : {}),
|
|
46
91
|
...(config.language_id ? { language_id: config.language_id } : {}),
|
|
@@ -56,9 +101,12 @@ export function prepare(input, tools, store) {
|
|
|
56
101
|
}
|
|
57
102
|
targets.push(target);
|
|
58
103
|
}
|
|
104
|
+
if (expected.package_name !== packageName || expected.version_name !== versionName ||
|
|
105
|
+
expected.version_code !== versionCode || expected.signer_sha256 !== signer)
|
|
106
|
+
throw new Error('APK identity changed after confirmation');
|
|
59
107
|
const body = { schema_version: 2, batch_id: input.batch_id, project_id: project.project_id,
|
|
60
|
-
package_name:
|
|
61
|
-
signer_sha256:
|
|
108
|
+
package_name: packageName, version_name: versionName, version_code: versionCode,
|
|
109
|
+
signer_sha256: signer, release_notes: notes, release_policy: 'after_approval', targets };
|
|
62
110
|
const batch = { ...body, digest: digest(body), created_at: new Date().toISOString() };
|
|
63
111
|
store.saveBatch(batch);
|
|
64
112
|
return batch;
|
package/build/model.js
CHANGED
|
@@ -118,9 +118,13 @@ export function canonical(value) {
|
|
|
118
118
|
}
|
|
119
119
|
export const digest = (value) => createHash('sha256').update(typeof value === 'string' ? value : canonical(value)).digest('hex');
|
|
120
120
|
export function validateProject(value) {
|
|
121
|
-
assertObject(value, ['schema_version', 'project_id', '
|
|
122
|
-
if (value.schema_version !== 2 || !validId(value.project_id) || !
|
|
121
|
+
assertObject(value, ['schema_version', 'project_id', 'channels'], ['package_name', 'signer_sha256']);
|
|
122
|
+
if (value.schema_version !== 2 || !validId(value.project_id) || !Array.isArray(value.channels) || value.channels.length < 1 || value.channels.length > CHANNELS.length)
|
|
123
123
|
throw new Error('invalid project identity');
|
|
124
|
+
if (value.package_name !== undefined || value.signer_sha256 !== undefined) {
|
|
125
|
+
if (!validPackage(value.package_name) || !validHash(value.signer_sha256))
|
|
126
|
+
throw new Error('invalid saved project identity');
|
|
127
|
+
}
|
|
124
128
|
const seen = new Set();
|
|
125
129
|
for (const row of value.channels) {
|
|
126
130
|
assertObject(row, ['channel', 'account', 'app_id'], ['client_id', 'language_id', 'icon_path', 'privacy_url']);
|
package/docs/architecture.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 架构说明
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
CLI 使用 Node.js 内置 SQLite 保存批次、操作和观测。修改执行或恢复逻辑前,先核对本页的持久化顺序。操作命令见[发布应用更新](release-workflow.md)。
|
|
4
4
|
|
|
5
5
|
| 代码 | 职责 |
|
|
6
6
|
| --- | --- |
|
|
@@ -10,26 +10,30 @@
|
|
|
10
10
|
| `src/engine.ts` | 准备批次、预检、审阅、执行和查询 |
|
|
11
11
|
| `src/state.ts` | 保存 SQLite 意图、回执、观测、令牌和授权材料 |
|
|
12
12
|
| `src/channels/` | 六个渠道的独立 HTTP 协议实现 |
|
|
13
|
-
| `src/credentials.ts` |
|
|
13
|
+
| `src/credentials.ts` | 终端隐藏输入与私有 JSON、PEM 材料读取 |
|
|
14
14
|
|
|
15
|
-
##
|
|
15
|
+
## 批次与提交
|
|
16
16
|
|
|
17
|
-
`setup`
|
|
17
|
+
`setup` 保存渠道公开账号和应用标识。`prepare` 验签并核对所有 APK 的包名、版本和签名,向操作者显示包名与版本。
|
|
18
18
|
|
|
19
|
-
`
|
|
19
|
+
确认后,`prepare` 将 APK 与小米图标复制到私有目录,再保存不可变批次。确认后若源 APK 变化,批次保存会失败。这两步不访问渠道。
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
`batch preflight` 只读查询远端账号和应用。荣耀、OPPO 的令牌申请属于账号生命周期操作,状态单独保存。`batch review` 将批次、所选渠道、APK 和预检的摘要写入批准文件。`batch submit` 在第一个业务请求前复核批准文件和全部选定产物。
|
|
22
22
|
|
|
23
|
-
`
|
|
23
|
+
每个业务步骤先向 `operations` 表提交 `intent`,再发送一次请求。取得可验证回执后,立即保存 `confirmed_applied`。上传授权的签名 URL 只保存在本机私有 SQLite 材料表,不进入报告。进程中断或响应无法验证时,日志保留 `intent` 或 `unknown`。
|
|
24
24
|
|
|
25
|
-
##
|
|
25
|
+
## 恢复与观测
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
`resume` 跳过已确认步骤,不重发未决步骤。`reconcile` 只读查询可关联的远端证据。当前六个渠道的响应丢失路径尚无足够依据自动解除阻塞;需要维护者核查。同一渠道、账号和应用的未决写入也会阻止新批次的冲突写入。
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
执行、审核、上架和查询健康分别保存。渠道受理提交只确认执行。观测与本次版本无法关联时,信息进入 `channel_context`,不会增加已核实的审核或上架数量。字段的操作含义见[状态查询与恢复](status-and-recovery.md)。
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
## 私有数据
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
状态位于 `~/Library/Application Support/AndroidPublisherCLI/v2/`。SQLite 使用 WAL 和完全同步设置。文件与目录限制为当前用户访问。
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
终端输入的渠道密钥只留在当前进程。华为 JSON、小米公钥证书及可选测试账号 JSON 从本机私有文件读取;文件须属于当前用户,权限为 `0600`。令牌和短期上传授权进入私有状态库,以便同一账号按已记录结果继续使用。
|
|
36
|
+
|
|
37
|
+
## 测试边界
|
|
38
|
+
|
|
39
|
+
`test/recovery.test.mjs` 在 HTTP 请求返回后终止子进程,再检查重启后的意图和重复写入阻塞。`test/android.test.mjs` 构建并验签临时 APK。`test/channels.test.mjs` 用合成响应覆盖六个渠道。这些测试不证明真实账号权限或商店未公开行为。
|