storebase-cli 0.0.0-stage → 1.1.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 CHANGED
@@ -1,3 +1,107 @@
1
- # Temporary Holding Version
1
+ # StoreBase App Publisher CLI
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
+ A Node.js CLI for validating signed Android releases and storing APKs, optional deltas, app icons, and version metadata directly in a Telegram broadcast channel. Release manifests are immutable and versioned; the CLI preserves every release and can keep a separate Telegram record identifying an app's default pinned version.
4
+
5
+ The gateway provides encrypted Telegram settings at runtime. The CLI decrypts them in memory, connects directly to Telegram, and never writes Telegram credentials to disk. The gateway does not relay APKs or catalog metadata.
6
+
7
+ ## Requirements
8
+
9
+ - Node.js 18 or newer
10
+ - Java `keytool`
11
+ - Android SDK Build Tools `apksigner` (on `PATH`, or under `$ANDROID_SDK_ROOT/build-tools/` or `$ANDROID_HOME/build-tools/`)
12
+ - A Telegram bot that is an administrator of the broadcast channel with permission to post
13
+ - Telegram API ID and API hash, a stored bot MTProto session, and a separate personal-user read session
14
+
15
+ ## Install and test
16
+
17
+ For local development:
18
+
19
+ ```sh
20
+ cd cli
21
+ npm install
22
+ npm test
23
+ ```
24
+
25
+ Install the published package globally with:
26
+
27
+ ```sh
28
+ npm install --global storebase-cli
29
+ ```
30
+
31
+ To install this checkout globally:
32
+
33
+ ```sh
34
+ npm install --global ./cli
35
+ ```
36
+
37
+ ## Runtime configuration
38
+
39
+ Gateway mode is the default. `storebase-telegram list`, `metadata`, and `download` use the read-only catalog bootstrap and do not require publisher credentials. `storebase-publish` and `storebase-telegram pin` use the separate publisher bootstrap and require the same high-entropy `STOREBASE_GATEWAY_PUBLISH_TOKEN` in the gateway's secret environment and the CLI runtime. Keep that token in a secret manager or mode-0600 `.env` file; do not commit it. In an interactive terminal, these write commands prompt for it without echoing it when the environment variable is absent; non-interactive runs must set the environment variable. The gateway URL is optional and defaults to `https://storebase.wasmer.app`.
40
+
41
+ ```dotenv
42
+ STOREBASE_GATEWAY_URL=https://storebase.wasmer.app
43
+ STOREBASE_GATEWAY_PUBLISH_TOKEN=<same-secret-configured-on-the-gateway>
44
+ ```
45
+
46
+ The gateway must also have its existing Telegram environment configured: `STOREBASE_TELEGRAM_API_ID`, `STOREBASE_TELEGRAM_API_HASH`, `STOREBASE_TELEGRAM_SESSION` (the bot's stored MTProto session), `STOREBASE_TELEGRAM_READ_SESSION` (a personal-user session with channel access), and `STOREBASE_TELEGRAM_CHANNEL`. The publisher bootstrap returns only the API ID/hash, bot session, reader session, and channel; it excludes the bot token. The shared publisher token is checked before those settings are returned. Do not place credentials in source code, command-line arguments, or this npm package.
47
+
48
+ At runtime, the CLI creates an ephemeral RSA keypair, requests settings over HTTPS, decrypts the AES-GCM envelope in memory, and passes the values directly to the Telegram client. It does not persist decrypted settings or include them in CLI output. This remains a personal-use design, not a production identity or device-attestation system. Anyone who obtains the publisher token can obtain the encrypted bot session; keep that token restricted and rotate it if exposed.
49
+
50
+ For a direct local Telegram connection instead, place the Telegram variables above in the CLI environment and pass `--telegram-config local`. The gateway remains the default for publishing and catalog commands.
51
+
52
+ ## Publish a release
53
+
54
+ The publisher prompts for missing app details. The signing-keystore password is read through hidden terminal input or from `STOREBASE_KEYSTORE_PASSWORD`; it is never accepted as a CLI option. If a keystore has multiple private-key aliases, use `--alias` or select one interactively.
55
+
56
+ ```sh
57
+ STOREBASE_KEYSTORE_PASSWORD="$KEYSTORE_PASSWORD" storebase-publish \
58
+ --name "Example App" \
59
+ --avatar ./icon.png \
60
+ --version 1.2.0 \
61
+ --apk ./app-release.apk \
62
+ --category Productivity \
63
+ --description "A short description of the app." \
64
+ --keystore ./release.jks \
65
+ --alias release
66
+ ```
67
+
68
+ The CLI runs `keytool` and `apksigner`, and refuses to publish unless the APK signer certificate matches the selected private-key certificate in the provided keystore. The keystore is used only for verification and is never uploaded.
69
+
70
+ For the `StoreBase` app, provide `--android-version-code` on every release. A matching `.sbd` patch can be attached using `--delta <file.sbd> --delta-base-version-code <installed-code>`; the CLI stores the patch in the same Telegram channel and includes its message ID, hash, size, base code, package ID, and release code in the versioned manifest.
71
+
72
+ Supported categories: Education, Social, Productivity, Communication, Entertainment, Games, Business, Shopping, Finance, Utilities, Health & Fitness, Travel & Navigation, and AI & Machine Learning.
73
+
74
+ Use `--storage local` to opt into the older local JSON/files workflow. Its `--catalog` and `--assets-dir` options are only used in local mode.
75
+
76
+ ## Telegram storage and versions
77
+
78
+ Each release adds an APK document, an icon document, an optional delta document, then a JSON manifest text post. The manifest is posted last, so an incomplete upload is not listed as a release. The artifacts and metadata stay in Telegram; publishing a later version does not delete older versions.
79
+
80
+ Each manifest begins with `STOREBASE_MANIFEST_V1` and includes app metadata, version, publish time, signing-certificate fingerprint, and Telegram message IDs, sizes, and SHA-256 hashes for its artifacts. StoreBase Android releases also include the package name, Android `versionCode`, and optional minimum required code. Publishing the same app/version and content is idempotent; changed content cannot overwrite an existing version.
81
+
82
+ An optional `STOREBASE_DEFAULT_VERSION_V1` message stores the default-version pointer separately, so pinning a release never edits or replaces its immutable manifest. If no explicit pin exists, the newest release is the default. Use `storebase-telegram pin <app-id> <version>` to set or change the default. The CLI list shows every version and the default; metadata and downloads without `--version` use that default.
83
+
84
+ ```sh
85
+ # See all apps, versions, and each app's default version
86
+ storebase-telegram list
87
+
88
+ # Select a default version; this adds a small metadata record in Telegram
89
+ storebase-telegram pin storebase 1.2.0
90
+
91
+ # Omit --version to use the pinned default (or newest release if unpinned)
92
+ storebase-telegram metadata storebase
93
+ storebase-telegram download storebase --kind apk --out ./downloads
94
+
95
+ # Select a specific historical version
96
+ storebase-telegram metadata storebase --version 1.1.0
97
+ storebase-telegram download storebase --version 1.1.0 --kind all --out ./downloads
98
+ ```
99
+
100
+ Downloads are size- and SHA-256-verified. Filenames include the app ID, version, artifact kind, and content hash to avoid replacing a different release.
101
+
102
+ ## Development
103
+
104
+ ```sh
105
+ cd cli
106
+ npm test
107
+ ```
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ require('dotenv').config();
5
+
6
+ const { createInterface } = require('node:readline/promises');
7
+ const { stdin, stdout } = require('node:process');
8
+ const { connectTelegramFromGateway } = require('../lib/gateway-client');
9
+ const { getPublisherEnvironment } = require('../lib/publisher-token');
10
+ const {
11
+ CATEGORIES,
12
+ inspectKeystore,
13
+ releaseApp,
14
+ validateInput,
15
+ verifyApkSignature,
16
+ } = require('../lib/publisher');
17
+ const { connectTelegram, disconnectTelegram } = require('../lib/telegram-client');
18
+ const { createTelegramStorage } = require('../lib/telegram-storage');
19
+
20
+ const OPTION_NAMES = new Set([
21
+ 'name', 'avatar', 'version', 'apk', 'category', 'description', 'keystore', 'alias', 'storage', 'catalog', 'assets-dir',
22
+ 'telegram-config',
23
+ 'android-version-code', 'minimum-android-version-code', 'delta', 'delta-base-version-code',
24
+ ]);
25
+
26
+ function parseArguments(args) {
27
+ const options = {};
28
+ for (let index = 0; index < args.length; index += 1) {
29
+ const argument = args[index];
30
+ if (argument === '--help' || argument === '-h') return { help: true, options };
31
+ if (!argument.startsWith('--')) throw new Error(`Unexpected argument: ${argument}`);
32
+ const equals = argument.indexOf('=');
33
+ const name = argument.slice(2, equals < 0 ? undefined : equals);
34
+ if (!OPTION_NAMES.has(name)) throw new Error(`Unknown option: --${name}`);
35
+ const value = equals < 0 ? args[++index] : argument.slice(equals + 1);
36
+ if (typeof value !== 'string' || !value.trim() || value.startsWith('--')) {
37
+ throw new Error(`Option --${name} requires a value.`);
38
+ }
39
+ options[name] = value;
40
+ }
41
+ return { help: false, options };
42
+ }
43
+
44
+ function showHelp() {
45
+ stdout.write(`StoreBase app publisher
46
+
47
+ Usage:
48
+ storebase-publish [options]
49
+
50
+ Options (omitted app details are prompted interactively):
51
+ --name <name> App name
52
+ --avatar <file> PNG, JPEG, or WebP avatar file
53
+ --version <version> App version
54
+ --apk <file> Signed Android APK
55
+ --android-version-code <n> Required for StoreBase Android releases
56
+ --minimum-android-version-code <n> Optional required-upgrade floor
57
+ --delta <file.sbd> Optional StoreBase patch artifact
58
+ --delta-base-version-code <n> Installed base code for the patch
59
+ --category <category> One of the 13 StoreBase categories
60
+ --description <text> App description
61
+ --keystore <file> Signing keystore used for the APK
62
+ --alias <name> Private-key alias, if the keystore has multiple
63
+ --storage <backend> telegram (default) or local
64
+ --telegram-config <mode> gateway (default) or local direct Telegram settings
65
+ --catalog <file> Local app-list JSON (local mode only)
66
+ --assets-dir <directory> Local artifact directory (local mode only)
67
+ -h, --help Show this help
68
+
69
+ Gateway mode uses STOREBASE_GATEWAY_URL (optional; defaults to the StoreBase gateway)
70
+ and STOREBASE_GATEWAY_PUBLISH_TOKEN. The gateway sends encrypted Telegram settings
71
+ to this run only; settings are decrypted in memory and are never written to disk.
72
+ Use --telegram-config local only when supplying Telegram settings directly in the environment.
73
+ The bot must be a channel administrator. Keystore passwords are entered in a hidden
74
+ prompt or read from STOREBASE_KEYSTORE_PASSWORD.
75
+ `);
76
+ }
77
+
78
+ function askHidden(label) {
79
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
80
+ return Promise.reject(new Error('Set STOREBASE_KEYSTORE_PASSWORD in the environment for non-interactive use.'));
81
+ }
82
+ return new Promise((resolve, reject) => {
83
+ let value = '';
84
+ let finished = false;
85
+ const cleanup = () => {
86
+ stdin.removeListener('data', onData);
87
+ try { stdin.setRawMode(false); } catch {}
88
+ stdin.pause();
89
+ stdout.write('\n');
90
+ };
91
+ const finish = (error) => {
92
+ if (finished) return;
93
+ finished = true;
94
+ cleanup();
95
+ if (error) reject(error);
96
+ else resolve(value);
97
+ };
98
+ const onData = (chunk) => {
99
+ for (const character of String(chunk)) {
100
+ if (character === '\u0003') return finish(new Error('Cancelled.'));
101
+ if (character === '\r' || character === '\n') return finish();
102
+ if (character === '\u007f' || character === '\b') value = value.slice(0, -1);
103
+ else if (character >= ' ') value += character;
104
+ }
105
+ };
106
+ stdout.write(`${label}: `);
107
+ stdin.setRawMode(true);
108
+ stdin.setEncoding('utf8');
109
+ stdin.resume();
110
+ stdin.on('data', onData);
111
+ });
112
+ }
113
+
114
+ async function collectInput(options) {
115
+ const required = [
116
+ ['name', 'App name'],
117
+ ['avatar', 'Avatar file path'],
118
+ ['version', 'Version'],
119
+ ['apk', 'APK file path'],
120
+ ['category', 'Category'],
121
+ ['description', 'Description'],
122
+ ['keystore', 'Signed keystore file path'],
123
+ ];
124
+ const missing = required.filter(([key]) => !options[key]);
125
+ if (missing.length && (!stdin.isTTY || !stdout.isTTY)) {
126
+ throw new Error(`Missing required options: ${missing.map(([key]) => `--${key}`).join(', ')}. Prompts require an interactive terminal.`);
127
+ }
128
+
129
+ const rl = createInterface({ input: stdin, output: stdout });
130
+ try {
131
+ for (const [key, label] of required) {
132
+ if (!options[key]) {
133
+ if (key === 'category') {
134
+ stdout.write('\nAvailable categories:\n');
135
+ CATEGORIES.forEach((category, index) => stdout.write(` ${index + 1}. ${category}\n`));
136
+ }
137
+ options[key] = (await rl.question(`${label}${key === 'category' ? ' (number or name)' : ''}: `)).trim();
138
+ if (key === 'category' && /^\d+$/.test(options[key])) {
139
+ const category = CATEGORIES[Number(options[key]) - 1];
140
+ if (category) options[key] = category;
141
+ }
142
+ }
143
+ }
144
+ } finally {
145
+ rl.close();
146
+ }
147
+ return {
148
+ name: options.name,
149
+ avatar: options.avatar,
150
+ version: options.version,
151
+ apk: options.apk,
152
+ category: options.category,
153
+ description: options.description,
154
+ keystore: options.keystore,
155
+ alias: options.alias,
156
+ androidVersionCode: options['android-version-code'],
157
+ minimumAndroidVersionCode: options['minimum-android-version-code'],
158
+ delta: options.delta,
159
+ deltaBaseVersionCode: options['delta-base-version-code'],
160
+ };
161
+ }
162
+
163
+ async function selectEntry(entries, aliasOption) {
164
+ if (aliasOption) {
165
+ const selected = entries.find((entry) => entry.alias === aliasOption);
166
+ if (!selected) throw new Error(`Keystore alias "${aliasOption}" is not a private-key entry. Available aliases: ${entries.map((entry) => entry.alias).join(', ')}.`);
167
+ return selected;
168
+ }
169
+ if (entries.length === 1) return entries[0];
170
+ if (!stdin.isTTY || !stdout.isTTY) {
171
+ throw new Error(`Keystore has multiple private-key aliases. Select one with --alias: ${entries.map((entry) => entry.alias).join(', ')}.`);
172
+ }
173
+ const rl = createInterface({ input: stdin, output: stdout });
174
+ try {
175
+ stdout.write(`Keystore private-key aliases: ${entries.map((entry) => entry.alias).join(', ')}\n`);
176
+ const alias = (await rl.question('Signing alias: ')).trim();
177
+ const selected = entries.find((entry) => entry.alias === alias);
178
+ if (!selected) throw new Error(`Unknown signing alias: ${alias}`);
179
+ return selected;
180
+ } finally {
181
+ rl.close();
182
+ }
183
+ }
184
+
185
+ async function main() {
186
+ const parsed = parseArguments(process.argv.slice(2));
187
+ if (parsed.help) return showHelp();
188
+ const options = parsed.options;
189
+ options.storage = options.storage || 'telegram';
190
+ if (!['telegram', 'local'].includes(options.storage)) {
191
+ throw new Error('--storage must be either telegram or local.');
192
+ }
193
+ options['telegram-config'] = options['telegram-config'] || 'gateway';
194
+ if (!['gateway', 'local'].includes(options['telegram-config'])) {
195
+ throw new Error('--telegram-config must be either gateway or local.');
196
+ }
197
+ const rawInput = await collectInput(options);
198
+ const input = await validateInput(rawInput);
199
+
200
+ const password = process.env.STOREBASE_KEYSTORE_PASSWORD ?? await askHidden('Keystore password (input hidden)');
201
+ const entries = await inspectKeystore(input.keystorePath, password);
202
+ const entry = await selectEntry(entries, input.alias);
203
+ const certificate = verifyApkSignature(input.apkPath, entry);
204
+ if (options.storage === 'local') {
205
+ if (input.androidVersionCode !== undefined || input.deltaPath) {
206
+ throw new Error('StoreBase Android update metadata and delta artifacts must be published through Telegram storage.');
207
+ }
208
+ const result = await releaseApp(input, {
209
+ catalogPath: options.catalog,
210
+ assetsDir: options['assets-dir'],
211
+ signingCertificateSha256: certificate,
212
+ });
213
+ stdout.write(`\nLocal release complete: ${result.app.name} ${result.app.version}\n`);
214
+ stdout.write(`App list: ${result.catalogPath}\n`);
215
+ stdout.write(`APK: ${result.app.apk}\n`);
216
+ stdout.write(`Avatar: ${result.app.avatar}\n`);
217
+ } else {
218
+ const connection = options['telegram-config'] === 'local'
219
+ ? await connectTelegram()
220
+ : await connectTelegramFromGateway(await getPublisherEnvironment());
221
+ try {
222
+ const storage = createTelegramStorage(connection.client, connection.channelEntity, {
223
+ readerClient: connection.readerClient,
224
+ readerChannel: connection.readerChannel,
225
+ writerChannel: connection.channelEntity,
226
+ channelRef: connection.channel,
227
+ });
228
+ const result = await storage.publish(input, certificate);
229
+ stdout.write(`\nTelegram release ${result.idempotent ? 'already exists' : 'published'}: ${input.name} ${input.version}\n`);
230
+ stdout.write(`Channel: ${connection.channel}\n`);
231
+ stdout.write(`Manifest message ID: ${result.messageId}\n`);
232
+ stdout.write(`APK message ID: ${result.manifest.artifacts.apk.messageId}\n`);
233
+ stdout.write(`Avatar message ID: ${result.manifest.artifacts.avatar.messageId}\n`);
234
+ if (result.manifest.artifacts.delta) stdout.write(`Delta message ID: ${result.manifest.artifacts.delta.messageId}\n`);
235
+ } finally {
236
+ await disconnectTelegram(connection);
237
+ }
238
+ }
239
+ stdout.write('The keystore was used only to verify the APK signature; it was not copied.\n');
240
+ }
241
+
242
+ main().catch((error) => {
243
+ process.stderr.write(`storebase-publish: ${error.message}\n`);
244
+ process.exitCode = 1;
245
+ });
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ require('dotenv').config();
5
+
6
+ const { stdout, stderr } = require('node:process');
7
+ const {
8
+ connectTelegramFromGateway,
9
+ connectTelegramReaderFromGateway,
10
+ } = require('../lib/gateway-client');
11
+ const { getPublisherEnvironment } = require('../lib/publisher-token');
12
+ const {
13
+ connectTelegram,
14
+ connectTelegramReader,
15
+ disconnectTelegram,
16
+ } = require('../lib/telegram-client');
17
+ const { createTelegramStorage } = require('../lib/telegram-storage');
18
+
19
+ function showHelp() {
20
+ stdout.write(`StoreBase Telegram catalog tools
21
+
22
+ Usage:
23
+ storebase-telegram list [--telegram-config gateway|local]
24
+ storebase-telegram metadata <app-id> [--version <version>] [--telegram-config gateway|local]
25
+ storebase-telegram download <app-id> [--version <version>] [--kind apk|avatar|delta|all] [--out <directory>] [--telegram-config gateway|local]
26
+ storebase-telegram pin <app-id> <version> [--telegram-config gateway|local]
27
+
28
+ Catalog reads use the read-only gateway bootstrap. pin and APK publishing use the
29
+ separate token-protected publisher bootstrap. With no --version, metadata and
30
+ downloads use the app's pinned default, or the newest release if none is pinned.
31
+ Gateway settings are decrypted in memory only.
32
+ `);
33
+ }
34
+
35
+ function parseOptions(args) {
36
+ const options = {};
37
+ for (let index = 0; index < args.length; index += 1) {
38
+ const arg = args[index];
39
+ if (!arg.startsWith('--')) throw new Error(`Unexpected argument: ${arg}`);
40
+ const equals = arg.indexOf('=');
41
+ const key = arg.slice(2, equals < 0 ? undefined : equals);
42
+ if (!['version', 'kind', 'out', 'telegram-config'].includes(key)) throw new Error(`Unknown option: --${key}`);
43
+ const value = equals < 0 ? args[++index] : arg.slice(equals + 1);
44
+ if (typeof value !== 'string' || !value.trim() || value.startsWith('--')) throw new Error(`Option --${key} requires a value.`);
45
+ options[key] = value;
46
+ }
47
+ return options;
48
+ }
49
+
50
+ async function releaseConnection(mode, needsWrite) {
51
+ if (mode === 'local') return needsWrite ? connectTelegram() : connectTelegramReader();
52
+ if (mode !== 'gateway') throw new Error('--telegram-config must be either gateway or local.');
53
+ return needsWrite
54
+ ? connectTelegramFromGateway(await getPublisherEnvironment())
55
+ : connectTelegramReaderFromGateway();
56
+ }
57
+
58
+ async function main() {
59
+ const [command, ...args] = process.argv.slice(2);
60
+ if (!command || command === '--help' || command === '-h') return showHelp();
61
+ if (!['list', 'metadata', 'download', 'pin'].includes(command)) throw new Error(`Unknown command: ${command}`);
62
+ const needsApp = ['metadata', 'download', 'pin'].includes(command);
63
+ const appId = needsApp ? args.shift() : undefined;
64
+ if (needsApp && (!appId || appId.startsWith('--'))) throw new Error(`${command} requires an app ID.`);
65
+ const pinVersion = command === 'pin' ? args.shift() : undefined;
66
+ if (command === 'pin' && (!pinVersion || pinVersion.startsWith('--'))) throw new Error('pin requires an app ID and version.');
67
+ const options = parseOptions(args);
68
+ const mode = options['telegram-config'] || 'gateway';
69
+ const connection = await releaseConnection(mode, command === 'pin');
70
+ try {
71
+ const storage = createTelegramStorage(connection.client, connection.channelEntity, {
72
+ readerClient: connection.readerClient,
73
+ readerChannel: connection.readerChannel,
74
+ writerChannel: connection.channelEntity,
75
+ });
76
+ if (command === 'list') {
77
+ const apps = await storage.getCatalog();
78
+ if (!apps.length) {
79
+ stdout.write('No StoreBase releases were found in the configured Telegram channel.\n');
80
+ return;
81
+ }
82
+ const rows = apps.map(({ appId: id, app, defaultVersion, defaultPinned, versions }) => ({
83
+ appId: id,
84
+ name: app.name,
85
+ category: app.category,
86
+ defaultVersion,
87
+ defaultPinned,
88
+ versions: versions.map(({ manifest, messageId }) => ({
89
+ version: manifest.version,
90
+ publishedAt: manifest.publishedAt,
91
+ manifestMessageId: messageId,
92
+ })),
93
+ }));
94
+ stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
95
+ return;
96
+ }
97
+ if (command === 'pin') {
98
+ const result = await storage.pinVersion(appId, pinVersion);
99
+ stdout.write(`Default version ${result.idempotent ? 'already set' : 'set'}: ${appId} ${pinVersion}\n`);
100
+ stdout.write(`Telegram message ID: ${result.messageId}\n`);
101
+ return;
102
+ }
103
+ if (command === 'metadata') {
104
+ const [release, apps] = await Promise.all([storage.getMetadata(appId, options.version), storage.getCatalog()]);
105
+ const app = apps.find((entry) => entry.appId === appId);
106
+ stdout.write(`${JSON.stringify({ ...release, defaultVersion: app?.defaultVersion }, null, 2)}\n`);
107
+ return;
108
+ }
109
+ const kind = options.kind || 'all';
110
+ const result = await storage.download(appId, options.version, kind, options.out || './storebase-downloads');
111
+ stdout.write(`Release: ${result.release.manifest.app.name} ${result.release.manifest.version}\n`);
112
+ for (const artifact of result.downloaded) stdout.write(`${artifact.kind}: ${artifact.path} (${artifact.size} bytes, sha256 ${artifact.sha256})\n`);
113
+ } finally {
114
+ await disconnectTelegram(connection);
115
+ }
116
+ }
117
+
118
+ main().catch((error) => {
119
+ stderr.write(`storebase-telegram: ${error.message}\n`);
120
+ process.exitCode = 1;
121
+ });