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
package/README.md
CHANGED
|
@@ -1,41 +1,57 @@
|
|
|
1
1
|
# StoreBase App Publisher CLI
|
|
2
2
|
|
|
3
|
-
A
|
|
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
|
-
|
|
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
|
-
|
|
37
|
+
## Runtime configuration
|
|
19
38
|
|
|
20
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
112
|
+
### Where secrets are stored
|
|
55
113
|
|
|
56
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
package/bin/storebase-publish.js
CHANGED
|
@@ -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
|
|
38
|
-
}
|
|
45
|
+
stdout.write(`StoreBase app publisher
|
|
39
46
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
+
});
|