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.
@@ -0,0 +1,53 @@
1
+ import { digest } from '../model.js';
2
+ import { ChannelHTTP } from './http.js';
3
+ export class BaseAdapter {
4
+ batch;
5
+ target;
6
+ secrets;
7
+ store;
8
+ fetcher;
9
+ http;
10
+ constructor(batch, target, secrets, store, fetcher = fetch) {
11
+ this.batch = batch;
12
+ this.target = target;
13
+ this.secrets = secrets;
14
+ this.store = store;
15
+ this.fetcher = fetcher;
16
+ this.http = new ChannelHTTP(target.channel, fetcher, operationId => {
17
+ const op = store.operationById(operationId);
18
+ return op?.effect === 'intent' && op.channel === target.channel && op.batch_id === batch.batch_id;
19
+ });
20
+ }
21
+ savedPreflight() {
22
+ const row = this.store.target(this.batch.batch_id, this.target.channel);
23
+ if (!row.preflight || !row.preflight_digest)
24
+ throw new Error('missing saved preflight');
25
+ const value = JSON.parse(row.preflight);
26
+ if (value.digest !== row.preflight_digest)
27
+ throw new Error('preflight digest changed');
28
+ return value;
29
+ }
30
+ material(step) {
31
+ const op = this.store.latestOperation(this.batch.batch_id, this.target.channel, step);
32
+ if (!op || op.effect !== 'confirmed_applied')
33
+ throw new Error(`missing confirmed ${step} receipt`);
34
+ return this.store.readMaterial(op);
35
+ }
36
+ ref(step) {
37
+ const op = this.store.latestOperation(this.batch.batch_id, this.target.channel, step);
38
+ if (!op || op.effect !== 'confirmed_applied' || !op.remote_ref)
39
+ throw new Error(`missing confirmed ${step} receipt`);
40
+ return op.remote_ref;
41
+ }
42
+ preflightRecord(remote) {
43
+ const value = { channel: this.target.channel, account: this.target.account, app_id: this.target.app_id,
44
+ package_name: this.batch.package_name, version_code: this.batch.version_code,
45
+ ...remote, inspected_at: new Date().toISOString() };
46
+ return { ...value, digest: digest(value) };
47
+ }
48
+ observation(review, availability, association, source, remoteRef, context) {
49
+ return { review, availability, association, source, observed_at: new Date().toISOString(),
50
+ ...(remoteRef ? { remote_ref: remoteRef } : {}), ...(context ? { context } : {}) };
51
+ }
52
+ async reconcile(_operation) { return { effect: 'unknown' }; }
53
+ }
@@ -0,0 +1,120 @@
1
+ import { digest } from '../model.js';
2
+ import { md5File } from '../private-file.js';
3
+ import { BaseAdapter } from './types.js';
4
+ import { check, decimal, hmacSign, object, required, str, unknown } from './http.js';
5
+ const metadataFields = ['onlineType', 'updateDesc', 'detailDesc', 'icon', 'screenshot', 'mainTitle', 'subTitle',
6
+ 'appClassify', 'subAppClassify', 'remark', 'specialQualifications', 'ecopyright', 'copyrightList',
7
+ 'copyrightAuthLetter', 'safetyreport', 'networkCultureLicense', 'privateSelfCheck', 'compatibleDevice',
8
+ 'customerService', 'video', 'videoCover', 'simpleDesc', 'commitmentLetter', 'icpRecordType',
9
+ 'icpLicenseType', 'icpLicenseNo', 'icpAuthLetter', 'offInternetLetter', 'rateAge', 'status', 'saleStatus'];
10
+ const attachmentFields = new Set(['icon', 'screenshot', 'specialQualifications', 'ecopyright', 'copyrightList',
11
+ 'copyrightAuthLetter', 'safetyreport', 'video', 'videoCover', 'commitmentLetter', 'icpAuthLetter', 'offInternetLetter']);
12
+ export class VivoAdapter extends BaseAdapter {
13
+ channel = 'vivo';
14
+ steps = ['upload', 'submit'];
15
+ async pace() {
16
+ const pause = this.store.reservePacing(digest(['vivo', this.target.account]), 60_000);
17
+ if (pause > 0)
18
+ await new Promise(resolve => setTimeout(resolve, pause));
19
+ }
20
+ signed(method, fields) {
21
+ const secret = this.secrets.access_secret ?? '';
22
+ const access = this.target.client_id ?? '';
23
+ required(secret, access);
24
+ const values = { ...fields, method, access_key: access, timestamp: String(Date.now()),
25
+ format: 'json', v: '1.0', sign_method: 'HMAC-SHA256', target_app_key: 'developer' };
26
+ return { ...values, sign: hmacSign(values, secret, ['sign', 'file']) };
27
+ }
28
+ validate(response) {
29
+ check(response, 'code');
30
+ if (response.subCode !== '0')
31
+ throw unknown();
32
+ }
33
+ async details() {
34
+ await this.pace();
35
+ const response = await this.http.form('/router/rest', 'read', this.signed('app.query.details', { packageName: this.batch.package_name }));
36
+ this.validate(response);
37
+ const data = object(response.data);
38
+ const version = decimal(data.versionCode);
39
+ if (!version || data.packageName !== this.batch.package_name)
40
+ throw unknown();
41
+ const metadata = {};
42
+ for (const key of metadataFields) {
43
+ const value = data[key];
44
+ if (typeof value === 'string')
45
+ metadata[key] = value;
46
+ else if (typeof value === 'number' || typeof value === 'bigint')
47
+ metadata[key] = String(value);
48
+ }
49
+ return { version, name: str(data.versionName), metadata };
50
+ }
51
+ async preflight() {
52
+ const details = await this.details();
53
+ if (BigInt(details.version) >= BigInt(this.batch.version_code))
54
+ throw new Error('vivo target version must exceed the existing version');
55
+ if (!['1', '2', '3'].includes(details.metadata.compatibleDevice ?? '') ||
56
+ !(details.metadata.ecopyright || details.metadata.copyrightList))
57
+ throw new Error('vivo baseline is missing required device or copyright fields');
58
+ if ([...this.batch.release_notes].length > 200 || [...(details.metadata.detailDesc ?? '')].length < 50 ||
59
+ [...(details.metadata.detailDesc ?? '')].length > 1000)
60
+ throw new Error('vivo release metadata does not meet publishing requirements');
61
+ return this.preflightRecord({ remote_version_code: details.version, metadata: details.metadata });
62
+ }
63
+ async execute(step, operationId) {
64
+ if (step === 'upload') {
65
+ await this.pace();
66
+ const file = this.target.artifact;
67
+ const md5 = md5File(file.path, file.sha256);
68
+ const fields = this.signed('app.upload.apk.app', { packageName: this.batch.package_name, fileMd5: md5 });
69
+ const response = await this.http.multipart('/router/rest', fields, { file }, 'write', operationId);
70
+ this.validate(response);
71
+ const data = object(response.data);
72
+ const version = decimal(data.versionCode);
73
+ const serial = str(data.serialnumber);
74
+ if (data.packageName !== this.batch.package_name || version !== this.batch.version_code ||
75
+ data.versionName !== this.batch.version_name || !serial || str(data.fileMd5).toLowerCase() !== md5)
76
+ throw unknown();
77
+ const uploaded = { serial, package_name: this.batch.package_name, version_code: version,
78
+ version_name: this.batch.version_name, md5 };
79
+ return { ref: serial, material: { value: uploaded, expiresAt: '9999-12-31T00:00:00.000Z' } };
80
+ }
81
+ if (step === 'submit') {
82
+ const uploaded = this.material('upload');
83
+ if (uploaded.serial !== this.ref('upload') || uploaded.version_code !== this.batch.version_code)
84
+ throw new Error('vivo upload receipt differs from target');
85
+ const baseline = this.savedPreflight().metadata;
86
+ if (!baseline)
87
+ throw new Error('vivo baseline missing');
88
+ const fields = {};
89
+ for (const key of metadataFields)
90
+ if (!attachmentFields.has(key) && key !== 'status' && key !== 'saleStatus' && baseline[key] !== undefined)
91
+ fields[key] = baseline[key];
92
+ const notes = this.batch.release_notes;
93
+ if ([...notes].length < 5 || [...notes].length > 200 ||
94
+ [...(fields.detailDesc ?? '')].length < 50 || [...(fields.detailDesc ?? '')].length > 1000 ||
95
+ !['1', '2', '3'].includes(fields.compatibleDevice ?? ''))
96
+ throw new Error('vivo metadata does not meet publishing requirements');
97
+ fields.updateDesc = notes;
98
+ fields.packageName = this.batch.package_name;
99
+ fields.versionCode = this.batch.version_code;
100
+ fields.apk = uploaded.serial;
101
+ fields.fileMd5 = uploaded.md5;
102
+ fields.onlineType = '1';
103
+ await this.pace();
104
+ const response = await this.http.form('/router/rest', 'write', this.signed('app.sync.update.app', fields), operationId);
105
+ this.validate(response);
106
+ return { ref: `ack-${operationId}` };
107
+ }
108
+ throw new Error('unsupported vivo step');
109
+ }
110
+ async observe(_remoteRef) {
111
+ const details = await this.details();
112
+ if (details.version !== this.batch.version_code)
113
+ return this.observation('unknown', 'unknown', 'unconfirmed', 'vivo_details', undefined, { observed_version_code: details.version });
114
+ const code = details.metadata.status;
115
+ const review = code === '2' ? 'in_review' : code === '3' ? 'approved' : code === '4' ? 'rejected' : 'unknown';
116
+ const availability = details.metadata.saleStatus === '1' && review === 'approved' ? 'live' :
117
+ ['0', '2'].includes(details.metadata.saleStatus ?? '') ? 'not_live' : 'unknown';
118
+ return this.observation(review, availability, 'confirmed', 'vivo_target_version');
119
+ }
120
+ }
@@ -0,0 +1,99 @@
1
+ import { createHash, createPublicKey, publicEncrypt, constants, X509Certificate } from 'node:crypto';
2
+ import path from 'node:path';
3
+ import { parseStrictJSON } from '../model.js';
4
+ import { hashFile, md5File } from '../private-file.js';
5
+ import { BaseAdapter } from './types.js';
6
+ import { check, decimal, object, required, str, unknown } from './http.js';
7
+ function xiaomiSIG(fields, files, password, keyPEM) {
8
+ required(password, keyPEM);
9
+ let key;
10
+ try {
11
+ key = keyPEM.includes('BEGIN CERTIFICATE') ? new X509Certificate(keyPEM).publicKey : createPublicKey(keyPEM);
12
+ }
13
+ catch {
14
+ throw new Error('invalid Xiaomi public key');
15
+ }
16
+ if (key.asymmetricKeyType !== 'rsa' || (key.asymmetricKeyDetails?.modulusLength ?? 0) < 1024)
17
+ throw new Error('invalid Xiaomi public key');
18
+ const sig = Object.entries(fields).map(([name, value]) => ({ name, hash: createHash('md5').update(value).digest('hex') }));
19
+ for (const [name, file] of Object.entries(files))
20
+ sig.push({ name, hash: md5File(file.path, file.sha256) });
21
+ sig.sort((a, b) => a.name.localeCompare(b.name));
22
+ const plaintext = Buffer.from(JSON.stringify({ sig, password }));
23
+ const keyBytes = Math.ceil((key.asymmetricKeyDetails?.modulusLength ?? 0) / 8);
24
+ const chunks = [];
25
+ for (let pos = 0; pos < plaintext.length; pos += keyBytes - 11)
26
+ chunks.push(publicEncrypt({ key, padding: constants.RSA_PKCS1_PADDING }, plaintext.subarray(pos, pos + keyBytes - 11)));
27
+ plaintext.fill(0);
28
+ return Buffer.concat(chunks).toString('hex');
29
+ }
30
+ function validTestAccounts(source) {
31
+ const parsed = object(parseStrictJSON(source));
32
+ const zh = object(parsed.zh_CN);
33
+ if (!Array.isArray(zh.accounts) || zh.accounts.length > 5 || typeof zh.auditNotes !== 'string' || [...zh.auditNotes].length > 500)
34
+ throw new Error('Xiaomi test accounts are invalid');
35
+ for (const item of zh.accounts) {
36
+ const row = object(item);
37
+ if (![1, 2].includes(row.t) || typeof row.a !== 'string' || typeof row.p !== 'string' || typeof row.c !== 'string' ||
38
+ Boolean(row.a) !== Boolean(row.p) || [row.a, row.p, row.c].some(value => [...value].length > 50))
39
+ throw new Error('Xiaomi test accounts are invalid');
40
+ }
41
+ }
42
+ export class XiaomiAdapter extends BaseAdapter {
43
+ channel = 'xiaomi';
44
+ steps = ['push'];
45
+ async call(verb, data, files = {}, operationId) {
46
+ const requestData = JSON.stringify(data);
47
+ const fields = { RequestData: requestData };
48
+ const sig = xiaomiSIG(fields, files, this.secrets.api_secret ?? '', this.secrets.public_key_pem ?? '');
49
+ const response = await this.http.multipart(`/devupload/dev/${verb}`, { ...fields, SIG: sig }, files, verb === 'push' ? 'write' : 'read', operationId);
50
+ check(response, 'result');
51
+ return response;
52
+ }
53
+ async details() {
54
+ const response = await this.call('query', { packageName: this.batch.package_name, userName: this.target.client_id });
55
+ const info = object(response.packageInfo);
56
+ const version = decimal(info.versionCode);
57
+ if (!version || info.packageName !== this.batch.package_name || typeof response.updateVersion !== 'boolean')
58
+ throw unknown();
59
+ return { version, appName: str(info.appName), updateAllowed: response.updateVersion };
60
+ }
61
+ async preflight() {
62
+ if (!this.target.client_id || !this.target.icon_path || !this.target.icon_sha256 || !this.target.privacy_url)
63
+ throw new Error('Xiaomi public configuration is incomplete');
64
+ const details = await this.details();
65
+ if (!details.updateAllowed || BigInt(details.version) >= BigInt(this.batch.version_code) || !details.appName)
66
+ throw new Error('Xiaomi existing application or update permission is not verified');
67
+ const icon = hashFile(this.target.icon_path);
68
+ if (icon.sha256 !== this.target.icon_sha256 || icon.size !== this.target.icon_size)
69
+ throw new Error('Xiaomi icon changed');
70
+ if (this.secrets.test_account_json)
71
+ validTestAccounts(this.secrets.test_account_json);
72
+ return this.preflightRecord({ remote_version_code: details.version, metadata: { app_name: details.appName, privacy_url: this.target.privacy_url } });
73
+ }
74
+ async waitBeforeStep(step) {
75
+ if (step === 'push' && this.secrets.test_account_json)
76
+ validTestAccounts(this.secrets.test_account_json);
77
+ }
78
+ async execute(step, operationId) {
79
+ if (step !== 'push')
80
+ throw new Error('unsupported Xiaomi step');
81
+ const preflight = this.savedPreflight();
82
+ const appName = preflight.metadata?.app_name ?? '';
83
+ const icon = { path: this.target.icon_path, filename: path.basename(this.target.icon_path),
84
+ sha256: this.target.icon_sha256, size: this.target.icon_size };
85
+ required(appName, this.target.privacy_url ?? '', this.batch.release_notes);
86
+ const info = { packageName: this.batch.package_name, appName, privacyUrl: this.target.privacy_url,
87
+ updateDesc: this.batch.release_notes, versionName: this.batch.version_name };
88
+ if (this.secrets.test_account_json) {
89
+ validTestAccounts(this.secrets.test_account_json);
90
+ info.testAccount = this.secrets.test_account_json;
91
+ }
92
+ await this.call('push', { userName: this.target.client_id, synchroType: 1, appInfo: info }, { apk: this.target.artifact, icon }, operationId);
93
+ return { ref: `ack-${operationId}` };
94
+ }
95
+ async observe(_remoteRef) {
96
+ const details = await this.details();
97
+ return this.observation('unknown', 'unknown', 'unconfirmed', 'xiaomi_package_query', undefined, { version_code: details.version, inference: BigInt(details.version) === BigInt(this.batch.version_code) ? 'target_version_returned' : 'version_differs' });
98
+ }
99
+ }
package/build/cli.js ADDED
@@ -0,0 +1,275 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import readline from 'node:readline/promises';
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';
9
+ import { CHANNELS, isChannel, validateBatchInput, validateProject, validAbsolute, validId } from './model.js';
10
+ import { readJSON, writePrivate } from './private-file.js';
11
+ import { Store } from './state.js';
12
+ function flags(args, values, booleans = []) {
13
+ const result = {};
14
+ for (let i = 0; i < args.length; i++) {
15
+ const name = args[i];
16
+ if (!name?.startsWith('--') || name === '--')
17
+ throw new Error(`unexpected argument: ${name ?? ''}`);
18
+ const key = name.slice(2);
19
+ if (key in result || !values.includes(key) && !booleans.includes(key))
20
+ throw new Error(`unsupported or duplicate flag: ${name}`);
21
+ if (booleans.includes(key))
22
+ result[key] = true;
23
+ else {
24
+ const value = args[++i];
25
+ if (!value || value.startsWith('--'))
26
+ throw new Error(`${name} needs a value`);
27
+ result[key] = value;
28
+ }
29
+ }
30
+ return result;
31
+ }
32
+ function requiredFlag(f, key) {
33
+ const value = f[key];
34
+ if (typeof value !== 'string' || !value)
35
+ throw new Error(`--${key} is required`);
36
+ return value;
37
+ }
38
+ function channelsFlag(value) {
39
+ if (value === undefined)
40
+ return undefined;
41
+ if (typeof value !== 'string')
42
+ throw new Error('invalid channel selection');
43
+ const channels = value.split(',').map(item => item.trim());
44
+ if (!channels.length || new Set(channels).size !== channels.length || channels.some(ch => !isChannel(ch)))
45
+ throw new Error('unknown or duplicate channel');
46
+ return channels;
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
+ function output(value) { process.stdout.write(`${JSON.stringify(value)}\n`); }
57
+ function toolFlags(f) {
58
+ const sdk = typeof f['sdk-root'] === 'string' ? f['sdk-root'] : undefined;
59
+ const java = typeof f['java-home'] === 'string' ? f['java-home'] : undefined;
60
+ return new AndroidTools(sdk, java);
61
+ }
62
+ function help() {
63
+ process.stdout.write(`Android Publisher CLI (TypeScript + npm)\n\n` +
64
+ `android-release doctor [--sdk-root DIR] [--java-home DIR]\n` +
65
+ `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` +
68
+ `android-release prepare --file /absolute/batch.json\n` +
69
+ `android-release batch preflight --batch ID [--channels LIST] [--credentials-file FILE]\n` +
70
+ `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` +
74
+ `android-release batch report --batch ID --file /absolute/report.md\n` +
75
+ `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`);
77
+ }
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);
82
+ const io = readline.createInterface({ input: process.stdin, output: process.stderr });
83
+ const ask = async (label, fallback = '') => {
84
+ const typed = (await io.question(`${label}${fallback ? ` [${fallback}]` : ''}:`)).trim();
85
+ if (typed.toLowerCase() === 'cancel')
86
+ throw new Error('setup canceled');
87
+ return typed || fallback;
88
+ };
89
+ 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
+ const selection = channelsFlag(await ask('选择渠道,逗号分隔', CHANNELS.join(',')));
94
+ const rows = [];
95
+ for (const channel of selection) {
96
+ process.stderr.write(`${channel}:只输入公开账号和应用标识。凭据在执行命令时另行输入。\n`);
97
+ 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 : '');
99
+ const row = { channel, account, app_id: appId };
100
+ const clientLabels = { honor: 'Honor client_id', xiaomi: '小米 userName',
101
+ vivo: 'vivo access_key', tencent: '应用宝用户 uid' };
102
+ if (clientLabels[channel])
103
+ row.client_id = await ask(clientLabels[channel]);
104
+ if (channel === 'huawei' || channel === 'honor') {
105
+ const language = await ask('中文语言 ID,可留空自动匹配');
106
+ if (language)
107
+ row.language_id = language;
108
+ }
109
+ if (channel === 'xiaomi') {
110
+ row.icon_path = await ask('小米图标绝对路径');
111
+ row.privacy_url = await ask('小米公开 HTTPS 隐私政策 URL');
112
+ }
113
+ rows.push(row);
114
+ }
115
+ const project = { schema_version: 2, project_id: projectId, package_name: inspected.package_name,
116
+ signer_sha256: inspected.signer_sha256, channels: rows };
117
+ validateProject(project);
118
+ process.stderr.write(`项目摘要:${JSON.stringify(project)}\n`);
119
+ if ((await ask('确认保存,输入 yes')) !== 'yes')
120
+ throw new Error('setup canceled');
121
+ return project;
122
+ }
123
+ finally {
124
+ io.close();
125
+ }
126
+ }
127
+ async function run(args) {
128
+ if (!args.length || args[0] === 'help' || args[0] === '--help' || args[0] === '-h') {
129
+ help();
130
+ return 0;
131
+ }
132
+ const command = args[0];
133
+ if (command === 'version' || command === '--version') {
134
+ const f = flags(args.slice(1), [], ['json']);
135
+ const packagePath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
136
+ const version = JSON.parse(fs.readFileSync(packagePath, 'utf8')).version;
137
+ if (f.json)
138
+ output({ schema_version: 2, version, runtime: process.version, arch: process.arch, platform: process.platform });
139
+ else
140
+ process.stdout.write(`${version}\n`);
141
+ return 0;
142
+ }
143
+ if (command === 'doctor') {
144
+ const f = flags(args.slice(1), ['sdk-root', 'java-home']);
145
+ const diagnostic = toolFlags(f).diagnostic();
146
+ output({ schema_version: 2, ...diagnostic });
147
+ return diagnostic.ready ? 0 : 2;
148
+ }
149
+ if (command === 'inspect') {
150
+ const f = flags(args.slice(1), ['apk', 'sdk-root', 'java-home']);
151
+ output({ schema_version: 2, ...toolFlags(f).inspect(requiredFlag(f, 'apk')) });
152
+ return 0;
153
+ }
154
+ if (command === 'protocols') {
155
+ if (args.length !== 1)
156
+ throw new Error('protocols takes no flags');
157
+ output({ schema_version: 2, channels: CHANNELS.map(channel => ({ channel,
158
+ protocol_mock_verified: true, account_verified: false, live_verified: false,
159
+ note: 'Real account access and lost-response reconciliation require separate evidence.' })) });
160
+ return 0;
161
+ }
162
+ 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);
177
+ 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 });
180
+ return 0;
181
+ }
182
+ if (command === 'prepare') {
183
+ const f = flags(args.slice(1), ['file', 'sdk-root', 'java-home']);
184
+ const input = validateBatchInput(readJSON(requiredFlag(f, 'file'), 64 * 1024));
185
+ const store = new Store();
186
+ try {
187
+ const batch = prepare(input, toolFlags(f), store);
188
+ output({ ...store.summary(batch.batch_id), effect: 'offline' });
189
+ return 0;
190
+ }
191
+ finally {
192
+ store.close();
193
+ }
194
+ }
195
+ if (command === 'history') {
196
+ if (args.length !== 1)
197
+ throw new Error('history takes no flags');
198
+ const store = new Store();
199
+ try {
200
+ output({ schema_version: 2, batches: store.batchIds().map(id => store.summary(id)) });
201
+ return 0;
202
+ }
203
+ finally {
204
+ store.close();
205
+ }
206
+ }
207
+ if (command === 'batch') {
208
+ const action = args[1];
209
+ if (!action)
210
+ throw new Error('batch action is required');
211
+ const values = {
212
+ preflight: ['batch', 'channels', 'credentials-file'],
213
+ 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'],
218
+ report: ['batch', 'file'],
219
+ };
220
+ if (!values[action])
221
+ throw new Error('unsupported batch action');
222
+ const f = flags(args.slice(2), values[action], action === 'status' ? ['refresh'] : []);
223
+ const batchId = requiredFlag(f, 'batch');
224
+ 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');
227
+ const store = new Store();
228
+ try {
229
+ if (action === 'preflight') {
230
+ const result = await preflight(store, batchId, credentialSource(f), selection);
231
+ output(result);
232
+ return Object.keys(result.errors).length ? 3 : 0;
233
+ }
234
+ if (action === 'review') {
235
+ const approval = review(store, batchId, requiredFlag(f, 'file'), selection);
236
+ output({ schema_version: 2, effect: 'offline', approval });
237
+ return 0;
238
+ }
239
+ if (action === 'submit' || action === 'resume') {
240
+ if (selection?.length)
241
+ 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');
243
+ output(result);
244
+ return Object.keys(result.errors).length ? 3 : 0;
245
+ }
246
+ if (action === 'status') {
247
+ const result = f.refresh ? await refresh(store, batchId, credentialSource(f), selection) : { ...store.summary(batchId), effect: 'cached' };
248
+ output(result);
249
+ return 'errors' in result && Object.keys(result.errors).length ? 3 : 0;
250
+ }
251
+ if (action === 'reconcile') {
252
+ const result = await reconcile(store, batchId, credentialSource(f), selection);
253
+ output(result);
254
+ return Object.keys(result.errors).length ? 3 : 0;
255
+ }
256
+ if (action === 'report') {
257
+ const file = requiredFlag(f, 'file');
258
+ if (!validAbsolute(file))
259
+ throw new Error('report file path must be absolute');
260
+ writePrivate(file, report(store, batchId));
261
+ output({ schema_version: 2, effect: 'read_only', report: file });
262
+ return 0;
263
+ }
264
+ throw new Error('unsupported batch action');
265
+ }
266
+ finally {
267
+ store.close();
268
+ }
269
+ }
270
+ throw new Error('unsupported command; run android-release --help');
271
+ }
272
+ run(process.argv.slice(2)).then(code => { process.exitCode = code; }).catch(error => {
273
+ output({ schema_version: 2, error: publicError(error), effect: 'not_sent', next_action: 'check_input_or_reconcile' });
274
+ process.exitCode = 2;
275
+ });
@@ -0,0 +1,118 @@
1
+ import fs from 'node:fs';
2
+ import tty from 'node:tty';
3
+ import { assertObject, parseStrictJSON } from './model.js';
4
+ import { regularFile } from './private-file.js';
5
+ export const credentialFields = {
6
+ huawei: ['service_account_json'],
7
+ honor: ['client_secret'],
8
+ xiaomi: ['api_secret', 'public_key_pem'],
9
+ oppo: ['client_secret'],
10
+ vivo: ['access_secret'],
11
+ tencent: ['api_secret'],
12
+ };
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
+ function askHidden(label) {
34
+ return new Promise((resolve, reject) => {
35
+ let fd;
36
+ try {
37
+ fd = fs.openSync('/dev/tty', 'r+');
38
+ }
39
+ catch {
40
+ reject(new Error('interactive credentials require a terminal; use --credentials-file for automation'));
41
+ return;
42
+ }
43
+ const input = new tty.ReadStream(fd);
44
+ if (!input.isTTY || !input.setRawMode) {
45
+ input.destroy();
46
+ reject(new Error('terminal input unavailable'));
47
+ return;
48
+ }
49
+ const output = (value) => fs.writeSync(fd, value);
50
+ const bytes = [];
51
+ let done = false;
52
+ const finish = (error) => {
53
+ if (done)
54
+ return;
55
+ done = true;
56
+ input.setRawMode(false);
57
+ input.removeAllListeners('data');
58
+ input.pause();
59
+ output('\n');
60
+ input.destroy();
61
+ if (error)
62
+ reject(error);
63
+ else
64
+ resolve(Buffer.from(bytes).toString('utf8'));
65
+ bytes.fill(0);
66
+ };
67
+ output(`${label}(输入 @/绝对路径 可从私有文件读取):`);
68
+ input.setRawMode(true);
69
+ input.resume();
70
+ input.on('data', (chunk) => {
71
+ for (const byte of chunk) {
72
+ if (byte === 3 || byte === 4) {
73
+ finish(new Error('credential input canceled'));
74
+ return;
75
+ }
76
+ if (byte === 13 || byte === 10) {
77
+ finish();
78
+ return;
79
+ }
80
+ if (byte === 127 || byte === 8) {
81
+ bytes.pop();
82
+ continue;
83
+ }
84
+ if (bytes.length >= 64 * 1024) {
85
+ finish(new Error('credential input is too long'));
86
+ return;
87
+ }
88
+ bytes.push(byte);
89
+ }
90
+ });
91
+ input.on('error', () => finish(new Error('credential input failed')));
92
+ });
93
+ }
94
+ export class PromptCredentials {
95
+ cache = new Map();
96
+ async forChannel(channel) {
97
+ const cached = this.cache.get(channel);
98
+ if (cached)
99
+ return cached;
100
+ const credentials = {};
101
+ 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;
104
+ if (!value || Buffer.byteLength(value) > 64 * 1024 || /[\u0000\u007f]/u.test(value))
105
+ throw new Error(`${channel}: credential input is invalid`);
106
+ credentials[field] = value;
107
+ }
108
+ 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;
113
+ }
114
+ this.cache.set(channel, credentials);
115
+ return credentials;
116
+ }
117
+ clear() { this.cache.clear(); }
118
+ }