android-publisher-cli 0.0.0-stage → 0.2.0-dev

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/.node-version ADDED
@@ -0,0 +1 @@
1
+ 24.22.0
package/AGENTS.md ADDED
@@ -0,0 +1,21 @@
1
+ # 代码 agent 协作规则
2
+
3
+ 仓库使用 TypeScript、Node.js 和 npm。按改动范围读取资料:
4
+
5
+ - 开发、测试或提交 PR:先读 [CONTRIBUTING.md](CONTRIBUTING.md)。
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)。
9
+
10
+ ## 必须保持的约束
11
+
12
+ - 业务写入前冻结批次和 APK。每个请求发出前保存操作意图。取得可验证回执后立即保存。结果未决时阻止同一渠道、账号和应用的冲突写入。
13
+ - `prepare` 和 `batch review` 离线执行。预检、观测和核对只读取发布数据。令牌申请与刷新经过账号状态记录。
14
+ - 分别记录执行、审核、上架和查询健康状态。未关联信息不能确认目标状态,也不能解除未决写入。
15
+ - 真实商店变更需要覆盖应用、产物和渠道范围的授权。初始化、测试、CI、PR 或 npm 打包均不构成提审授权。
16
+ - 凭据默认在终端交互输入。自动化输入文件必须属于当前用户,权限为 `0600`。令牌和上传授权只写入本机私有状态。真实凭据、APK、私钥、Cookie、签名 URL 和敏感响应不得进入 Git、issue、agent 提示词、包或普通日志。
17
+ - 新版状态位于 `~/Library/Application Support/AndroidPublisherCLI/v2/`。测试和 CI 使用合成数据。渠道协议独立实现;复用第三方代码前核对许可证。
18
+
19
+ 有 `.codegraph/` 索引时,定位代码先用 `codegraph explore`。没有该目录就直接使用仓库搜索工具。
20
+
21
+ 获准使用 subagents 时,限定路径和问题范围。审查者默认只读。协调者负责整合修订,并记录未解决问题。
package/CHANGELOG.md ADDED
@@ -0,0 +1,53 @@
1
+ # 更新记录
2
+
3
+ ## 0.2.0-dev
4
+
5
+ - 将 CLI 重写为 TypeScript,使用 Node.js 24 和 npm 构建、测试及打包。
6
+ - 六个渠道保留已有应用的上传和提审路径。发布前仍需冻结 APK、预检并核对批准文件。
7
+ - 凭据默认在终端交互输入;自动化可读取权限为 `0600` 的私有 JSON 文件。CLI 不再读取钥匙串。
8
+ - 新版状态保存在独立的 `v2` 目录。0.1.x 的项目、批次、命令和状态不自动迁移。
9
+ - 删除旧的 Go 二进制、浏览器模拟和 macOS 安装包流程。交付物改为 npm tarball。
10
+ - 命令入口改为 `android-release`;增加版本 PR 和 npm 注册表发布工作流。
11
+
12
+ 合成渠道测试、真实签名 APK 测试和子进程崩溃恢复测试已通过。新版尚未完成六个渠道的真实账号验证。响应丢失仍会阻止冲突写入,当前没有自动解除该阻塞的依据。
13
+
14
+ ## 0.1.0-beta.5
15
+
16
+ - 重写日常发布和首次使用教程,按六渠道说明预检、准备、提交、查询、核对和恢复。
17
+ - 补充跨渠道发布前检查,并说明失败后的排查和继续执行方法。
18
+
19
+ 本版本只更新文档,CLI 行为和本地状态结构没有变化。CI 预览包使用 ad-hoc 临时签名,未公证,没有稳定的钥匙串信任身份。
20
+
21
+ ## 0.1.0-beta.4
22
+
23
+ - 项目文档和协作模板改为中文,保留命令、链接及标准许可证原文。
24
+ - 按发版同事、贡献者和维护者整理文档入口,合并重复教程和验收规则,将初始验收记录归档。
25
+ - 安装包首页改为离线导航,使用步骤统一维护在对应手册。
26
+
27
+ 本版本为 Apple Silicon CI 预览包,使用 ad-hoc 临时签名,未公证,没有稳定的钥匙串信任身份。使用真实凭据前应采用团队固定签名包。CLI 行为和本地状态结构没有变化。
28
+
29
+ ## 0.1.0-beta.3
30
+
31
+ - 建立脱敏后的公开源码基线,提交身份使用 noreply 邮箱。
32
+ - 保留已审查的安装包文档和完整验收要求。
33
+ - 真实 Chromium 验收在冷启动运行器上使用协议已有的 30 秒时限。
34
+
35
+ ## 0.1.0-beta.2
36
+
37
+ - 修复安装包中更新记录指向分发说明的链接。
38
+ - 检查安装包根目录的每份 Markdown 文档,防止离线链接缺失。
39
+
40
+ 分发状态仍为 CI 预览:使用 ad-hoc 临时签名,未公证。
41
+
42
+ ## 0.1.0-beta.1
43
+
44
+ 首个公开测试版包括:
45
+
46
+ - 六渠道本地 APK 发布计划、明确批准、持久化操作回执和受约束的恢复。
47
+ - 可复用的项目配置、批次准备、提交、核对和继续执行。
48
+ - 自动查询小米版本并标注审核推测;读取应用宝审核信息、脱敏驳回原因和公开商店版本。
49
+ - 缓存历史及私有 Markdown/JSON 报告。
50
+ - Apple Silicon 安装、升级、回滚和卸载脚本。
51
+ - 合成数据协议、浏览器、Android 签名及子进程恢复验收,以及 GitHub CI 和草稿发布工作流。
52
+
53
+ 公开版本采用新的版本基线,不宣称已验证真实渠道账号。CI 预览产物使用 ad-hoc 临时签名,未公证,详见[旧版验收记录](docs/archive/public-beta-acceptance.md)。
@@ -0,0 +1,55 @@
1
+ # 贡献指南
2
+
3
+ 本页供修改 CLI、测试和文档的贡献者使用。操作发布命令请读[首次使用](docs/colleague-quickstart.md)和[日常发布](docs/daily-cli-workflow.md)。
4
+
5
+ ## 开发环境
6
+
7
+ 安装 [`.node-version`](.node-version) 指定的 Node.js。仓库使用 `package-lock.json` 固定 npm 依赖。在仓库根目录运行:
8
+
9
+ ```sh
10
+ npm ci --ignore-scripts
11
+ npm run check
12
+ ```
13
+
14
+ `npm run check` 先检查 TypeScript 类型,再构建 CLI 并运行 Node.js 测试。真实 APK 测试需要 Android SDK Build Tools 和 JDK。CI 会安装独立工具,并设置 `PUBLISHER_REQUIRE_ANDROID_TESTS=1`。本机执行完整验收时也设置这个变量:
15
+
16
+ ```sh
17
+ PUBLISHER_REQUIRE_ANDROID_TESTS=1 npm run check
18
+ ```
19
+
20
+ 工具缺失时,完整验收应失败。普通本机测试会跳过真实 APK 用例;测试结果会明确显示跳过。合成渠道测试使用本地响应,不会访问商店或提交应用。
21
+
22
+ ## 按改动检查
23
+
24
+ | 改动 | 先读 | 验收重点 |
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
+ | 文档 | 本页“文档归属” | 保留命令、条件、链接和验证程度 |
30
+
31
+ 渠道协议由本仓库独立实现。使用第三方代码前,先确认许可证。测试、示例和截图使用 `com.example.app`、`example.invalid` 等合成内容。真实账号、APK、私钥、Cookie、签名 URL 和原始响应不能进入 Git、issue、构建产物或普通日志。
32
+
33
+ ## 分支与 PR
34
+
35
+ 代码 agent 使用 `codex/topic` 分支。提交前,在本仓库配置 GitHub noreply 邮箱,并确认 GitHub 网页设置隐藏邮箱地址。按实际改动运行检查,向 `main` 创建 PR。说明行为变化、检查结果、恢复影响和仍待验证的渠道条件。
36
+
37
+ 合并前,以待合并提交上的必需 CI `check` 为准。解决实质性审查意见后,使用 squash 合并。仓库规则禁止直接推送、强制推送或删除 `main`。PR 合并不授权 Android 应用提审。
38
+
39
+ CI 工作流见 [check.yml](https://github.com/wangyiliangnet/android-publisher-cli/blob/main/.github/workflows/check.yml)。它运行公开源码扫描、TypeScript 检查、合成协议测试、真实 APK 签名测试、子进程崩溃恢复测试,以及 npm 安装检查。CI 只使用临时身份和合成材料。
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。
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
+ 写操作步骤时,先列准备材料,再给命令。命令后说明成功的判断依据,以及失败时该做什么。审核、上架和查询健康状态分别描述,不能把“已受理”写成“已上架”。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wangyiliangnet and contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,11 @@
1
+ # MIT 许可证中文译文
2
+
3
+ 本文为 [LICENSE](LICENSE) 的中文译文,供阅读参考;许可条款以英文原文为准。
4
+
5
+ 版权所有 (c) 2026 wangyiliangnet 及贡献者
6
+
7
+ 特此免费授予任何取得本软件及相关文档文件(以下简称“软件”)副本的人,不受限制地处理软件的权利,包括但不限于使用、复制、修改、合并、发布、分发、再许可及销售软件副本的权利,并允许获得软件的人行使上述权利,但须遵守以下条件:
8
+
9
+ 在软件的所有副本或实质性部分中,均须包含上述版权声明和本许可声明。
10
+
11
+ 软件按“原样”提供,不附带任何明示或默示担保,包括但不限于对适销性、特定用途适用性及不侵权的担保。在任何情况下,作者或版权持有人均不对因软件、软件使用或其他软件交易而产生、引起或与之相关的任何索赔、损害或其他责任负责,无论该责任基于合同、侵权或其他法律依据。
package/README.md CHANGED
@@ -1,3 +1,34 @@
1
- # Temporary Holding Version
1
+ # Android 发布工具
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 这个 CLI 使用 TypeScript 和 npm 构建。在 Apple Silicon Mac 上,它可向华为、荣耀、小米、OPPO、vivo 和应用宝提交已有应用的 Android 版本更新。发布规则固定为“审核通过后立即发布”。
4
+
5
+ 凭据默认在终端交互输入。CLI 不读取 macOS 钥匙串,也不保存用户输入的渠道密钥。发布批次、操作日志、短期令牌和上传授权保存在本机私有目录。真实渠道账号尚未完成新版实现的逐一验证;合成测试通过不代表账号具备发布权限。
6
+
7
+ ## 开始使用
8
+
9
+ 先安装 [`.node-version`](.node-version) 指定的 Node.js,以及 Android SDK Build Tools 和 JDK。安装与项目配置见[首次使用](docs/colleague-quickstart.md)。
10
+
11
+ ```sh
12
+ npm ci --ignore-scripts
13
+ npm run build
14
+ node build/cli.js doctor
15
+ ```
16
+
17
+ 运行 `android-release prepare` 后,按[日常发布](docs/daily-cli-workflow.md)完成预检、审阅、提交和查询。**真实提交会上传 APK 并提审。** 请先核对应用、产物和渠道范围的授权。
18
+
19
+ ## 阅读入口
20
+
21
+ | 任务 | 文档 |
22
+ | --- | --- |
23
+ | 安装、配置、输入凭据 | [首次使用](docs/colleague-quickstart.md) |
24
+ | 准备批次、提交、查询和恢复 | [日常发布](docs/daily-cli-workflow.md) |
25
+ | 理解审核、上架和查询状态 | [自动查询说明](docs/channels/automatic-observation.md) |
26
+ | 修改代码、测试或提交 PR | [贡献指南](CONTRIBUTING.md) |
27
+ | 查看状态机和渠道依据 | [架构](docs/architecture.md)、[协议参考](docs/channels/protocol-evidence.md) |
28
+ | 打包或发布 npm 产物 | [构建与分发](docs/mac-cli-delivery.md)、[版本发布](docs/release-process.md) |
29
+
30
+ 0.1.x Go 版的本地状态不会迁入新版。新版数据保存在 `~/Library/Application Support/AndroidPublisherCLI/v2/`。迁移范围见[更新记录](CHANGELOG.md)。
31
+
32
+ ## 许可证
33
+
34
+ 原创代码采用 [MIT 许可证](LICENSE)。依赖和协议来源见[第三方声明](THIRD_PARTY_NOTICES.md)。本项目独立于各渠道厂商。
@@ -0,0 +1,9 @@
1
+ # 第三方声明
2
+
3
+ 项目原创代码采用 [MIT 许可证](LICENSE)。npm 构建依赖的准确版本和完整性摘要记录在 `package-lock.json` 中。TypeScript 使用 Apache-2.0 许可证;`@types/node` 和 `undici-types` 使用 MIT 许可证。构建依赖不包含在交付的 npm tarball 中。
4
+
5
+ CLI 运行时使用 Node.js 内置模块。Node.js、Android SDK Build Tools 和 JDK 由使用者另行安装,适用各自的许可证。npm tarball 不包含这些工具、真实 APK 或渠道凭据。
6
+
7
+ 仓库内的中文技术写作技能来自 [leter/zh-tech-writing](https://github.com/leter/zh-tech-writing),采用 MIT 许可证。技能文件和许可保存在 `.agents/skills/zh-tech-writing/`,来源记录在 `skills-lock.json` 中。技能文件不包含在 CLI tarball 内。
8
+
9
+ 渠道文档用于研究协议。早期自动查询设计参考过 [apkgo](https://github.com/KevinGong2013/apkgo) 的一般思路。本仓库独立实现渠道协议,没有复制 apkgo 源码。apkgo 使用 PolyForm Noncommercial 许可证。渠道 API 和文档仍受各自条款约束,来源见[协议参考](docs/channels/protocol-evidence.md)。
@@ -0,0 +1,151 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { validHash, validPackage, validVersion } from './model.js';
6
+ import { freezeFile, hashFile } from './private-file.js';
7
+ function latest(directory, file) {
8
+ if (!fs.existsSync(directory))
9
+ return '';
10
+ const versions = fs.readdirSync(directory, { withFileTypes: true }).filter(entry => entry.isDirectory())
11
+ .map(entry => entry.name).sort((a, b) => {
12
+ const aa = a.match(/\d+/g)?.map(Number) ?? [];
13
+ const bb = b.match(/\d+/g)?.map(Number) ?? [];
14
+ for (let i = 0; i < Math.max(aa.length, bb.length); i++)
15
+ if ((aa[i] ?? 0) !== (bb[i] ?? 0))
16
+ return (bb[i] ?? 0) - (aa[i] ?? 0);
17
+ return b.localeCompare(a);
18
+ });
19
+ for (const version of versions) {
20
+ const found = path.join(directory, version, file);
21
+ if (fs.existsSync(found))
22
+ return found;
23
+ }
24
+ return '';
25
+ }
26
+ export class AndroidTools {
27
+ sdkRoot;
28
+ javaHome;
29
+ apkSigner;
30
+ apkAnalyzer;
31
+ aapt2;
32
+ androidJar;
33
+ constructor(sdkRoot = process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME || path.join(os.homedir(), 'Library', 'Android', 'sdk'), javaHome = process.env.JAVA_HOME || '/Applications/Android Studio.app/Contents/jbr/Contents/Home') {
34
+ this.sdkRoot = sdkRoot;
35
+ this.javaHome = javaHome;
36
+ this.apkSigner = latest(path.join(sdkRoot, 'build-tools'), 'apksigner');
37
+ this.aapt2 = latest(path.join(sdkRoot, 'build-tools'), 'aapt2');
38
+ this.androidJar = latest(path.join(sdkRoot, 'platforms'), 'android.jar');
39
+ this.apkAnalyzer = path.join(sdkRoot, 'cmdline-tools', 'latest', 'bin', 'apkanalyzer');
40
+ if (!fs.existsSync(this.apkAnalyzer))
41
+ this.apkAnalyzer = latest(path.join(sdkRoot, 'cmdline-tools'), 'bin/apkanalyzer');
42
+ }
43
+ diagnostic() {
44
+ return { sdk_root: this.sdkRoot, java_home: this.javaHome,
45
+ apksigner: this.apkSigner, apkanalyzer: this.apkAnalyzer, aapt2: this.aapt2,
46
+ android_jar: this.androidJar, ready: Boolean(this.apkSigner && this.apkAnalyzer && this.aapt2 && this.androidJar) };
47
+ }
48
+ command(executable, args) {
49
+ if (!executable || !fs.existsSync(executable))
50
+ throw new Error('Android tool is unavailable; run android-release doctor');
51
+ const result = spawnSync(executable, args, { encoding: 'utf8', timeout: 30000, maxBuffer: 4 * 1024 * 1024,
52
+ env: { ...process.env, JAVA_HOME: this.javaHome } });
53
+ if (result.status !== 0 || result.error)
54
+ throw new Error(`${path.basename(executable)} failed; check the SDK, JDK and APK`);
55
+ return result.stdout;
56
+ }
57
+ inspect(apk) {
58
+ const certs = this.command(this.apkSigner, ['verify', '--verbose', '--print-certs', apk]);
59
+ const counts = [...certs.matchAll(/^Number of signers: ([0-9]+)[ \t\r]*$/gm)];
60
+ if (counts.length !== 1 || counts[0]?.[1] !== '1')
61
+ throw new Error('APK must have exactly one verified signer');
62
+ const hashes = new Set();
63
+ for (const line of certs.split('\n')) {
64
+ if (!line.includes('certificate SHA-256 digest:') || line.startsWith('Source Stamp '))
65
+ continue;
66
+ const match = line.match(/^(?:Signer #1 |V[0-9]+(?:\.[0-9]+)? Signer: )certificate SHA-256 digest: ([a-fA-F0-9]{64})[ \t\r]*$/);
67
+ if (!match)
68
+ throw new Error('unrecognized APK signer output');
69
+ hashes.add(match[1].toLowerCase());
70
+ }
71
+ if (hashes.size !== 1)
72
+ throw new Error('APK signer changed across signature schemes');
73
+ const query = (verb) => this.command(this.apkAnalyzer, ['manifest', verb, apk]).trim();
74
+ const packageName = query('application-id');
75
+ const versionName = query('version-name');
76
+ const versionCode = query('version-code');
77
+ const debuggable = query('debuggable');
78
+ if (!validPackage(packageName) || !validVersion(versionCode) || !versionName || versionName.length > 255 || !['true', 'false'].includes(debuggable))
79
+ throw new Error('invalid APK manifest');
80
+ if (debuggable === 'true')
81
+ throw new Error('debuggable APK cannot be published');
82
+ return { signer_sha256: [...hashes][0], package_name: packageName, version_name: versionName,
83
+ version_code: versionCode, abis: apkAbis(apk) };
84
+ }
85
+ }
86
+ // Read ZIP central-directory names without extracting untrusted APK content.
87
+ export function apkAbis(apk) {
88
+ const fd = fs.openSync(apk, 'r');
89
+ try {
90
+ const size = fs.fstatSync(fd).size;
91
+ const tailSize = Math.min(size, 65557);
92
+ const tail = Buffer.alloc(tailSize);
93
+ fs.readSync(fd, tail, 0, tailSize, size - tailSize);
94
+ let eocd = -1;
95
+ for (let i = tail.length - 22; i >= 0; i--)
96
+ if (tail.readUInt32LE(i) === 0x06054b50) {
97
+ eocd = i;
98
+ break;
99
+ }
100
+ if (eocd < 0)
101
+ throw new Error('APK ZIP directory is missing');
102
+ const count = tail.readUInt16LE(eocd + 10);
103
+ const offset = tail.readUInt32LE(eocd + 16);
104
+ const length = tail.readUInt32LE(eocd + 12);
105
+ if (count === 0xffff || offset === 0xffffffff || length === 0xffffffff || offset + length > size)
106
+ throw new Error('unsupported ZIP64 APK');
107
+ const names = new Set();
108
+ let pos = offset;
109
+ for (let i = 0; i < count; i++) {
110
+ const head = Buffer.alloc(46);
111
+ if (fs.readSync(fd, head, 0, head.length, pos) !== head.length || head.readUInt32LE(0) !== 0x02014b50)
112
+ throw new Error('invalid APK ZIP directory');
113
+ const nameSize = head.readUInt16LE(28);
114
+ const extraSize = head.readUInt16LE(30);
115
+ const commentSize = head.readUInt16LE(32);
116
+ if (nameSize > 4096 || pos + 46 + nameSize + extraSize + commentSize > size)
117
+ throw new Error('invalid APK ZIP entry');
118
+ const name = Buffer.alloc(nameSize);
119
+ fs.readSync(fd, name, 0, nameSize, pos + 46);
120
+ const found = name.toString('utf8').match(/^lib\/([^/]+)\/[^/]+\.so$/);
121
+ if (found)
122
+ names.add(found[1]);
123
+ pos += 46 + nameSize + extraSize + commentSize;
124
+ }
125
+ return [...names].sort();
126
+ }
127
+ finally {
128
+ fs.closeSync(fd);
129
+ }
130
+ }
131
+ export function inspectAndFreeze(tools, source, root) {
132
+ const hashed = hashFile(source);
133
+ const inspected = tools.inspect(source);
134
+ const after = hashFile(source);
135
+ if (hashed.sha256 !== after.sha256 || hashed.size !== after.size)
136
+ throw new Error('APK changed while being inspected');
137
+ const frozen = path.join(root, 'artifacts', hashed.sha256, 'blob.apk');
138
+ freezeFile(source, frozen, hashed.sha256);
139
+ if (!validHash(inspected.signer_sha256))
140
+ throw new Error('invalid APK signer hash');
141
+ return { path: frozen, filename: path.basename(source), sha256: hashed.sha256, size: hashed.size, ...inspected };
142
+ }
143
+ export function verifyFrozen(tools, artifact) {
144
+ const hashed = hashFile(artifact.path);
145
+ if (hashed.sha256 !== artifact.sha256 || hashed.size !== artifact.size)
146
+ throw new Error('frozen APK differs from the reviewed artifact');
147
+ const current = tools.inspect(artifact.path);
148
+ for (const key of ['signer_sha256', 'package_name', 'version_name', 'version_code'])
149
+ if (current[key] !== artifact[key])
150
+ throw new Error('frozen APK identity changed');
151
+ }
@@ -0,0 +1,65 @@
1
+ import { constants, createPrivateKey, sign as signBytes } from 'node:crypto';
2
+ import { digest, parseStrictJSON } from '../model.js';
3
+ import { check, num, object, required, str, unknown } from './http.js';
4
+ function b64(value) { return Buffer.from(value).toString('base64url'); }
5
+ export function huaweiJWT(serviceJSON, now = Date.now()) {
6
+ const service = object(parseStrictJSON(serviceJSON));
7
+ const keyId = str(service.key_id);
8
+ const subAccount = str(service.sub_account);
9
+ const privateKey = str(service.private_key);
10
+ required(keyId, subAccount, privateKey);
11
+ const key = createPrivateKey(privateKey);
12
+ if (key.asymmetricKeyType !== 'rsa' || (key.asymmetricKeyDetails?.modulusLength ?? 0) < 2048)
13
+ throw new Error('Huawei service account key is invalid');
14
+ const seconds = Math.floor(now / 1000);
15
+ const signed = `${b64(JSON.stringify({ alg: 'PS256', kid: keyId, typ: 'JWT' }))}.${b64(JSON.stringify({
16
+ aud: 'https://oauth-login.cloud.huawei.com/oauth2/v3/token', iss: subAccount, iat: seconds, exp: seconds + 3600
17
+ }))}`;
18
+ const signature = signBytes('sha256', Buffer.from(signed), { key, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 });
19
+ return `${signed}.${signature.toString('base64url')}`;
20
+ }
21
+ export async function bearer(channel, account, clientId, credentials, store, http) {
22
+ if (channel === 'huawei')
23
+ return huaweiJWT(credentials.service_account_json ?? '');
24
+ if (channel !== 'honor' && channel !== 'oppo')
25
+ return '';
26
+ const scope = digest([channel, account, clientId, 'token']);
27
+ const existing = store.beginToken(scope);
28
+ if (existing)
29
+ return existing;
30
+ try {
31
+ let token;
32
+ let expiry;
33
+ if (channel === 'honor') {
34
+ const secret = credentials.client_secret ?? '';
35
+ required(clientId, secret);
36
+ const form = Buffer.from(new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: secret }).toString());
37
+ const response = await http.send({ method: 'POST', url: 'https://iam.developer.honor.com/auth/token', class: 'lifecycle',
38
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' }, body: form, size: form.length });
39
+ token = str(response.access_token);
40
+ const seconds = num(response.expires_in);
41
+ if (!token || response.token_type !== 'Bearer' || seconds === null || seconds <= 30 || seconds > 86400)
42
+ throw unknown();
43
+ expiry = Date.now() + seconds * 1000;
44
+ }
45
+ else {
46
+ const secret = credentials.client_secret ?? '';
47
+ required(clientId, secret);
48
+ const query = new URLSearchParams({ client_id: clientId, client_secret: secret });
49
+ const response = await http.send({ method: 'GET', path: `/developer/v1/token?${query}`, class: 'lifecycle' });
50
+ check(response, 'errno');
51
+ const data = object(response.data);
52
+ token = str(data.access_token);
53
+ const seconds = num(data.expire_in);
54
+ if (!token || seconds === null || seconds <= Date.now() / 1000 || seconds > 253402300799)
55
+ throw unknown();
56
+ expiry = seconds * 1000;
57
+ }
58
+ store.finishToken(scope, token, new Date(expiry).toISOString());
59
+ return token;
60
+ }
61
+ catch (error) {
62
+ store.unknownToken(scope);
63
+ throw error;
64
+ }
65
+ }
@@ -0,0 +1,128 @@
1
+ import { bearer } from './auth.js';
2
+ import { BaseAdapter } from './types.js';
3
+ import { check, decimal, id, list, object, required, str, unknown } from './http.js';
4
+ const integerID = (value) => /^[1-9][0-9]{0,18}$/.test(value) && BigInt(value) <= 9223372036854775807n;
5
+ export class HonorAdapter extends BaseAdapter {
6
+ channel = 'honor';
7
+ steps = ['authorize', 'upload', 'bind', 'metadata', 'submit'];
8
+ async call(path, method, kind, value, operationId) {
9
+ const token = await bearer('honor', this.target.account, this.target.client_id ?? '', this.secrets, this.store, this.http);
10
+ const response = await this.http.json(path, method, kind, value, operationId, { Authorization: `Bearer ${token}` });
11
+ check(response, 'code');
12
+ return response;
13
+ }
14
+ async raw(path, source, kind, operationId) {
15
+ const token = await bearer('honor', this.target.account, this.target.client_id ?? '', this.secrets, this.store, this.http);
16
+ const response = await this.http.rawJSON(path, 'POST', kind, source, operationId, { Authorization: `Bearer ${token}` });
17
+ check(response, 'code');
18
+ return response;
19
+ }
20
+ async details() {
21
+ const pkg = this.batch.package_name;
22
+ const found = await this.call(`/openapi/v1/publish/get-app-id?${new URLSearchParams({ pkgName: pkg })}`, 'GET', 'read');
23
+ const rows = list(found.data);
24
+ if (rows.length !== 1 || rows[0]?.packageName !== pkg || id(rows[0]?.appId) !== this.target.app_id || !integerID(this.target.app_id))
25
+ throw unknown();
26
+ const response = await this.call(`/openapi/v1/publish/get-app-detail?${new URLSearchParams({ appId: this.target.app_id })}`, 'GET', 'read');
27
+ const data = object(response.data);
28
+ const basic = object(data.basicInfo);
29
+ if (id(basic.appId) !== this.target.app_id || basic.packageName !== pkg)
30
+ throw unknown();
31
+ const release = object(data.releaseInfo);
32
+ const version = decimal(release.versionCode);
33
+ if (!version || BigInt(version) <= 0n)
34
+ throw unknown();
35
+ const languageRows = list(data.languageInfo);
36
+ let language = this.target.language_id ?? '';
37
+ if (!language) {
38
+ const matches = languageRows.filter(row => str(row.languageId).replaceAll('_', '-').toLowerCase() === 'zh-cn');
39
+ if (matches.length !== 1)
40
+ throw new Error('Honor Chinese language is missing or ambiguous');
41
+ language = str(matches[0]?.languageId);
42
+ }
43
+ const row = languageRows.find(item => item.languageId === language);
44
+ if (!row)
45
+ throw unknown();
46
+ const metadata = { language_id: language, app_name: str(row.appName), intro: str(row.intro),
47
+ brief_intro: str(row.briefIntro), prior_notes: str(row.newFeature) };
48
+ if (!metadata.app_name || !metadata.intro || !metadata.brief_intro)
49
+ throw unknown();
50
+ return { version, metadata };
51
+ }
52
+ async preflight() {
53
+ const details = await this.details();
54
+ if (BigInt(details.version) >= BigInt(this.batch.version_code))
55
+ throw new Error('Honor target version must exceed the live version');
56
+ return this.preflightRecord({ remote_version_code: details.version, remote_ref: this.target.app_id, metadata: details.metadata });
57
+ }
58
+ async execute(step, operationId) {
59
+ if (step === 'authorize') {
60
+ const response = await this.call(`/openapi/v1/publish/get-file-upload-url?${new URLSearchParams({ appId: this.target.app_id })}`, 'POST', 'write', [{ fileName: this.target.artifact.filename, fileType: 100,
61
+ fileSize: this.target.artifact.size, fileSha256: this.target.artifact.sha256 }], operationId);
62
+ const rows = list(response.data);
63
+ if (rows.length !== 1 || rows[0]?.fileName !== this.target.artifact.filename)
64
+ throw unknown();
65
+ const entry = rows[0];
66
+ const ref = id(entry.objectId);
67
+ const expiry = Number(decimal(entry.expireTime));
68
+ const url = str(entry.uploadUrl);
69
+ let endpoint;
70
+ try {
71
+ endpoint = new URL(url);
72
+ }
73
+ catch {
74
+ throw unknown();
75
+ }
76
+ if (!integerID(ref) || !Number.isSafeInteger(expiry) || expiry <= Date.now() / 1000 ||
77
+ endpoint.origin !== 'https://appmarket-openapi-drcn.cloud.honor.com' || endpoint.pathname !== '/openapi/v1/publish/file-upload' ||
78
+ endpoint.searchParams.size !== 2 || endpoint.searchParams.get('appId') !== this.target.app_id || endpoint.searchParams.get('objectId') !== ref)
79
+ throw unknown();
80
+ const grant = { url, ref, expires_at: new Date(expiry * 1000).toISOString() };
81
+ return { ref, material: { value: grant, expiresAt: grant.expires_at } };
82
+ }
83
+ if (step === 'upload') {
84
+ const grant = this.material('authorize');
85
+ if (grant.ref !== this.ref('authorize'))
86
+ throw new Error('Honor upload grant differs from authorization receipt');
87
+ const token = await bearer('honor', this.target.account, this.target.client_id ?? '', this.secrets, this.store, this.http);
88
+ const response = await this.http.multipart(grant.url, {}, { file: this.target.artifact }, 'write', operationId, { Authorization: `Bearer ${token}` }, true);
89
+ check(response, 'code');
90
+ return { ref: grant.ref };
91
+ }
92
+ if (step === 'bind') {
93
+ const ref = this.ref('upload');
94
+ if (!integerID(ref))
95
+ throw new Error('Honor object ID is invalid');
96
+ await this.raw(`/openapi/v1/publish/update-file-info?${new URLSearchParams({ appId: this.target.app_id })}`, `{"bindingFileList":[{"objectId":${ref}}]}`, 'write', operationId);
97
+ return { ref };
98
+ }
99
+ if (step === 'metadata') {
100
+ const baseline = this.savedPreflight().metadata;
101
+ if (!baseline)
102
+ throw new Error('Honor language baseline missing');
103
+ required(baseline.language_id ?? '', baseline.app_name ?? '', baseline.intro ?? '', baseline.brief_intro ?? '', this.batch.release_notes);
104
+ await this.call(`/openapi/v1/publish/update-language-info?${new URLSearchParams({ appId: this.target.app_id })}`, 'POST', 'write', { setAll: 0, languageInfoList: [{ languageId: baseline.language_id, appName: baseline.app_name,
105
+ intro: baseline.intro, briefIntro: baseline.brief_intro, newFeature: this.batch.release_notes }] }, operationId);
106
+ return { ref: `ack-${operationId}` };
107
+ }
108
+ if (step === 'submit') {
109
+ const response = await this.call(`/openapi/v1/publish/submit-audit?${new URLSearchParams({ appId: this.target.app_id })}`, 'POST', 'write', { releaseType: 1 }, operationId);
110
+ const ref = str(response.data);
111
+ if (!ref)
112
+ throw unknown();
113
+ return { ref };
114
+ }
115
+ throw new Error('unsupported Honor step');
116
+ }
117
+ async observe(remoteRef) {
118
+ if (!integerID(this.target.app_id) || !remoteRef || remoteRef.startsWith('ack-'))
119
+ return this.observation('unknown', 'unknown', 'unconfirmed', 'honor_audit_missing_release_id');
120
+ const response = await this.raw('/openapi/v1/publish/get-audit-result', `{"appId":[{"appId":${this.target.app_id},"releaseId":${JSON.stringify(remoteRef)}}]}`, 'read');
121
+ const rows = list(response.data);
122
+ if (rows.length !== 1 || id(rows[0]?.appId) !== this.target.app_id || rows[0]?.releaseId !== remoteRef)
123
+ throw unknown();
124
+ const code = decimal(rows[0]?.auditResult);
125
+ const review = code === '0' ? 'in_review' : code === '1' ? 'approved' : code === '2' ? 'rejected' : 'unknown';
126
+ return this.observation(review, 'unknown', 'confirmed', 'honor_release_audit', remoteRef);
127
+ }
128
+ }