storebase-cli 1.0.0 → 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.
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+ const { stdin, stdout } = require('node:process');
3
+ const TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,256}$/;
4
+
5
+ function fail(message) {
6
+ throw new Error(message);
7
+ }
8
+
9
+ function readHiddenToken(input = stdin, output = stdout) {
10
+ if (!input.isTTY || typeof input.setRawMode !== 'function' || !output.isTTY) {
11
+ return Promise.reject(new Error('Set STOREBASE_GATEWAY_PUBLISH_TOKEN in the environment for non-interactive use.'));
12
+ }
13
+ return new Promise((resolve, reject) => {
14
+ let value = '';
15
+ let finished = false;
16
+ const cleanup = () => {
17
+ input.removeListener('data', onData);
18
+ try { input.setRawMode(false); } catch {}
19
+ input.pause();
20
+ output.write('\n');
21
+ };
22
+ const finish = (error) => {
23
+ if (finished) return;
24
+ finished = true;
25
+ cleanup();
26
+ if (error) reject(error);
27
+ else resolve(value);
28
+ };
29
+ const onData = (chunk) => {
30
+ for (const character of String(chunk)) {
31
+ if (character === '\u0003') return finish(new Error('Cancelled.'));
32
+ if (character === '\r' || character === '\n') return finish();
33
+ if (character === '\u007f' || character === '\b') value = value.slice(0, -1);
34
+ else if (character >= ' ' && value.length < 256) value += character;
35
+ }
36
+ };
37
+ output.write('StoreBase gateway publisher key (input hidden): ');
38
+ input.setRawMode(true);
39
+ input.setEncoding('utf8');
40
+ input.resume();
41
+ input.on('data', onData);
42
+ });
43
+ }
44
+
45
+ async function getPublisherEnvironment(env = process.env, input = stdin, output = stdout) {
46
+ let token = env.STOREBASE_GATEWAY_PUBLISH_TOKEN;
47
+ if (token === undefined || token === '') token = await readHiddenToken(input, output);
48
+ if (typeof token !== 'string' || !TOKEN_PATTERN.test(token)) {
49
+ fail('STOREBASE_GATEWAY_PUBLISH_TOKEN must be a high-entropy 32–256 character token using letters, numbers, underscores, or hyphens.');
50
+ }
51
+ const runtimeEnv = { STOREBASE_GATEWAY_PUBLISH_TOKEN: token };
52
+ if (typeof env.STOREBASE_GATEWAY_URL === 'string' && env.STOREBASE_GATEWAY_URL) {
53
+ runtimeEnv.STOREBASE_GATEWAY_URL = env.STOREBASE_GATEWAY_URL;
54
+ }
55
+ return runtimeEnv;
56
+ }
57
+
58
+ module.exports = { TOKEN_PATTERN, getPublisherEnvironment, readHiddenToken };
package/lib/publisher.js CHANGED
@@ -89,6 +89,23 @@ async function validateInput(raw, options = {}) {
89
89
  if (!/^[A-Za-z0-9][A-Za-z0-9.+_-]{0,63}$/.test(version)) {
90
90
  fail('Version is required and must be 1–64 safe characters (letters, numbers, dot, plus, underscore, or hyphen).');
91
91
  }
92
+ const id = slugify(name);
93
+ const androidVersionCode = raw.androidVersionCode === undefined || raw.androidVersionCode === ''
94
+ ? undefined : Number(raw.androidVersionCode);
95
+ const minimumAndroidVersionCode = raw.minimumAndroidVersionCode === undefined || raw.minimumAndroidVersionCode === ''
96
+ ? 0 : Number(raw.minimumAndroidVersionCode);
97
+ if (id === 'storebase' && !Number.isSafeInteger(androidVersionCode)) {
98
+ fail('StoreBase Telegram releases require --android-version-code.');
99
+ }
100
+ if (id !== 'storebase' && androidVersionCode !== undefined) {
101
+ fail('Android updater version metadata is reserved for the StoreBase app ID.');
102
+ }
103
+ if ((androidVersionCode !== undefined && (!Number.isSafeInteger(androidVersionCode) || androidVersionCode <= 0 || androidVersionCode > 2147483647))
104
+ || !Number.isSafeInteger(minimumAndroidVersionCode) || minimumAndroidVersionCode < 0
105
+ || (androidVersionCode === undefined && minimumAndroidVersionCode !== 0)
106
+ || (androidVersionCode !== undefined && minimumAndroidVersionCode > androidVersionCode)) {
107
+ fail('Android version codes must be valid positive integers and the minimum cannot exceed the release code.');
108
+ }
92
109
 
93
110
  const description = typeof raw.description === 'string' ? raw.description.trim() : '';
94
111
  if (!description) fail('Description is required.');
@@ -110,10 +127,34 @@ async function validateInput(raw, options = {}) {
110
127
  } finally {
111
128
  await apkHandle.close();
112
129
  }
130
+ let deltaPath;
131
+ let deltaBaseVersionCode;
132
+ if (raw.delta) {
133
+ if (id !== 'storebase') fail('Telegram delta artifacts are currently supported only for the StoreBase Android app.');
134
+ if (!Number.isSafeInteger(androidVersionCode)) fail('A StoreBase delta requires --android-version-code.');
135
+ deltaBaseVersionCode = Number(raw.deltaBaseVersionCode);
136
+ if (!Number.isSafeInteger(deltaBaseVersionCode) || deltaBaseVersionCode <= 0 || deltaBaseVersionCode >= androidVersionCode) {
137
+ fail('--delta-base-version-code must be a positive version lower than --android-version-code.');
138
+ }
139
+ deltaPath = await requireFile(raw.delta, 'Delta patch file', cwd);
140
+ if (path.extname(deltaPath).toLowerCase() !== '.sbd') fail('Delta patch file must have the .sbd extension.');
141
+ const patchFile = await require('node:fs/promises').open(deltaPath, 'r');
142
+ try {
143
+ const patchHeader = Buffer.alloc(2);
144
+ const { bytesRead } = await patchFile.read(patchHeader, 0, 2, 0);
145
+ if (bytesRead !== 2 || patchHeader[0] !== 0x1f || patchHeader[1] !== 0x8b) {
146
+ fail('Delta patch must be a gzip-compressed SBD file.');
147
+ }
148
+ } finally {
149
+ await patchFile.close();
150
+ }
151
+ } else if (raw.deltaBaseVersionCode !== undefined && raw.deltaBaseVersionCode !== '') {
152
+ fail('--delta-base-version-code requires --delta.');
153
+ }
113
154
  const keystorePath = await requireFile(raw.keystore, 'Signed keystore file', cwd);
114
155
 
115
156
  return {
116
- id: slugify(name),
157
+ id,
117
158
  name,
118
159
  avatarPath,
119
160
  avatarExtension,
@@ -123,6 +164,10 @@ async function validateInput(raw, options = {}) {
123
164
  description,
124
165
  keystorePath,
125
166
  alias: typeof raw.alias === 'string' && raw.alias.trim() ? raw.alias.trim() : undefined,
167
+ androidVersionCode,
168
+ minimumAndroidVersionCode,
169
+ deltaPath,
170
+ deltaBaseVersionCode,
126
171
  };
127
172
  }
128
173
 
@@ -0,0 +1,178 @@
1
+ 'use strict';
2
+
3
+ function fail(message) {
4
+ throw new Error(message);
5
+ }
6
+
7
+ function readTelegramConfig(env = process.env, options = {}) {
8
+ const apiIdText = env.STOREBASE_TELEGRAM_API_ID;
9
+ const apiHash = env.STOREBASE_TELEGRAM_API_HASH;
10
+ const botToken = env.STOREBASE_TELEGRAM_BOT_TOKEN;
11
+ const session = env.STOREBASE_TELEGRAM_SESSION || '';
12
+ const readSession = env.STOREBASE_TELEGRAM_READ_SESSION || '';
13
+ const channelValue = env.STOREBASE_TELEGRAM_CHANNEL;
14
+ const apiId = Number(apiIdText);
15
+
16
+ if (!apiIdText || !Number.isSafeInteger(apiId) || apiId <= 0) {
17
+ fail('Set STOREBASE_TELEGRAM_API_ID to the Telegram application API ID.');
18
+ }
19
+ if (typeof apiHash !== 'string' || !apiHash.trim()) {
20
+ fail('Set STOREBASE_TELEGRAM_API_HASH to the Telegram application API hash.');
21
+ }
22
+ if (options.requireChannel !== false && (!channelValue || !String(channelValue).trim())) {
23
+ fail('Set STOREBASE_TELEGRAM_CHANNEL to the target channel username (for example @StoreBaseApps) or channel ID.');
24
+ }
25
+ if (!session && !botToken) {
26
+ fail('Set STOREBASE_TELEGRAM_BOT_TOKEN for initial MTProto bot authorization, or provide an authorized STOREBASE_TELEGRAM_SESSION.');
27
+ }
28
+ if (options.requireReader !== false && !readSession) {
29
+ fail('Set STOREBASE_TELEGRAM_READ_SESSION to an authorized personal MTProto StringSession with read access to the channel. Telegram bots cannot read channel history.');
30
+ }
31
+
32
+ return {
33
+ apiId,
34
+ apiHash: apiHash.trim(),
35
+ botToken: botToken?.trim(),
36
+ session,
37
+ readSession,
38
+ channel: channelValue ? String(channelValue).trim() : undefined,
39
+ };
40
+ }
41
+
42
+ function createClient(StringSession, TelegramClient, session, config) {
43
+ return new TelegramClient(new StringSession(session), config.apiId, config.apiHash, {
44
+ connectionRetries: 5,
45
+ autoReconnect: true,
46
+ });
47
+ }
48
+
49
+ function requireBroadcastChannel(entity, label) {
50
+ if (!entity || entity.className !== 'Channel' || entity.broadcast !== true) {
51
+ fail(`${label} must resolve to a Telegram broadcast channel.`);
52
+ }
53
+ }
54
+
55
+ async function disconnectTelegram(connection) {
56
+ if (!connection) return;
57
+ const clients = [...new Set([connection.client, connection.readerClient].filter(Boolean))];
58
+ await Promise.allSettled(clients.map((client) => client.disconnect()));
59
+ }
60
+
61
+ async function disconnectTelegramReader(connection) {
62
+ if (!connection?.client) return;
63
+ await Promise.allSettled([connection.client.disconnect()]);
64
+ }
65
+
66
+ function readTelegramReaderConfig(env = process.env) {
67
+ const apiIdText = env.STOREBASE_TELEGRAM_API_ID;
68
+ const apiHash = env.STOREBASE_TELEGRAM_API_HASH;
69
+ const readSession = env.STOREBASE_TELEGRAM_READ_SESSION;
70
+ const channelValue = env.STOREBASE_TELEGRAM_CHANNEL;
71
+ const apiId = Number(apiIdText);
72
+ if (!apiIdText || !Number.isSafeInteger(apiId) || apiId <= 0) {
73
+ fail('Set STOREBASE_TELEGRAM_API_ID to the Telegram application API ID.');
74
+ }
75
+ if (typeof apiHash !== 'string' || !apiHash.trim()) {
76
+ fail('Set STOREBASE_TELEGRAM_API_HASH to the Telegram application API hash.');
77
+ }
78
+ if (typeof readSession !== 'string' || !readSession.trim()) {
79
+ fail('Set STOREBASE_TELEGRAM_READ_SESSION to an authorized personal MTProto StringSession with read access to the channel.');
80
+ }
81
+ if (!channelValue || !String(channelValue).trim()) {
82
+ fail('Set STOREBASE_TELEGRAM_CHANNEL to the target channel username or channel ID.');
83
+ }
84
+ return { apiId, apiHash: apiHash.trim(), readSession: readSession.trim(), channel: String(channelValue).trim() };
85
+ }
86
+
87
+ async function connectTelegramReader(env = process.env) {
88
+ const config = readTelegramReaderConfig(env);
89
+ const { TelegramClient } = require('teleproto');
90
+ const { StringSession } = require('teleproto/sessions');
91
+ const client = createClient(StringSession, TelegramClient, config.readSession, config);
92
+ try {
93
+ await client.connect();
94
+ if (!await client.checkAuthorization()) {
95
+ fail('STOREBASE_TELEGRAM_READ_SESSION is not authorized. Supply a valid user StringSession with access to the StoreBase channel.');
96
+ }
97
+ if (typeof client.isBot === 'function' && await client.isBot()) {
98
+ fail('STOREBASE_TELEGRAM_READ_SESSION must be a personal user session. Bot sessions cannot read channel history.');
99
+ }
100
+ const channelEntity = await client.getEntity(config.channel);
101
+ requireBroadcastChannel(channelEntity, 'STOREBASE_TELEGRAM_CHANNEL as seen by the reader session');
102
+ await client.getMessages(channelEntity, { limit: 1 });
103
+ return { client, channelEntity, channel: config.channel };
104
+ } catch (error) {
105
+ await disconnectTelegramReader({ client });
106
+ throw error;
107
+ }
108
+ }
109
+
110
+ async function connectTelegram(env = process.env, options = {}) {
111
+ const config = readTelegramConfig(env, options);
112
+ const { TelegramClient } = require('teleproto');
113
+ const { StringSession } = require('teleproto/sessions');
114
+ const client = createClient(StringSession, TelegramClient, config.session, config);
115
+ let readerClient;
116
+
117
+ try {
118
+ if (config.session) {
119
+ await client.connect();
120
+ const authorized = await client.checkAuthorization();
121
+ if (!authorized) {
122
+ if (!config.botToken) fail('The saved Telegram bot session is not authorized. Set STOREBASE_TELEGRAM_BOT_TOKEN to authenticate it again.');
123
+ await client.start({ botAuthToken: config.botToken });
124
+ }
125
+ } else {
126
+ await client.start({ botAuthToken: config.botToken });
127
+ }
128
+
129
+ if (typeof client.isBot === 'function' && !await client.isBot()) {
130
+ fail('STOREBASE_TELEGRAM_SESSION must belong to the StoreBase admin bot; use STOREBASE_TELEGRAM_READ_SESSION for a personal user session.');
131
+ }
132
+ const channelEntity = config.channel ? await client.getEntity(config.channel) : undefined;
133
+ if (config.channel) requireBroadcastChannel(channelEntity, 'STOREBASE_TELEGRAM_CHANNEL');
134
+
135
+ let readerChannel;
136
+ if (options.requireReader !== false) {
137
+ readerClient = createClient(StringSession, TelegramClient, config.readSession, config);
138
+ await readerClient.connect();
139
+ if (!await readerClient.checkAuthorization()) {
140
+ fail('STOREBASE_TELEGRAM_READ_SESSION is not authorized. Supply a valid user StringSession with access to the StoreBase channel.');
141
+ }
142
+ if (typeof readerClient.isBot === 'function' && await readerClient.isBot()) {
143
+ fail('STOREBASE_TELEGRAM_READ_SESSION must be a personal user session. Bot sessions cannot read channel history.');
144
+ }
145
+ readerChannel = await readerClient.getEntity(config.channel);
146
+ requireBroadcastChannel(readerChannel, 'STOREBASE_TELEGRAM_CHANNEL as seen by the reader session');
147
+ if (String(readerChannel.id) !== String(channelEntity.id)) {
148
+ fail('The bot and reader sessions resolved STOREBASE_TELEGRAM_CHANNEL to different channels.');
149
+ }
150
+ // Bot accounts are barred from reading channel history; a personal MTProto session
151
+ // performs this read-only check and handles catalog listing and artifact retrieval.
152
+ await readerClient.getMessages(readerChannel, { limit: 1 });
153
+ }
154
+
155
+ return {
156
+ client,
157
+ readerClient,
158
+ channelEntity,
159
+ readerChannel,
160
+ channel: config.channel,
161
+ session: client.session.save(),
162
+ readSession: config.readSession,
163
+ hasStoredSession: Boolean(config.session),
164
+ };
165
+ } catch (error) {
166
+ await disconnectTelegram({ client, readerClient });
167
+ throw error;
168
+ }
169
+ }
170
+
171
+ module.exports = {
172
+ connectTelegram,
173
+ connectTelegramReader,
174
+ disconnectTelegram,
175
+ disconnectTelegramReader,
176
+ readTelegramConfig,
177
+ readTelegramReaderConfig,
178
+ };