storebase-cli 1.0.0 → 1.2.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/README.md +111 -28
- package/bin/storebase-keystore.js +89 -0
- package/bin/storebase-publish.js +104 -54
- package/bin/storebase-telegram.js +121 -0
- package/lib/gateway-client.js +259 -0
- package/lib/publisher-token.js +58 -0
- package/lib/publisher.js +70 -30
- package/lib/secure-prompt.js +60 -0
- package/lib/signing-profiles.js +83 -0
- package/lib/signing-vault.js +372 -0
- package/lib/telegram-client.js +178 -0
- package/lib/telegram-storage.js +405 -0
- package/package.json +11 -5
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { mkdir, rm, stat } = require('node:fs/promises');
|
|
4
|
+
const { createReadStream } = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { createHash } = require('node:crypto');
|
|
7
|
+
|
|
8
|
+
const MANIFEST_MARKER = 'STOREBASE_MANIFEST_V1';
|
|
9
|
+
const ARTIFACT_MARKER = 'STOREBASE_ARTIFACT_V1';
|
|
10
|
+
const MANIFEST_FORMAT = 'storebase.app-release';
|
|
11
|
+
const MANIFEST_SCHEMA_VERSION = 1;
|
|
12
|
+
const MANIFEST_MESSAGE_PREFIX = `${MANIFEST_MARKER}\n`;
|
|
13
|
+
const DEFAULT_VERSION_MARKER = 'STOREBASE_DEFAULT_VERSION_V1';
|
|
14
|
+
const DEFAULT_VERSION_FORMAT = 'storebase.default-version';
|
|
15
|
+
const DEFAULT_VERSION_MESSAGE_PREFIX = `${DEFAULT_VERSION_MARKER}\n`;
|
|
16
|
+
|
|
17
|
+
function fail(message) {
|
|
18
|
+
throw new Error(message);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function sha256File(filePath) {
|
|
22
|
+
const hash = createHash('sha256');
|
|
23
|
+
for await (const chunk of createReadStream(filePath)) hash.update(chunk);
|
|
24
|
+
return hash.digest('hex');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function safeMessageId(value, label = 'Telegram message ID') {
|
|
28
|
+
const id = typeof value === 'number' ? value : Number(value);
|
|
29
|
+
if (!Number.isSafeInteger(id) || id <= 0) fail(`${label} is invalid.`);
|
|
30
|
+
return id;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseManifestMessage(message) {
|
|
34
|
+
const text = typeof message?.message === 'string' ? message.message : '';
|
|
35
|
+
if (!text.startsWith(MANIFEST_MESSAGE_PREFIX)) return null;
|
|
36
|
+
let manifest;
|
|
37
|
+
try {
|
|
38
|
+
manifest = JSON.parse(text.slice(MANIFEST_MESSAGE_PREFIX.length));
|
|
39
|
+
} catch (error) {
|
|
40
|
+
fail(`Manifest in Telegram message ${message.id} is invalid JSON: ${error.message}`);
|
|
41
|
+
}
|
|
42
|
+
validateManifest(manifest, message.id);
|
|
43
|
+
return { manifest, messageId: safeMessageId(message.id) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseDefaultVersionMessage(message) {
|
|
47
|
+
const text = typeof message?.message === 'string' ? message.message : '';
|
|
48
|
+
if (!text.startsWith(DEFAULT_VERSION_MESSAGE_PREFIX)) return null;
|
|
49
|
+
let record;
|
|
50
|
+
try {
|
|
51
|
+
record = JSON.parse(text.slice(DEFAULT_VERSION_MESSAGE_PREFIX.length));
|
|
52
|
+
} catch (error) {
|
|
53
|
+
fail(`Default-version record in Telegram message ${message.id} is invalid JSON: ${error.message}`);
|
|
54
|
+
}
|
|
55
|
+
if (!record || record.format !== DEFAULT_VERSION_FORMAT || record.schemaVersion !== 1
|
|
56
|
+
|| typeof record.appId !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(record.appId)
|
|
57
|
+
|| typeof record.version !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9.+_-]{0,63}$/.test(record.version)
|
|
58
|
+
|| typeof record.pinnedAt !== 'string' || Number.isNaN(Date.parse(record.pinnedAt))) {
|
|
59
|
+
fail(`Default-version record in Telegram message ${message.id} is invalid.`);
|
|
60
|
+
}
|
|
61
|
+
return { record, messageId: safeMessageId(message.id) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function validateManifest(manifest, messageId) {
|
|
65
|
+
if (!manifest || manifest.format !== MANIFEST_FORMAT || manifest.schemaVersion !== MANIFEST_SCHEMA_VERSION) {
|
|
66
|
+
fail(`Unsupported StoreBase manifest in Telegram message ${messageId}.`);
|
|
67
|
+
}
|
|
68
|
+
if (typeof manifest.appId !== 'string' || !manifest.appId || typeof manifest.version !== 'string' || !manifest.version) {
|
|
69
|
+
fail(`Manifest in Telegram message ${messageId} is missing its app ID or version.`);
|
|
70
|
+
}
|
|
71
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(manifest.appId)
|
|
72
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9.+_-]{0,63}$/.test(manifest.version)) {
|
|
73
|
+
fail(`Manifest in Telegram message ${messageId} has an unsafe app ID or version.`);
|
|
74
|
+
}
|
|
75
|
+
if (!manifest.app || typeof manifest.app.name !== 'string' || !manifest.artifacts) {
|
|
76
|
+
fail(`Manifest in Telegram message ${messageId} is missing app metadata or artifacts.`);
|
|
77
|
+
}
|
|
78
|
+
for (const kind of ['apk', 'avatar']) {
|
|
79
|
+
const artifact = manifest.artifacts[kind];
|
|
80
|
+
if (!artifact || !Number.isSafeInteger(Number(artifact.messageId)) || Number(artifact.messageId) <= 0
|
|
81
|
+
|| !/^[a-f\d]{64}$/i.test(artifact.sha256 || '') || !Number.isSafeInteger(Number(artifact.size)) || Number(artifact.size) <= 0) {
|
|
82
|
+
fail(`Manifest in Telegram message ${messageId} has invalid ${kind} artifact metadata.`);
|
|
83
|
+
}
|
|
84
|
+
artifact.messageId = Number(artifact.messageId);
|
|
85
|
+
artifact.size = Number(artifact.size);
|
|
86
|
+
}
|
|
87
|
+
const androidUpdate = manifest.androidUpdate;
|
|
88
|
+
if (androidUpdate !== undefined && (manifest.appId !== 'storebase'
|
|
89
|
+
|| !androidUpdate || androidUpdate.packageName !== 'com.nexuss0781.storebase'
|
|
90
|
+
|| !Number.isSafeInteger(androidUpdate.versionCode) || androidUpdate.versionCode <= 0 || androidUpdate.versionCode > 2147483647
|
|
91
|
+
|| !Number.isSafeInteger(androidUpdate.minimumVersionCode) || androidUpdate.minimumVersionCode < 0
|
|
92
|
+
|| androidUpdate.minimumVersionCode > androidUpdate.versionCode)) {
|
|
93
|
+
fail(`Manifest in Telegram message ${messageId} has invalid Android update metadata.`);
|
|
94
|
+
}
|
|
95
|
+
if (manifest.artifacts.delta !== undefined) {
|
|
96
|
+
const delta = manifest.artifacts.delta;
|
|
97
|
+
if (!androidUpdate || !Number.isSafeInteger(Number(delta.messageId)) || Number(delta.messageId) <= 0
|
|
98
|
+
|| !/^[a-f\d]{64}$/i.test(delta.sha256 || '') || !Number.isSafeInteger(Number(delta.size)) || Number(delta.size) <= 0
|
|
99
|
+
|| !Number.isSafeInteger(Number(delta.fromVersionCode)) || Number(delta.fromVersionCode) <= 0
|
|
100
|
+
|| Number(delta.fromVersionCode) >= androidUpdate.versionCode || typeof delta.fileName !== 'string'
|
|
101
|
+
|| typeof delta.mimeType !== 'string') {
|
|
102
|
+
fail(`Manifest in Telegram message ${messageId} has invalid delta artifact metadata.`);
|
|
103
|
+
}
|
|
104
|
+
delta.messageId = Number(delta.messageId);
|
|
105
|
+
delta.size = Number(delta.size);
|
|
106
|
+
delta.fromVersionCode = Number(delta.fromVersionCode);
|
|
107
|
+
}
|
|
108
|
+
if (typeof manifest.publishedAt !== 'string' || Number.isNaN(Date.parse(manifest.publishedAt))) {
|
|
109
|
+
fail(`Manifest in Telegram message ${messageId} has an invalid publish timestamp.`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function equivalentRelease(a, b) {
|
|
114
|
+
return a.format === b.format
|
|
115
|
+
&& a.schemaVersion === b.schemaVersion
|
|
116
|
+
&& a.appId === b.appId
|
|
117
|
+
&& a.version === b.version
|
|
118
|
+
&& JSON.stringify(a.app) === JSON.stringify(b.app)
|
|
119
|
+
&& JSON.stringify(a.androidUpdate || null) === JSON.stringify(b.androidUpdate || null)
|
|
120
|
+
&& a.signingCertificateSha256 === b.signingCertificateSha256
|
|
121
|
+
&& a.artifacts.apk.sha256 === b.artifacts.apk.sha256
|
|
122
|
+
&& a.artifacts.avatar.sha256 === b.artifacts.avatar.sha256
|
|
123
|
+
&& (a.artifacts.delta?.sha256 || null) === (b.artifacts.delta?.sha256 || null);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function getReleaseMessages(client, channel) {
|
|
127
|
+
const releases = [];
|
|
128
|
+
for await (const message of client.iterMessages(channel, { search: MANIFEST_MARKER })) {
|
|
129
|
+
const release = parseManifestMessage(message);
|
|
130
|
+
if (release) releases.push(release);
|
|
131
|
+
}
|
|
132
|
+
releases.sort((a, b) => Date.parse(b.manifest.publishedAt) - Date.parse(a.manifest.publishedAt)
|
|
133
|
+
|| b.messageId - a.messageId);
|
|
134
|
+
return releases;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function getDefaultVersionMap(client, channel) {
|
|
138
|
+
const records = [];
|
|
139
|
+
for await (const message of client.iterMessages(channel, { search: DEFAULT_VERSION_MARKER })) {
|
|
140
|
+
const parsed = parseDefaultVersionMessage(message);
|
|
141
|
+
if (parsed) records.push(parsed);
|
|
142
|
+
}
|
|
143
|
+
records.sort((a, b) => Date.parse(b.record.pinnedAt) - Date.parse(a.record.pinnedAt)
|
|
144
|
+
|| b.messageId - a.messageId);
|
|
145
|
+
const latest = new Map();
|
|
146
|
+
for (const item of records) if (!latest.has(item.record.appId)) latest.set(item.record.appId, item);
|
|
147
|
+
return latest;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function buildCatalog(releases, pins) {
|
|
151
|
+
const apps = new Map();
|
|
152
|
+
for (const release of releases) {
|
|
153
|
+
const { manifest } = release;
|
|
154
|
+
let entry = apps.get(manifest.appId);
|
|
155
|
+
if (!entry) {
|
|
156
|
+
entry = {
|
|
157
|
+
appId: manifest.appId,
|
|
158
|
+
app: manifest.app,
|
|
159
|
+
defaultVersion: manifest.version,
|
|
160
|
+
defaultPinned: false,
|
|
161
|
+
versions: [],
|
|
162
|
+
};
|
|
163
|
+
apps.set(manifest.appId, entry);
|
|
164
|
+
}
|
|
165
|
+
entry.versions.push(release);
|
|
166
|
+
}
|
|
167
|
+
for (const [appId, pin] of pins) {
|
|
168
|
+
const entry = apps.get(appId);
|
|
169
|
+
if (!entry) fail(`Pinned default for app "${appId}" has no Telegram releases.`);
|
|
170
|
+
const release = entry.versions.find(({ manifest }) => manifest.version === pin.record.version);
|
|
171
|
+
if (!release) fail(`Pinned default ${pin.record.version} for app "${appId}" is missing its Telegram release.`);
|
|
172
|
+
entry.defaultVersion = pin.record.version;
|
|
173
|
+
entry.defaultPinned = true;
|
|
174
|
+
entry.pinnedAt = pin.record.pinnedAt;
|
|
175
|
+
}
|
|
176
|
+
return [...apps.values()];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function selectRelease(releases, appId, version) {
|
|
180
|
+
const matches = releases.filter(({ manifest }) => manifest.appId === appId
|
|
181
|
+
&& (!version || manifest.version === version));
|
|
182
|
+
if (!matches.length) fail(`No Telegram release found for app "${appId}"${version ? ` version ${version}` : ''}.`);
|
|
183
|
+
return matches[0];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function safeFileName(value) {
|
|
187
|
+
const base = path.basename(String(value || 'artifact.bin'));
|
|
188
|
+
const cleaned = base.replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^\.+/, '');
|
|
189
|
+
return cleaned || 'artifact.bin';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function createTelegramStorage(client, channel, options = {}) {
|
|
193
|
+
if (!client || !channel) fail('A connected Telegram client and channel are required.');
|
|
194
|
+
const writerClient = options.writerClient || client;
|
|
195
|
+
const readerClient = options.readerClient || client;
|
|
196
|
+
const writerChannel = options.writerChannel || channel;
|
|
197
|
+
const readerChannel = options.readerChannel || channel;
|
|
198
|
+
|
|
199
|
+
async function listReleases() {
|
|
200
|
+
return getReleaseMessages(readerClient, readerChannel);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function getCatalog() {
|
|
204
|
+
const [releases, pins] = await Promise.all([
|
|
205
|
+
getReleaseMessages(readerClient, readerChannel),
|
|
206
|
+
getDefaultVersionMap(readerClient, readerChannel),
|
|
207
|
+
]);
|
|
208
|
+
return buildCatalog(releases, pins);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function getMetadata(appId, version) {
|
|
212
|
+
if (typeof appId !== 'string' || !appId.trim()) fail('App ID is required.');
|
|
213
|
+
const normalizedId = appId.trim();
|
|
214
|
+
const releases = await getReleaseMessages(readerClient, readerChannel);
|
|
215
|
+
if (version) return selectRelease(releases, normalizedId, version);
|
|
216
|
+
const pins = await getDefaultVersionMap(readerClient, readerChannel);
|
|
217
|
+
const pinned = pins.get(normalizedId);
|
|
218
|
+
return selectRelease(releases, normalizedId, pinned?.record.version);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function pinVersion(appId, version) {
|
|
222
|
+
if (typeof appId !== 'string' || !appId.trim()) fail('App ID is required.');
|
|
223
|
+
if (typeof version !== 'string' || !version.trim()) fail('Version is required.');
|
|
224
|
+
const normalizedId = appId.trim();
|
|
225
|
+
const target = selectRelease(await getReleaseMessages(readerClient, readerChannel), normalizedId, version.trim());
|
|
226
|
+
const pins = await getDefaultVersionMap(readerClient, readerChannel);
|
|
227
|
+
const previous = pins.get(normalizedId);
|
|
228
|
+
if (previous?.record.version === target.manifest.version) {
|
|
229
|
+
return { record: previous.record, messageId: previous.messageId, idempotent: true };
|
|
230
|
+
}
|
|
231
|
+
const record = {
|
|
232
|
+
format: DEFAULT_VERSION_FORMAT,
|
|
233
|
+
schemaVersion: 1,
|
|
234
|
+
appId: normalizedId,
|
|
235
|
+
version: target.manifest.version,
|
|
236
|
+
pinnedAt: options.pinnedAt || new Date().toISOString(),
|
|
237
|
+
};
|
|
238
|
+
const message = await writerClient.sendMessage(writerChannel, {
|
|
239
|
+
message: `${DEFAULT_VERSION_MESSAGE_PREFIX}${JSON.stringify(record, null, 2)}`,
|
|
240
|
+
parseMode: undefined,
|
|
241
|
+
});
|
|
242
|
+
return { record, messageId: safeMessageId(message?.id, 'Default-version message ID'), idempotent: false };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function publish(input, signingCertificateSha256) {
|
|
246
|
+
const certificate = String(signingCertificateSha256 || '').replace(/[^a-f\d]/gi, '').toUpperCase();
|
|
247
|
+
if (!/^[A-F\d]{64}$/.test(certificate)) fail('A verified 64-character SHA-256 signing certificate is required before release.');
|
|
248
|
+
if (!input || !input.id || !input.name || !input.version || !input.apkPath || !input.avatarPath) {
|
|
249
|
+
fail('Validated StoreBase app input is required before release.');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (input.id === 'storebase' && !Number.isSafeInteger(input.androidVersionCode)) {
|
|
253
|
+
fail('StoreBase Android releases require an Android version code.');
|
|
254
|
+
}
|
|
255
|
+
const [apkHash, avatarHash, apkDetails, avatarDetails] = await Promise.all([
|
|
256
|
+
sha256File(input.apkPath), sha256File(input.avatarPath), stat(input.apkPath), stat(input.avatarPath),
|
|
257
|
+
]);
|
|
258
|
+
const artifacts = {
|
|
259
|
+
apk: {
|
|
260
|
+
fileName: path.basename(input.apkPath), mimeType: 'application/vnd.android.package-archive',
|
|
261
|
+
size: apkDetails.size, sha256: apkHash,
|
|
262
|
+
},
|
|
263
|
+
avatar: {
|
|
264
|
+
fileName: path.basename(input.avatarPath),
|
|
265
|
+
mimeType: input.avatarExtension === '.jpg' ? 'image/jpeg' : `image/${input.avatarExtension.slice(1)}`,
|
|
266
|
+
size: avatarDetails.size, sha256: avatarHash,
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
if (input.deltaPath) {
|
|
270
|
+
const [deltaHash, deltaDetails] = await Promise.all([sha256File(input.deltaPath), stat(input.deltaPath)]);
|
|
271
|
+
artifacts.delta = {
|
|
272
|
+
fileName: path.basename(input.deltaPath), mimeType: 'application/vnd.storebase.delta',
|
|
273
|
+
size: deltaDetails.size, sha256: deltaHash, fromVersionCode: input.deltaBaseVersionCode,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
const manifest = {
|
|
277
|
+
format: MANIFEST_FORMAT,
|
|
278
|
+
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
279
|
+
appId: input.id,
|
|
280
|
+
app: { name: input.name, category: input.category, description: input.description },
|
|
281
|
+
version: input.version,
|
|
282
|
+
publishedAt: options.publishedAt || new Date().toISOString(),
|
|
283
|
+
signingCertificateSha256: certificate,
|
|
284
|
+
artifacts,
|
|
285
|
+
};
|
|
286
|
+
if (input.androidVersionCode !== undefined) {
|
|
287
|
+
manifest.androidUpdate = {
|
|
288
|
+
packageName: 'com.nexuss0781.storebase',
|
|
289
|
+
versionCode: input.androidVersionCode,
|
|
290
|
+
minimumVersionCode: input.minimumAndroidVersionCode || 0,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const sizeCheckManifest = structuredClone(manifest);
|
|
294
|
+
for (const artifact of Object.values(sizeCheckManifest.artifacts)) artifact.messageId = Number.MAX_SAFE_INTEGER;
|
|
295
|
+
if (`${MANIFEST_MESSAGE_PREFIX}${JSON.stringify(sizeCheckManifest, null, 2)}`.length > 4096) {
|
|
296
|
+
fail('StoreBase manifest exceeds Telegram’s 4096-character message limit. Reduce the app metadata before publishing.');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const existingReleases = await getReleaseMessages(readerClient, readerChannel);
|
|
300
|
+
const conflictingIdentity = existingReleases.find(({ manifest: item }) => item.appId === input.id && item.app.name !== input.name);
|
|
301
|
+
if (conflictingIdentity) fail(`App ID "${input.id}" is already used by "${conflictingIdentity.manifest.app.name}".`);
|
|
302
|
+
const prior = existingReleases.find(({ manifest: item }) => item.appId === input.id && item.version === input.version);
|
|
303
|
+
if (prior) {
|
|
304
|
+
if (equivalentRelease(prior.manifest, manifest)) return { ...prior, idempotent: true };
|
|
305
|
+
fail(`App ${input.id} version ${input.version} already exists in Telegram and is immutable. Publish a new version instead.`);
|
|
306
|
+
}
|
|
307
|
+
if (manifest.androidUpdate) {
|
|
308
|
+
const previousCodes = existingReleases.filter(({ manifest: item }) => item.appId === input.id && item.androidUpdate)
|
|
309
|
+
.map(({ manifest: item }) => item.androidUpdate.versionCode);
|
|
310
|
+
if (previousCodes.length && manifest.androidUpdate.versionCode <= Math.max(...previousCodes)) {
|
|
311
|
+
fail('StoreBase Android version code must be higher than every version already published in Telegram.');
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Telegram keeps every APK/icon/patch as a durable channel document. The manifest is posted last,
|
|
316
|
+
// so a partially uploaded release is never discoverable by direct clients.
|
|
317
|
+
for (const [kind, artifact] of Object.entries(manifest.artifacts)) {
|
|
318
|
+
const filePath = kind === 'apk' ? input.apkPath : kind === 'avatar' ? input.avatarPath : input.deltaPath;
|
|
319
|
+
const tag = { appId: input.id, version: input.version, kind, sha256: artifact.sha256 };
|
|
320
|
+
if (kind === 'delta') tag.fromVersionCode = artifact.fromVersionCode;
|
|
321
|
+
const uploaded = await writerClient.sendFile(writerChannel, {
|
|
322
|
+
file: filePath,
|
|
323
|
+
forceDocument: true,
|
|
324
|
+
parseMode: undefined,
|
|
325
|
+
caption: `${ARTIFACT_MARKER} ${JSON.stringify(tag)}`,
|
|
326
|
+
});
|
|
327
|
+
artifact.messageId = safeMessageId(uploaded?.id, `Uploaded ${kind} message ID`);
|
|
328
|
+
}
|
|
329
|
+
const manifestText = `${MANIFEST_MESSAGE_PREFIX}${JSON.stringify(manifest, null, 2)}`;
|
|
330
|
+
if (manifestText.length > 4096) fail('StoreBase manifest exceeds Telegram’s 4096-character message limit. Reduce the app metadata before publishing.');
|
|
331
|
+
const manifestMessage = await writerClient.sendMessage(writerChannel, { message: manifestText, parseMode: undefined });
|
|
332
|
+
return { manifest, messageId: safeMessageId(manifestMessage?.id, 'Manifest message ID'), idempotent: false };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function download(appId, version, kind, outputDirectory) {
|
|
336
|
+
if (!['apk', 'avatar', 'delta', 'all'].includes(kind)) fail('Artifact must be apk, avatar, delta, or all.');
|
|
337
|
+
const release = await getMetadata(appId, version);
|
|
338
|
+
const kinds = kind === 'all' ? ['apk', 'avatar', ...(release.manifest.artifacts.delta ? ['delta'] : [])] : [kind];
|
|
339
|
+
if (kinds.includes('delta') && !release.manifest.artifacts.delta) fail('This Telegram release has no delta artifact.');
|
|
340
|
+
const directory = path.resolve(outputDirectory || process.cwd());
|
|
341
|
+
await mkdir(directory, { recursive: true });
|
|
342
|
+
const downloaded = [];
|
|
343
|
+
|
|
344
|
+
for (const artifactKind of kinds) {
|
|
345
|
+
const artifact = release.manifest.artifacts[artifactKind];
|
|
346
|
+
const extension = path.extname(safeFileName(artifact.fileName));
|
|
347
|
+
const destination = path.join(directory, `${release.manifest.appId}-${release.manifest.version}-${artifactKind}-${artifact.sha256.slice(0, 12)}${extension}`);
|
|
348
|
+
const messageId = safeMessageId(artifact.messageId);
|
|
349
|
+
const messages = await readerClient.getMessages(readerChannel, { ids: [messageId] });
|
|
350
|
+
const message = messages?.[0];
|
|
351
|
+
if (!message) fail(`Telegram artifact message ${messageId} is no longer available.`);
|
|
352
|
+
if (await exists(destination)) {
|
|
353
|
+
const existingSize = (await stat(destination)).size;
|
|
354
|
+
const existingHash = await sha256File(destination);
|
|
355
|
+
if (existingSize !== Number(artifact.size) || existingHash !== artifact.sha256.toLowerCase()) {
|
|
356
|
+
fail(`Refusing to overwrite existing file with different content: ${destination}`);
|
|
357
|
+
}
|
|
358
|
+
downloaded.push({ kind: artifactKind, path: destination, size: existingSize, sha256: existingHash });
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
const returned = await readerClient.downloadMedia(message, { outputFile: destination });
|
|
363
|
+
if (returned && !await exists(destination)) {
|
|
364
|
+
const bytes = Buffer.isBuffer(returned) ? returned : Buffer.from(returned);
|
|
365
|
+
await require('node:fs/promises').writeFile(destination, bytes, { flag: 'wx' });
|
|
366
|
+
}
|
|
367
|
+
if (!await exists(destination)) fail(`Telegram did not produce the requested ${artifactKind} file.`);
|
|
368
|
+
const actualSize = (await stat(destination)).size;
|
|
369
|
+
const actualHash = await sha256File(destination);
|
|
370
|
+
if (actualSize !== Number(artifact.size) || actualHash !== artifact.sha256.toLowerCase()) {
|
|
371
|
+
fail(`Downloaded ${artifactKind} failed integrity verification against the Telegram manifest.`);
|
|
372
|
+
}
|
|
373
|
+
downloaded.push({ kind: artifactKind, path: destination, size: actualSize, sha256: actualHash });
|
|
374
|
+
} catch (error) {
|
|
375
|
+
await rm(destination, { force: true });
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return { release, downloaded };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return { listReleases, getCatalog, getMetadata, pinVersion, publish, download };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function exists(filePath) {
|
|
386
|
+
try {
|
|
387
|
+
await stat(filePath);
|
|
388
|
+
return true;
|
|
389
|
+
} catch {
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
module.exports = {
|
|
395
|
+
ARTIFACT_MARKER,
|
|
396
|
+
DEFAULT_VERSION_FORMAT,
|
|
397
|
+
DEFAULT_VERSION_MARKER,
|
|
398
|
+
MANIFEST_FORMAT,
|
|
399
|
+
MANIFEST_MARKER,
|
|
400
|
+
MANIFEST_SCHEMA_VERSION,
|
|
401
|
+
createTelegramStorage,
|
|
402
|
+
parseDefaultVersionMessage,
|
|
403
|
+
parseManifestMessage,
|
|
404
|
+
validateManifest,
|
|
405
|
+
};
|
package/package.json
CHANGED
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "storebase-cli",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Publish signed Android apps
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Publish signed Android apps to a versioned Telegram-backed StoreBase catalog",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
7
7
|
"type": "commonjs",
|
|
8
8
|
"bin": {
|
|
9
|
-
"storebase-publish": "bin/storebase-publish.js"
|
|
9
|
+
"storebase-publish": "bin/storebase-publish.js",
|
|
10
|
+
"storebase-telegram": "bin/storebase-telegram.js",
|
|
11
|
+
"storebase-keystore": "bin/storebase-keystore.js"
|
|
10
12
|
},
|
|
11
13
|
"files": [
|
|
12
|
-
"bin
|
|
13
|
-
"lib
|
|
14
|
+
"bin/*.js",
|
|
15
|
+
"lib/*.js",
|
|
14
16
|
"README.md"
|
|
15
17
|
],
|
|
16
18
|
"engines": {
|
|
17
19
|
"node": ">=18"
|
|
18
20
|
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"dotenv": "^16.4.7",
|
|
23
|
+
"teleproto": "^1.230.1"
|
|
24
|
+
},
|
|
19
25
|
"scripts": {
|
|
20
26
|
"test": "node --test"
|
|
21
27
|
}
|