android-publisher-cli 0.2.0-dev → 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 CHANGED
@@ -4,8 +4,8 @@
4
4
 
5
5
  - 开发、测试或提交 PR:先读 [CONTRIBUTING.md](CONTRIBUTING.md)。
6
6
  - 修改批次、执行、状态或恢复:先读 [架构](docs/architecture.md)。
7
- - 修改渠道协议:先读 [协议参考](docs/channels/protocol-evidence.md)。修改状态展示时,再读[自动查询说明](docs/channels/automatic-observation.md)。
8
- - 修改 npm 打包或版本交付:先读[构建与分发](docs/mac-cli-delivery.md)和[版本发布](docs/release-process.md)。
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
- - 凭据默认在终端交互输入。自动化输入文件必须属于当前用户,权限为 `0600`。令牌和上传授权只写入本机私有状态。真实凭据、APK、私钥、Cookie、签名 URL 和敏感响应不得进入 Git、issue、agent 提示词、包或普通日志。
17
- - 新版状态位于 `~/Library/Application Support/AndroidPublisherCLI/v2/`。测试和 CI 使用合成数据。渠道协议独立实现;复用第三方代码前核对许可证。
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
- 本页供修改 CLI、测试和文档的贡献者使用。操作发布命令请读[首次使用](docs/colleague-quickstart.md)和[日常发布](docs/daily-cli-workflow.md)。
3
+ 修改 CLI、测试或文档前,先按改动范围读取对应资料。操作者的安装和发布步骤从 [README](README.md) 进入。
4
4
 
5
- ## 开发环境
5
+ ## 开发与测试
6
6
 
7
- 安装 [`.node-version`](.node-version) 指定的 Node.js。仓库使用 `package-lock.json` 固定 npm 依赖。在仓库根目录运行:
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` 先检查 TypeScript 类型,再构建 CLI 并运行 Node.js 测试。真实 APK 测试需要 Android SDK Build Tools 和 JDK。CI 会安装独立工具,并设置 `PUBLISHER_REQUIRE_ANDROID_TESTS=1`。本机执行完整验收时也设置这个变量:
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
- 工具缺失时,完整验收应失败。普通本机测试会跳过真实 APK 用例;测试结果会明确显示跳过。合成渠道测试使用本地响应,不会访问商店或提交应用。
20
+ 工具缺失时,完整验收应失败。普通本机测试会明确显示跳过的真实 APK 用例。合成渠道测试使用本地响应,不访问商店或提交应用。从源码试用 CLI 时,运行 `npm run build` 后可执行 `npm link`。
21
21
 
22
22
  ## 按改动检查
23
23
 
24
24
  | 改动 | 先读 | 验收重点 |
25
25
  | --- | --- | --- |
26
- | 批次、执行、状态或恢复 | [架构](docs/architecture.md) | 写入前已有持久意图;响应丢失后重启不能重复发送 |
27
- | 渠道请求或观测 | [协议参考](docs/channels/protocol-evidence.md)、[自动查询说明](docs/channels/automatic-observation.md) | 使用准确的官方字段;分别测试成功、错误和无法关联的结果 |
28
- | npm 打包与交付 | [构建与分发](docs/mac-cli-delivery.md) | 用实际 tarball 安装;核对文件清单、命令版本和依赖声明 |
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
- 渠道协议由本仓库独立实现。使用第三方代码前,先确认许可证。测试、示例和截图使用 `com.example.app`、`example.invalid` 等合成内容。真实账号、APK、私钥、Cookie、签名 URL 和原始响应不能进入 Git、issue、构建产物或普通日志。
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
- CI 工作流见 [check.yml](https://github.com/wangyiliangnet/android-publisher-cli/blob/main/.github/workflows/check.yml)。它运行公开源码扫描、TypeScript 检查、合成协议测试、真实 APK 签名测试、子进程崩溃恢复测试,以及 npm 安装检查。CI 只使用临时身份和合成材料。
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。合并后由维护者发布对应 GitHub Release,触发 [Publish npm package](.github/workflows/publish-npm.yml)。配置和核对步骤见[版本发布](docs/release-process.md)。这些工作流不会使用渠道账号或上传 APK。
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
- 这个 CLI 使用 TypeScript 和 npm 构建。在 Apple Silicon Mac 上,它可向华为、荣耀、小米、OPPO、vivo 和应用宝提交已有应用的 Android 版本更新。发布规则固定为“审核通过后立即发布”。
3
+ `android-release` 是运行在 Apple Silicon Mac 上的命令行工具。它为华为、荣耀、小米、OPPO、vivo 和应用宝提交已有应用的 Android 版本更新。发布策略固定为审核通过后立即发布。
4
4
 
5
- 凭据默认在终端交互输入。CLI 不读取 macOS 钥匙串,也不保存用户输入的渠道密钥。发布批次、操作日志、短期令牌和上传授权保存在本机私有目录。真实渠道账号尚未完成新版实现的逐一验证;合成测试通过不代表账号具备发布权限。
5
+ 先用 `setup` 保存渠道公开信息。`prepare` 会显示 APK 的包名和版本,确认后冻结产物与批次。提交前,你需要审阅批准文件,并明确授权应用、产物和渠道范围。渠道受理提交不表示审核通过或公开上架。
6
6
 
7
- ## 开始使用
7
+ ## 安装
8
8
 
9
- 先安装 [`.node-version`](.node-version) 指定的 Node.js,以及 Android SDK Build Tools 和 JDK。安装与项目配置见[首次使用](docs/colleague-quickstart.md)。
9
+ 安装 [`.node-version`](.node-version) 指定的 Node.js,以及 Android SDK Build Tools 和 JDK。然后安装 npm 包:
10
10
 
11
11
  ```sh
12
- npm ci --ignore-scripts
13
- npm run build
14
- node build/cli.js doctor
12
+ npm install --global android-publisher-cli
13
+ android-release version --json
14
+ android-release doctor
15
15
  ```
16
16
 
17
- 运行 `android-release prepare` 后,按[日常发布](docs/daily-cli-workflow.md)完成预检、审阅、提交和查询。**真实提交会上传 APK 并提审。** 请先核对应用、产物和渠道范围的授权。
17
+ `version` 应显示已安装的版本。`doctor` 的 `ready` 为 `true` 时,表示 CLI 找到了 APK 检查工具。工具缺失时,按[安装与配置](docs/getting-started.md)检查 SDK 和 JDK 路径。
18
18
 
19
- ## 阅读入口
19
+ ## 文档
20
20
 
21
- | 任务 | 文档 |
21
+ | 你要做的事 | 阅读 |
22
22
  | --- | --- |
23
- | 安装、配置、输入凭据 | [首次使用](docs/colleague-quickstart.md) |
24
- | 准备批次、提交、查询和恢复 | [日常发布](docs/daily-cli-workflow.md) |
25
- | 理解审核、上架和查询状态 | [自动查询说明](docs/channels/automatic-observation.md) |
23
+ | 安装 CLI、配置应用、输入凭据 | [安装与配置](docs/getting-started.md) |
24
+ | 准备批次、预检、审阅并提交 | [发布应用更新](docs/release-workflow.md) |
25
+ | 查询审核与上架状态、处理中断 | [状态查询与恢复](docs/status-and-recovery.md) |
26
26
  | 修改代码、测试或提交 PR | [贡献指南](CONTRIBUTING.md) |
27
- | 查看状态机和渠道依据 | [架构](docs/architecture.md)、[协议参考](docs/channels/protocol-evidence.md) |
28
- | 打包或发布 npm 产物 | [构建与分发](docs/mac-cli-delivery.md)、[版本发布](docs/release-process.md) |
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
- 0.1.x Go 版的本地状态不会迁入新版。新版数据保存在 `~/Library/Application Support/AndroidPublisherCLI/v2/`。迁移范围见[更新记录](CHANGELOG.md)。
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 { FileCredentials, PromptCredentials } from './credentials.js';
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 --apk /absolute/trusted.apk\n` +
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] [--credentials-file FILE]\n` +
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 [--credentials-file FILE]\n` +
72
- `android-release batch status --batch ID [--refresh] [--credentials-file FILE]\n` +
73
- `android-release batch reconcile --batch ID [--credentials-file FILE]\n` +
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 by default. A 0600 JSON file is supported for automation.\n`);
67
+ `Credentials are requested in the terminal. Multiline JSON and PEM inputs use private files.\n`);
77
68
  }
78
- async function setupWizard(projectId, apk, tools) {
79
- if (!validId(projectId) || !validAbsolute(apk))
80
- throw new Error('project ID or trusted APK path is invalid');
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 使用包名', ['xiaomi', 'oppo', 'vivo'].includes(channel) ? inspected.package_name : '');
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, package_name: inspected.package_name,
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), ['file', 'project', 'apk', 'sdk-root', 'java-home'], ['replace']);
164
- const apk = requiredFlag(f, 'apk');
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, package_name: project.package_name,
179
- signer_sha256: project.signer_sha256, channels: project.channels.map(row => row.channel), saved: true });
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 batch = prepare(input, toolFlags(f), store);
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', 'credentials-file'],
199
+ preflight: ['batch', 'channels'],
213
200
  review: ['batch', 'channels', 'file'],
214
- submit: ['batch', 'approval', 'approve-plan', 'credentials-file', 'sdk-root', 'java-home'],
215
- resume: ['batch', 'approval', 'approve-plan', 'credentials-file', 'sdk-root', 'java-home'],
216
- status: ['batch', 'channels', 'credentials-file'],
217
- reconcile: ['batch', 'channels', 'credentials-file'],
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 && (selection?.length || f['credentials-file']))
226
- throw new Error('cached status does not use channels or credentials');
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, credentialSource(f), selection);
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'), credentialSource(f), toolFlags(f), action === 'resume');
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, credentialSource(f), selection) : { ...store.summary(batchId), effect: 'cached' };
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, credentialSource(f), selection);
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
  }
@@ -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('interactive credentials require a terminal; use --credentials-file for automation'));
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 typed = await askHidden(`${channel} ${field}`);
103
- const value = typed.startsWith('@/') ? regularFile(typed.slice(1), 64 * 1024, true).toString('utf8').trimEnd() : typed;
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 typed = await askHidden('xiaomi test_account_json(可留空)');
110
- if (typed)
111
- credentials.test_account_json = typed.startsWith('@/')
112
- ? regularFile(typed.slice(1), 64 * 1024, true).toString('utf8').trimEnd() : typed;
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
- export function prepare(input, tools, store) {
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.package_name !== project.package_name || artifact.signer_sha256 !== project.signer_sha256)
37
- throw new Error(`${config.channel}: APK package or signer differs from the approved project`);
38
- if (!versionName) {
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
- if (versionName !== artifact.version_name || versionCode !== artifact.version_code)
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: project.package_name, version_name: versionName, version_code: versionCode,
61
- signer_sha256: project.signer_sha256, release_notes: notes, release_policy: 'after_approval', targets };
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', 'package_name', 'signer_sha256', 'channels']);
122
- if (value.schema_version !== 2 || !validId(value.project_id) || !validPackage(value.package_name) || !validHash(value.signer_sha256) || !Array.isArray(value.channels) || value.channels.length < 1 || value.channels.length > CHANNELS.length)
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']);
@@ -1,6 +1,6 @@
1
1
  # 架构说明
2
2
 
3
- 本页供修改执行和状态的维护者使用。CLI 使用 Node.js 内置 SQLite 保存批次、操作和观测。操作者的命令顺序见[日常发布](daily-cli-workflow.md)。
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` | 终端隐藏输入和可选的私有 JSON 文件输入 |
13
+ | `src/credentials.ts` | 终端隐藏输入与私有 JSON、PEM 材料读取 |
14
14
 
15
- ## 发布顺序
15
+ ## 批次与提交
16
16
 
17
- `setup` 用可信 APK 确定包名和签名,并保存公开账号配置。`prepare` 检查每个 APK 的包名、签名和版本。检查通过后,CLI 把 APK 和小米图标复制到私有目录,再保存不可变批次。这两步不访问渠道。
17
+ `setup` 保存渠道公开账号和应用标识。`prepare` 验签并核对所有 APK 的包名、版本和签名,向操作者显示包名与版本。
18
18
 
19
- `batch preflight` 只读查询远端应用。荣耀和 OPPO 的令牌申请属于账号生命周期操作,状态单独保存。`batch review` 把批次摘要、选定渠道、APK 摘要和预检摘要写入批准文件。`batch submit` 在第一个业务请求前复核全部选定产物和批准内容。
19
+ 确认后,`prepare` 将 APK 与小米图标复制到私有目录,再保存不可变批次。确认后若源 APK 变化,批次保存会失败。这两步不访问渠道。
20
20
 
21
- 每个业务步骤先在 `operations` 表提交 `intent`,再发送一次请求。取得可验证回执后,立即把结果改为 `confirmed_applied`。上传授权的签名 URL 只保存在本机私有 SQLite 材料表,不进入报告。若进程中断或响应无法验证,日志保留 `intent` 或 `unknown`。
21
+ `batch preflight` 只读查询远端账号和应用。荣耀、OPPO 的令牌申请属于账号生命周期操作,状态单独保存。`batch review` 将批次、所选渠道、APK 和预检的摘要写入批准文件。`batch submit` 在第一个业务请求前复核批准文件和全部选定产物。
22
22
 
23
- `resume` 跳过已确认的步骤。遇到未决步骤时,它不会重发。`reconcile` 只读查询现有证据。当前六个渠道的响应丢失路径尚无足够依据自动解除阻塞;需要维护者核查。新批次也不能绕过同一渠道、账号和应用的未决写入。
23
+ 每个业务步骤先向 `operations` 表提交 `intent`,再发送一次请求。取得可验证回执后,立即保存 `confirmed_applied`。上传授权的签名 URL 只保存在本机私有 SQLite 材料表,不进入报告。进程中断或响应无法验证时,日志保留 `intent` 或 `unknown`。
24
24
 
25
- ## 状态与本地数据
25
+ ## 恢复与观测
26
26
 
27
- 执行、审核、上架和查询健康分别保存。渠道受理提交只确认执行。观测与本次版本无法关联时,信息进入 `channel_context`,不会增加已核实的审核或上架数量。
27
+ `resume` 跳过已确认步骤,不重发未决步骤。`reconcile` 只读查询可关联的远端证据。当前六个渠道的响应丢失路径尚无足够依据自动解除阻塞;需要维护者核查。同一渠道、账号和应用的未决写入也会阻止新批次的冲突写入。
28
28
 
29
- 新版状态位于 `~/Library/Application Support/AndroidPublisherCLI/v2/`。SQLite 使用 WAL 和完全同步设置。文件与目录限制为当前用户访问。0.1.x Go 版状态没有自动迁移;旧目录仍由用户自行保管。
29
+ 执行、审核、上架和查询健康分别保存。渠道受理提交只确认执行。观测与本次版本无法关联时,信息进入 `channel_context`,不会增加已核实的审核或上架数量。字段的操作含义见[状态查询与恢复](status-and-recovery.md)。
30
30
 
31
- 终端输入的渠道密钥只留在当前进程内。可选的 `--credentials-file` 由用户在本机保存,CLI 要求文件属于当前用户且权限为 `0600`。令牌和短期上传授权进入私有状态库,以便同一账号按已记录结果继续使用。
31
+ ## 私有数据
32
32
 
33
- ## 验收边界
33
+ 状态位于 `~/Library/Application Support/AndroidPublisherCLI/v2/`。SQLite 使用 WAL 和完全同步设置。文件与目录限制为当前用户访问。
34
34
 
35
- `test/recovery.test.mjs` 在 HTTP 请求返回后真实终止子进程,再检查重启后的意图和重复写入阻塞。`test/android.test.mjs` 构建并验签临时 APK。`test/channels.test.mjs` 使用合成响应覆盖六个渠道。测试不能证明真实账号权限或商店未公开行为。
35
+ 终端输入的渠道密钥只留在当前进程。华为 JSON、小米公钥证书及可选测试账号 JSON 从本机私有文件读取;文件须属于当前用户,权限为 `0600`。令牌和短期上传授权进入私有状态库,以便同一账号按已记录结果继续使用。
36
+
37
+ ## 测试边界
38
+
39
+ `test/recovery.test.mjs` 在 HTTP 请求返回后终止子进程,再检查重启后的意图和重复写入阻塞。`test/android.test.mjs` 构建并验签临时 APK。`test/channels.test.mjs` 用合成响应覆盖六个渠道。这些测试不证明真实账号权限或商店未公开行为。