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 CHANGED
@@ -1,41 +1,57 @@
1
1
  # StoreBase App Publisher CLI
2
2
 
3
- A dependency-free npm CLI for validating and releasing a signed Android APK into a local StoreBase app-list catalog. This package is intentionally isolated under `cli/`; it does not change the StoreBase frontend.
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.
4
6
 
5
7
  ## Requirements
6
8
 
7
9
  - Node.js 18 or newer
8
- - Java `keytool`
10
+ - Java 21 or newer `keytool` for the signing-profile import workflow (tested with Java 21; supports password delivery through `:env`)
9
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
+ ```
10
30
 
11
- ## Install from this repository
31
+ To install this checkout globally:
12
32
 
13
33
  ```sh
14
34
  npm install --global ./cli
15
- storebase-publish
16
35
  ```
17
36
 
18
- The CLI prompts for the app name, avatar image, version, APK, category, description, and signing keystore. The supported categories match StoreBase Explore:
37
+ ## Runtime configuration
19
38
 
20
- - Education
21
- - Social
22
- - Productivity
23
- - Communication
24
- - Entertainment
25
- - Games
26
- - Business
27
- - Shopping
28
- - Finance
29
- - Utilities
30
- - Health & Fitness
31
- - Travel & Navigation
32
- - AI & Machine Learning
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`.
33
40
 
34
- The keystore password is requested with hidden terminal input. For automation, set `STOREBASE_KEYSTORE_PASSWORD` in the process environment instead. **Do not put the password in a command-line argument.** If the keystore contains more than one private-key alias, the CLI asks which alias to use (or accepts `--alias`).
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.
35
49
 
36
- The CLI runs `keytool` and `apksigner`, and refuses to add the release unless the APK is signed and its signer certificate matches the selected private-key certificate in the provided keystore. The password is written briefly to a mode-0600 temporary file for `keytool`, then the file is overwritten best-effort and removed. The keystore itself is never copied to the catalog.
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.
37
51
 
38
- ## Non-interactive example
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.
39
55
 
40
56
  ```sh
41
57
  STOREBASE_KEYSTORE_PASSWORD="$KEYSTORE_PASSWORD" storebase-publish \
@@ -49,18 +65,85 @@ STOREBASE_KEYSTORE_PASSWORD="$KEYSTORE_PASSWORD" storebase-publish \
49
65
  --alias release
50
66
  ```
51
67
 
52
- Available options: `--name`, `--avatar`, `--version`, `--apk`, `--category`, `--description`, `--keystore`, `--alias`, `--catalog`, `--assets-dir`, and `--help`.
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
+ ## Import a signing keystore once
77
+
78
+ Version 1.2 adds a local encrypted signing vault. Create a named profile once, then select that profile for any app tag or release instead of repeatedly supplying a keystore file:
79
+
80
+ ```sh
81
+ storebase-keystore import release --file /path/to/release.jks --alias release
82
+ storebase-keystore list
83
+ storebase-publish --signing-profile release \
84
+ --name "Example App" --version 2.0.0 --apk ./app-release.apk \
85
+ --avatar ./icon.png --category Productivity --description "Updated release."
86
+ ```
87
+
88
+ Import prompts without echo for a vault passphrase (at least 12 characters, confirmed on creation), the keystore password, and the private-key password. Press Enter at the private-key prompt to reuse the keystore password. Use a long, unique vault passphrase; there is no saved unlock key or recovery service. Profile operations and profile-based publishing require an interactive terminal. Password arguments, password files, and environment variables are not accepted by these new profile commands. The older one-off `--keystore` workflow remains compatible, including its legacy runtime password variable; do not save signing passwords in `.env` or shell history.
89
+
90
+ Java `keytool` validates the imported keystore, private-key alias, private-key password, and certificate. Multiple private-key entries require `--alias`. The CLI stores an encrypted copy rather than a source path, so the source keystore need not remain at the original location. `--signing-profile` cannot be combined with `--keystore` or `--alias`. Profiles are explicit, not automatically tied to app names or selected as a default.
91
+
92
+ Publishing continues to **verify an already-signed APK**, not sign or re-sign it. It compares the APK certificate to the certificate validated when the profile was imported. Profile-based publishing never extracts the stored private key to disk. A different signer is rejected. Neither the keystore nor its passwords are uploaded to Telegram or the gateway; only the public certificate fingerprint belongs in release metadata.
93
+
94
+ ### Update, rotate, or remove
95
+
96
+ ```sh
97
+ # Update passwords or replace the stored key after validating the replacement
98
+ storebase-keystore import release --file /path/to/replacement.jks --alias release --replace
99
+
100
+ # Re-encrypt every profile under a new vault passphrase
101
+ storebase-keystore rekey
102
+
103
+ # Delete a profile from the current encrypted vault
104
+ storebase-keystore remove release
105
+
106
+ # Print the location without unlocking the vault
107
+ storebase-keystore location
108
+ ```
109
+
110
+ Replacing and removing profiles require an explicit typed confirmation. Replacement validates the new keystore before atomically updating the vault; a failed import leaves the old profile intact. Changing the vault passphrase does not change the keystore's own passwords or signing key. Replacing a key does not generate Android signing lineage, change Play App Signing, or make existing installations accept a different certificate. Keep the old key backed up before rotation; otherwise updates to existing installs may become impossible.
53
111
 
54
- ## Output
112
+ ### Where secrets are stored
55
113
 
56
- By default, running from a project directory creates or updates:
114
+ The vault is `signing-vault.json` under `~/.local/share/storebase-cli/` on Linux, `~/Library/Application Support/storebase-cli/` on macOS, or `~/AppData/Local/storebase-cli/` on Windows. The CLI does not use the current project, repository, npm install directory, Telegram, or an environment-selected path for its production vault. It does not use an OS keychain: the separate master passphrase is requested for every command and is never stored.
57
115
 
58
- - `storebase-app-list.json` — schema version 1, with one latest-version listing per app name/slug.
59
- - `storebase-app-assets/<app-id>/<version>/` — content-addressed APK and avatar files referenced by the JSON manifest.
116
+ The complete profile collection—including keystore bytes, store password, private-key password, alias, and certificate—is encrypted with AES-256-GCM. A fresh random salt and nonce are used for each write; scrypt derives the encryption key from the master passphrase (N=32768, r=8, p=1). Authentication detects wrong passphrases and modified vaults. Writes are locked and atomic. On POSIX, the dedicated directory is mode 0700 and files are mode 0600; unsafe permissions, ownership, or symlink paths are refused. On Windows, keep the user-data directory restricted to your account using Windows permissions; ciphertext encryption is not a substitute for protecting the account.
60
117
 
61
- The catalog entry contains the app name, avatar path, version, APK path, category, description, the public SHA-256 signing-certificate fingerprint, and publish timestamp. Releasing the same app again updates its catalog entry; a different version gets a separate artifact path. The previous artifacts are not removed.
118
+ Import briefly creates a private mode-0700 temporary directory containing a mode-0600 copy of the keystore so Java can validate exactly the bytes being imported. It overwrites that copy best-effort and removes the directory in `finally`, on success or failure. Passwords are passed to Java through its child-process environment, never arguments, logs, or plaintext password files. Decrypted data exists in process memory while unlocked. Buffers are cleared best-effort, but JavaScript strings, OS swap, privileged process inspection, crashes, and malicious code running as your user cannot be made safe by this vault. Temporary files may remain after an uncatchable termination; remove orphaned `storebase-signing-import-*` directories from your OS temporary directory securely.
119
+
120
+ Keep encrypted-vault backups and your master passphrase safe and separate. Losing the passphrase cannot be recovered by the CLI. Removing a profile or rekeying replaces the current vault but does not erase originals, old snapshots, filesystem journals, or backups; no forensic erasure is promised. The npm file allowlist contains only CLI JavaScript and documentation, never local vaults, `.env`, `.jks`, `.keystore`, `.p12`, or `.pfx` files.
121
+
122
+ ## Telegram storage and versions
123
+
124
+ 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.
125
+
126
+ 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.
127
+
128
+ 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.
129
+
130
+ ```sh
131
+ # See all apps, versions, and each app's default version
132
+ storebase-telegram list
133
+
134
+ # Select a default version; this adds a small metadata record in Telegram
135
+ storebase-telegram pin storebase 1.2.0
136
+
137
+ # Omit --version to use the pinned default (or newest release if unpinned)
138
+ storebase-telegram metadata storebase
139
+ storebase-telegram download storebase --kind apk --out ./downloads
140
+
141
+ # Select a specific historical version
142
+ storebase-telegram metadata storebase --version 1.1.0
143
+ storebase-telegram download storebase --version 1.1.0 --kind all --out ./downloads
144
+ ```
62
145
 
63
- The current StoreBase repository has no app-list API or deployment endpoint. Therefore this CLI creates a **local app-list release artifact only**; it does not upload to a server, publish the npm package, or make the app appear in the existing UI yet. A later frontend/backend change can consume `storebase-app-list.json` without changing this CLI's input contract.
146
+ Downloads are size- and SHA-256-verified. Filenames include the app ID, version, artifact kind, and content hash to avoid replacing a different release.
64
147
 
65
148
  ## Development
66
149
 
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const fs = require('node:fs/promises');
4
+ const { SigningVault, vaultPath } = require('../lib/signing-vault');
5
+ const { hidden, question, newPassphrase } = require('../lib/secure-prompt');
6
+ const { profileName, importProfile, clearProfiles } = require('../lib/signing-profiles');
7
+
8
+ function parseArguments(args) {
9
+ if (!args.length || args.includes('--help') || args.includes('-h')) return { command: 'help' };
10
+ const [command, ...rest] = args;
11
+ if (!['import', 'list', 'remove', 'rekey', 'location'].includes(command)) throw new Error('Unknown keystore command. Use --help.');
12
+ const result = { command };
13
+ if (['import', 'remove'].includes(command)) result.name = profileName(rest.shift());
14
+ while (rest.length) {
15
+ const arg = rest.shift();
16
+ if (command === 'import' && arg === '--replace' && !result.replace) result.replace = true;
17
+ else if (command === 'import' && ['--file', '--alias'].includes(arg)) {
18
+ const key = arg.slice(2);
19
+ if (result[key] !== undefined || !rest[0] || rest[0].startsWith('--')) throw new Error('Invalid or duplicate keystore option.');
20
+ result[key] = rest.shift();
21
+ } else throw new Error('Unexpected keystore argument. Password arguments are not supported.');
22
+ }
23
+ if (command === 'import' && !result.file) throw new Error('Import requires --file <keystore>.');
24
+ return result;
25
+ }
26
+
27
+ async function main(args = process.argv.slice(2)) {
28
+ const opts = parseArguments(args);
29
+ if (opts.command === 'help') {
30
+ process.stdout.write(`StoreBase encrypted signing profiles\n\nCommands:\n storebase-keystore import <profile> --file <keystore> [--alias <alias>] [--replace]\n storebase-keystore list\n storebase-keystore remove <profile>\n storebase-keystore rekey\n storebase-keystore location\n\nSecrets are collected only through hidden terminal prompts. The encrypted vault is\noutside the project in your user data directory. Its master passphrase is never saved.\nUse --signing-profile <profile> with storebase-publish to reuse a verified certificate.\nImport --replace replaces a profile; it does not create Android signing lineage.\n`);
31
+ return;
32
+ }
33
+ if (opts.command === 'location') { process.stdout.write(`${vaultPath()}\n`); return; }
34
+ const vault = new SigningVault();
35
+ let exists = true;
36
+ try { await fs.lstat(vaultPath()); } catch (e) { if (e.code === 'ENOENT') exists = false; else throw e; }
37
+ if (!exists && opts.command !== 'import') throw new Error('No signing vault exists. Import a keystore first.');
38
+ let passphrase = exists ? await hidden('Vault passphrase') : await newPassphrase();
39
+ let data;
40
+ let imported;
41
+ try {
42
+ data = await vault.read(passphrase);
43
+ if (opts.command === 'list') {
44
+ for (const name of Object.keys(data.profiles).sort()) {
45
+ const p = data.profiles[name];
46
+ process.stdout.write(`${name}\talias=${JSON.stringify(p.alias)}\tSHA256=${p.fingerprint}\n`);
47
+ }
48
+ if (!Object.keys(data.profiles).length) process.stdout.write('No signing profiles stored.\n');
49
+ } else if (opts.command === 'import') {
50
+ const replacing = Object.hasOwn(data.profiles, opts.name);
51
+ if (replacing && !opts.replace) throw new Error('Profile already exists. Use --replace to explicitly rotate or update it.');
52
+ if (opts.replace) {
53
+ if (!replacing) throw new Error('Cannot replace a profile that does not exist.');
54
+ process.stdout.write('Replacing a signing key may prevent Android updates to existing installs. Keep a secure backup of the old key.\n');
55
+ if (await question(`Type REPLACE ${opts.name} to continue: `) !== `REPLACE ${opts.name}`) throw new Error('Cancelled.');
56
+ }
57
+ let storePassword = await hidden('Keystore password');
58
+ let keyPassword = await hidden('Private-key password (Enter to reuse keystore password)');
59
+ if (!keyPassword) keyPassword = storePassword;
60
+ try { imported = await importProfile(opts.file, storePassword, keyPassword, opts.alias); }
61
+ finally { storePassword = ''; keyPassword = ''; }
62
+ await vault.mutate(passphrase, (current) => {
63
+ if (JSON.stringify(current.profiles[opts.name]) !== JSON.stringify(data.profiles[opts.name])) throw new Error('Profile changed concurrently. Retry.');
64
+ current.profiles[opts.name] = imported;
65
+ });
66
+ process.stdout.write(`Signing profile ${opts.replace ? 'replaced' : 'imported'}. Original keystore was not changed or uploaded.\n`);
67
+ } else if (opts.command === 'remove') {
68
+ if (!Object.hasOwn(data.profiles, opts.name)) throw new Error('Signing profile not found.');
69
+ if (await question(`Type REMOVE ${opts.name} to delete its stored keystore and passwords: `) !== `REMOVE ${opts.name}`) throw new Error('Cancelled.');
70
+ await vault.mutate(passphrase, (current) => {
71
+ if (JSON.stringify(current.profiles[opts.name]) !== JSON.stringify(data.profiles[opts.name])) throw new Error('Profile changed concurrently. Retry.');
72
+ delete current.profiles[opts.name];
73
+ });
74
+ process.stdout.write('Signing profile removed. Original source files and backups are not deleted.\n');
75
+ } else if (opts.command === 'rekey') {
76
+ let replacement = await newPassphrase();
77
+ try { await vault.rekey(passphrase, replacement); } finally { replacement = ''; }
78
+ process.stdout.write('Vault passphrase changed; all profiles re-encrypted.\n');
79
+ }
80
+ } finally {
81
+ passphrase = '';
82
+ clearProfiles(data);
83
+ if (imported) { imported.keystore = ''; imported.storePassword = ''; imported.keyPassword = ''; }
84
+ }
85
+ }
86
+ if (require.main === module) main().catch((error) => {
87
+ process.stderr.write(`storebase-keystore: ${error.message}\n`); process.exitCode = 1;
88
+ });
89
+ module.exports = { parseArguments, main };
@@ -3,6 +3,10 @@
3
3
 
4
4
  const { createInterface } = require('node:readline/promises');
5
5
  const { stdin, stdout } = require('node:process');
6
+ const { connectTelegramFromGateway } = require('../lib/gateway-client');
7
+ const { getPublisherEnvironment } = require('../lib/publisher-token');
8
+ const { loadProfileEntry } = require('../lib/signing-profiles');
9
+ const { hidden } = require('../lib/secure-prompt');
6
10
  const {
7
11
  CATEGORIES,
8
12
  inspectKeystore,
@@ -10,9 +14,13 @@ const {
10
14
  validateInput,
11
15
  verifyApkSignature,
12
16
  } = require('../lib/publisher');
17
+ const { connectTelegram, disconnectTelegram } = require('../lib/telegram-client');
18
+ const { createTelegramStorage } = require('../lib/telegram-storage');
13
19
 
14
20
  const OPTION_NAMES = new Set([
15
- 'name', 'avatar', 'version', 'apk', 'category', 'description', 'keystore', 'alias', 'catalog', 'assets-dir',
21
+ 'name', 'avatar', 'version', 'apk', 'category', 'description', 'keystore', 'alias', 'storage', 'catalog', 'assets-dir',
22
+ 'telegram-config', 'signing-profile',
23
+ 'android-version-code', 'minimum-android-version-code', 'delta', 'delta-base-version-code',
16
24
  ]);
17
25
 
18
26
  function parseArguments(args) {
@@ -34,45 +42,41 @@ function parseArguments(args) {
34
42
  }
35
43
 
36
44
  function showHelp() {
37
- stdout.write(`StoreBase app publisher\n\nUsage:\n storebase-publish [options]\n\nOptions (omitted values are prompted interactively):\n --name <name> App name\n --avatar <file> PNG, JPEG, or WebP avatar file\n --version <version> App version\n --apk <file> Signed Android APK\n --category <category> One of the 13 StoreBase categories\n --description <text> App description\n --keystore <file> Signing keystore used for the APK\n --alias <name> Private-key alias, if the keystore has multiple\n --catalog <file> App-list JSON (default: ./storebase-app-list.json)\n --assets-dir <directory> Artifact directory (default: next to the catalog)\n -h, --help Show this help\n\nThe keystore password is entered in a hidden prompt, or read from\nSTOREBASE_KEYSTORE_PASSWORD for unattended use. It is never accepted as a CLI argument.\n`);
38
- }
45
+ stdout.write(`StoreBase app publisher
39
46
 
40
- function askHidden(label) {
41
- if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
42
- return Promise.reject(new Error('Set STOREBASE_KEYSTORE_PASSWORD in the environment for non-interactive use.'));
43
- }
44
- return new Promise((resolve, reject) => {
45
- let value = '';
46
- let finished = false;
47
- const cleanup = () => {
48
- stdin.removeListener('data', onData);
49
- try { stdin.setRawMode(false); } catch {}
50
- stdin.pause();
51
- stdout.write('\n');
52
- };
53
- const finish = (error) => {
54
- if (finished) return;
55
- finished = true;
56
- cleanup();
57
- if (error) reject(error);
58
- else resolve(value);
59
- };
60
- const onData = (chunk) => {
61
- for (const character of String(chunk)) {
62
- if (character === '\u0003') return finish(new Error('Cancelled.'));
63
- if (character === '\r' || character === '\n') return finish();
64
- if (character === '\u007f' || character === '\b') value = value.slice(0, -1);
65
- else if (character >= ' ') value += character;
66
- }
67
- };
68
- stdout.write(`${label}: `);
69
- stdin.setRawMode(true);
70
- stdin.setEncoding('utf8');
71
- stdin.resume();
72
- stdin.on('data', onData);
73
- });
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
+ --signing-profile <profile> Reuse a profile from storebase-keystore
62
+ --keystore <file> One-off signing keystore (no profile)
63
+ --alias <name> Private-key alias, if the keystore has multiple
64
+ --storage <backend> telegram (default) or local
65
+ --telegram-config <mode> gateway (default) or local direct Telegram settings
66
+ --catalog <file> Local app-list JSON (local mode only)
67
+ --assets-dir <directory> Local artifact directory (local mode only)
68
+ -h, --help Show this help
69
+
70
+ Gateway mode uses STOREBASE_GATEWAY_URL (optional; defaults to the StoreBase gateway)
71
+ and STOREBASE_GATEWAY_PUBLISH_TOKEN. The gateway sends encrypted Telegram settings
72
+ to this run only; settings are decrypted in memory and are never written to disk.
73
+ Use --telegram-config local only when supplying Telegram settings directly in the environment.
74
+ The bot must be a channel administrator. Keystore passwords are entered in a hidden
75
+ prompt or read from STOREBASE_KEYSTORE_PASSWORD.
76
+ `);
74
77
  }
75
78
 
79
+
76
80
  async function collectInput(options) {
77
81
  const required = [
78
82
  ['name', 'App name'],
@@ -83,6 +87,7 @@ async function collectInput(options) {
83
87
  ['description', 'Description'],
84
88
  ['keystore', 'Signed keystore file path'],
85
89
  ];
90
+ if (options['signing-profile']) required.splice(required.findIndex(([key]) => key === 'keystore'), 1);
86
91
  const missing = required.filter(([key]) => !options[key]);
87
92
  if (missing.length && (!stdin.isTTY || !stdout.isTTY)) {
88
93
  throw new Error(`Missing required options: ${missing.map(([key]) => `--${key}`).join(', ')}. Prompts require an interactive terminal.`);
@@ -115,6 +120,10 @@ async function collectInput(options) {
115
120
  description: options.description,
116
121
  keystore: options.keystore,
117
122
  alias: options.alias,
123
+ androidVersionCode: options['android-version-code'],
124
+ minimumAndroidVersionCode: options['minimum-android-version-code'],
125
+ delta: options.delta,
126
+ deltaBaseVersionCode: options['delta-base-version-code'],
118
127
  };
119
128
  }
120
129
 
@@ -141,30 +150,71 @@ async function selectEntry(entries, aliasOption) {
141
150
  }
142
151
 
143
152
  async function main() {
153
+ require('dotenv').config();
144
154
  const parsed = parseArguments(process.argv.slice(2));
145
155
  if (parsed.help) return showHelp();
146
156
  const options = parsed.options;
157
+ options.storage = options.storage || 'telegram';
158
+ if (!['telegram', 'local'].includes(options.storage)) {
159
+ throw new Error('--storage must be either telegram or local.');
160
+ }
161
+ options['telegram-config'] = options['telegram-config'] || 'gateway';
162
+ if (!['gateway', 'local'].includes(options['telegram-config'])) {
163
+ throw new Error('--telegram-config must be either gateway or local.');
164
+ }
165
+ if (options['signing-profile'] && (options.keystore || options.alias)) {
166
+ throw new Error('--signing-profile cannot be combined with --keystore or --alias.');
167
+ }
147
168
  const rawInput = await collectInput(options);
148
- const input = await validateInput(rawInput);
149
-
150
- const password = process.env.STOREBASE_KEYSTORE_PASSWORD ?? await askHidden('Keystore password (input hidden)');
151
- const entries = await inspectKeystore(input.keystorePath, password);
152
- const entry = await selectEntry(entries, input.alias);
169
+ let entry = options['signing-profile'] ? await loadProfileEntry(options['signing-profile']) : undefined;
170
+ const input = await validateInput(rawInput, { signingProfile: entry });
171
+ if (!entry) {
172
+ let password = process.env.STOREBASE_KEYSTORE_PASSWORD ?? await hidden('Keystore password');
173
+ try { entry = await selectEntry(await inspectKeystore(input.keystorePath, password), input.alias); }
174
+ finally { password = ''; }
175
+ }
153
176
  const certificate = verifyApkSignature(input.apkPath, entry);
154
- const result = await releaseApp(input, {
155
- catalogPath: options.catalog,
156
- assetsDir: options['assets-dir'],
157
- signingCertificateSha256: certificate,
158
- });
159
-
160
- stdout.write(`\nRelease complete: ${result.app.name} ${result.app.version}\n`);
161
- stdout.write(`App list: ${result.catalogPath}\n`);
162
- stdout.write(`APK: ${result.app.apk}\n`);
163
- stdout.write(`Avatar: ${result.app.avatar}\n`);
164
- stdout.write('The keystore was used only to verify the APK signature; it was not copied.\n');
177
+ if (options.storage === 'local') {
178
+ if (input.androidVersionCode !== undefined || input.deltaPath) {
179
+ throw new Error('StoreBase Android update metadata and delta artifacts must be published through Telegram storage.');
180
+ }
181
+ const result = await releaseApp(input, {
182
+ catalogPath: options.catalog,
183
+ assetsDir: options['assets-dir'],
184
+ signingCertificateSha256: certificate,
185
+ });
186
+ stdout.write(`\nLocal release complete: ${result.app.name} ${result.app.version}\n`);
187
+ stdout.write(`App list: ${result.catalogPath}\n`);
188
+ stdout.write(`APK: ${result.app.apk}\n`);
189
+ stdout.write(`Avatar: ${result.app.avatar}\n`);
190
+ } else {
191
+ const connection = options['telegram-config'] === 'local'
192
+ ? await connectTelegram()
193
+ : await connectTelegramFromGateway(await getPublisherEnvironment());
194
+ try {
195
+ const storage = createTelegramStorage(connection.client, connection.channelEntity, {
196
+ readerClient: connection.readerClient,
197
+ readerChannel: connection.readerChannel,
198
+ writerChannel: connection.channelEntity,
199
+ channelRef: connection.channel,
200
+ });
201
+ const result = await storage.publish(input, certificate);
202
+ stdout.write(`\nTelegram release ${result.idempotent ? 'already exists' : 'published'}: ${input.name} ${input.version}\n`);
203
+ stdout.write(`Channel: ${connection.channel}\n`);
204
+ stdout.write(`Manifest message ID: ${result.messageId}\n`);
205
+ stdout.write(`APK message ID: ${result.manifest.artifacts.apk.messageId}\n`);
206
+ stdout.write(`Avatar message ID: ${result.manifest.artifacts.avatar.messageId}\n`);
207
+ if (result.manifest.artifacts.delta) stdout.write(`Delta message ID: ${result.manifest.artifacts.delta.messageId}\n`);
208
+ } finally {
209
+ await disconnectTelegram(connection);
210
+ }
211
+ }
212
+ stdout.write('APK signing certificate verified. No keystore or signing passwords were uploaded.\n');
165
213
  }
166
214
 
167
- main().catch((error) => {
215
+ if (require.main === module) main().catch((error) => {
168
216
  process.stderr.write(`storebase-publish: ${error.message}\n`);
169
217
  process.exitCode = 1;
170
218
  });
219
+
220
+ module.exports = { parseArguments, collectInput };
@@ -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
+ });