storebase-cli 1.1.0 → 1.3.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 +58 -1
- package/bin/storebase-keystore.js +89 -0
- package/bin/storebase-publish.js +21 -46
- package/bin/storebase-skill.js +63 -0
- package/lib/publisher.js +24 -29
- package/lib/secure-prompt.js +60 -0
- package/lib/signing-profiles.js +83 -0
- package/lib/signing-vault.js +372 -0
- package/package.json +11 -5
- package/skills/storebase/SKILL.md +88 -0
- package/skills/storebase/references/cli-workflows.md +171 -0
- package/skills/storebase/references/runtime-and-android.md +58 -0
- package/skills/storebase/references/updates-and-development.md +75 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs/promises');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
const { promisify } = require('node:util');
|
|
8
|
+
|
|
9
|
+
const scrypt = promisify(crypto.scrypt);
|
|
10
|
+
const VERSION = 1;
|
|
11
|
+
const SCRYPT_N = 32768;
|
|
12
|
+
const SCRYPT_R = 8;
|
|
13
|
+
const SCRYPT_P = 1;
|
|
14
|
+
const SCRYPT_MAXMEM = 64 * 1024 * 1024;
|
|
15
|
+
const MAX_FILE_SIZE = 32 * 1024 * 1024;
|
|
16
|
+
const MAX_PAYLOAD_SIZE = 31 * 1024 * 1024;
|
|
17
|
+
const MAX_KEYSTORE_SIZE = 8 * 1024 * 1024;
|
|
18
|
+
const AAD = Buffer.from('storebase-signing-vault:v1:scrypt-32768-8-1:aes-256-gcm', 'utf8');
|
|
19
|
+
const UNLOCK_ERROR = 'Signing vault could not be unlocked; passphrase may be incorrect or vault data is damaged.';
|
|
20
|
+
const LOCK_ERROR = 'Signing vault is locked by another operation; retry after it finishes.';
|
|
21
|
+
|
|
22
|
+
function vaultPath() {
|
|
23
|
+
const home = os.homedir();
|
|
24
|
+
if (process.platform === 'win32') return path.join(home, 'AppData', 'Local', 'storebase-cli', 'signing-vault.json');
|
|
25
|
+
if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'storebase-cli', 'signing-vault.json');
|
|
26
|
+
return path.join(home, '.local', 'share', 'storebase-cli', 'signing-vault.json');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function checkPassphrase(passphrase) {
|
|
30
|
+
if (typeof passphrase !== 'string' || [...passphrase].length < 12) {
|
|
31
|
+
throw new Error('Signing vault passphrase must be at least 12 characters.');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validatePayload(data) {
|
|
36
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)
|
|
37
|
+
|| Object.getPrototypeOf(data) !== Object.prototype
|
|
38
|
+
|| Object.keys(data).length !== 1 || !Object.hasOwn(data, 'profiles')) {
|
|
39
|
+
throw new Error('Signing vault data must contain only a profiles object.');
|
|
40
|
+
}
|
|
41
|
+
const profiles = data.profiles;
|
|
42
|
+
if (!profiles || typeof profiles !== 'object' || Array.isArray(profiles)
|
|
43
|
+
|| Object.getPrototypeOf(profiles) !== Object.prototype) {
|
|
44
|
+
throw new Error('Signing vault profiles must be an object.');
|
|
45
|
+
}
|
|
46
|
+
for (const [name, profile] of Object.entries(profiles)) {
|
|
47
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name)
|
|
48
|
+
|| ['constructor', 'prototype', '__proto__'].includes(name)) {
|
|
49
|
+
throw new Error('Signing vault contains an invalid profile name.');
|
|
50
|
+
}
|
|
51
|
+
const fields = ['keystore', 'storePassword', 'keyPassword', 'alias', 'fingerprint'];
|
|
52
|
+
if (!profile || typeof profile !== 'object' || Array.isArray(profile)
|
|
53
|
+
|| Object.getPrototypeOf(profile) !== Object.prototype
|
|
54
|
+
|| Object.keys(profile).length !== fields.length
|
|
55
|
+
|| fields.some((field) => !Object.hasOwn(profile, field))) {
|
|
56
|
+
throw new Error(`Signing profile "${name}" has an invalid schema.`);
|
|
57
|
+
}
|
|
58
|
+
if (typeof profile.keystore !== 'string' || profile.keystore.length === 0
|
|
59
|
+
|| profile.keystore.length > Math.ceil(MAX_KEYSTORE_SIZE / 3) * 4
|
|
60
|
+
|| /[^A-Za-z0-9+/=]/.test(profile.keystore)) {
|
|
61
|
+
throw new Error(`Signing profile "${name}" has invalid keystore data.`);
|
|
62
|
+
}
|
|
63
|
+
const keystoreBytes = Buffer.from(profile.keystore, 'base64');
|
|
64
|
+
if (!keystoreBytes.length || keystoreBytes.length > MAX_KEYSTORE_SIZE
|
|
65
|
+
|| keystoreBytes.toString('base64') !== profile.keystore) {
|
|
66
|
+
keystoreBytes.fill(0);
|
|
67
|
+
throw new Error(`Signing profile "${name}" has invalid keystore data.`);
|
|
68
|
+
}
|
|
69
|
+
keystoreBytes.fill(0);
|
|
70
|
+
for (const field of fields.slice(1)) {
|
|
71
|
+
if (typeof profile[field] !== 'string' || profile[field].length > 4096
|
|
72
|
+
|| (field !== 'storePassword' && field !== 'keyPassword' && profile[field].length === 0)
|
|
73
|
+
|| (field === 'alias' && /[\x00-\x1f\x7f]/.test(profile[field]))
|
|
74
|
+
|| (field === 'fingerprint' && !/^[A-F0-9]{64}$/.test(profile[field]))) {
|
|
75
|
+
throw new Error(`Signing profile "${name}" has an invalid ${field}.`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const serialized = JSON.stringify(data);
|
|
80
|
+
if (Buffer.byteLength(serialized, 'utf8') > MAX_PAYLOAD_SIZE) {
|
|
81
|
+
throw new Error('Signing vault data exceeds the maximum supported size.');
|
|
82
|
+
}
|
|
83
|
+
return serialized;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function assertSafeStat(stat, { directory = false } = {}) {
|
|
87
|
+
if (directory ? !stat.isDirectory() : !stat.isFile()) {
|
|
88
|
+
throw new Error('Signing vault path has an unsafe file type.');
|
|
89
|
+
}
|
|
90
|
+
if (process.platform !== 'win32') {
|
|
91
|
+
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
|
92
|
+
throw new Error('Signing vault path is not owned by the current user.');
|
|
93
|
+
}
|
|
94
|
+
const mode = stat.mode & 0o777;
|
|
95
|
+
if ((mode & 0o077) !== 0 || (directory && (mode & 0o700) !== 0o700)) {
|
|
96
|
+
throw new Error('Signing vault path has unsafe permissions.');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function lstatIfExists(target) {
|
|
102
|
+
try {
|
|
103
|
+
return await fs.lstat(target);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error.code === 'ENOENT') return null;
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function ensureParent(parent, create) {
|
|
111
|
+
const absolute = path.resolve(parent);
|
|
112
|
+
const parsed = path.parse(absolute);
|
|
113
|
+
let current = parsed.root;
|
|
114
|
+
const components = absolute.slice(parsed.root.length).split(path.sep).filter(Boolean);
|
|
115
|
+
for (let index = 0; index < components.length; index += 1) {
|
|
116
|
+
current = path.join(current, components[index]);
|
|
117
|
+
let stat = await lstatIfExists(current);
|
|
118
|
+
if (!stat && create) {
|
|
119
|
+
try {
|
|
120
|
+
await fs.mkdir(current, { mode: 0o700 });
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error.code !== 'EEXIST') throw error;
|
|
123
|
+
}
|
|
124
|
+
stat = await lstatIfExists(current);
|
|
125
|
+
}
|
|
126
|
+
if (!stat) return false;
|
|
127
|
+
if (stat.isSymbolicLink()) throw new Error('Signing vault parent directory must not be a symlink.');
|
|
128
|
+
if (!stat.isDirectory()) throw new Error('Signing vault parent path is not a directory.');
|
|
129
|
+
if (index === components.length - 1) assertSafeStat(stat, { directory: true });
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function checkVaultFile(filePath) {
|
|
135
|
+
const stat = await lstatIfExists(filePath);
|
|
136
|
+
if (!stat) return null;
|
|
137
|
+
if (stat.isSymbolicLink()) throw new Error('Signing vault file must not be a symlink.');
|
|
138
|
+
assertSafeStat(stat);
|
|
139
|
+
if (process.platform !== 'win32' && stat.nlink !== 1) {
|
|
140
|
+
throw new Error('Signing vault file has an unsafe link count.');
|
|
141
|
+
}
|
|
142
|
+
return stat;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function deriveKey(passphrase, salt) {
|
|
146
|
+
const passphraseBytes = Buffer.from(passphrase, 'utf8');
|
|
147
|
+
try {
|
|
148
|
+
return await scrypt(passphraseBytes, salt, 32, {
|
|
149
|
+
N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, maxmem: SCRYPT_MAXMEM,
|
|
150
|
+
});
|
|
151
|
+
} finally {
|
|
152
|
+
passphraseBytes.fill(0);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function decodeBase64(value, expectedLength) {
|
|
157
|
+
if (typeof value !== 'string' || /[^A-Za-z0-9+/=]/.test(value)) return null;
|
|
158
|
+
const result = Buffer.from(value, 'base64');
|
|
159
|
+
if (result.toString('base64') !== value || (expectedLength !== undefined && result.length !== expectedLength)) {
|
|
160
|
+
result.fill(0);
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function validateEnvelope(envelope) {
|
|
167
|
+
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)
|
|
168
|
+
|| Object.getPrototypeOf(envelope) !== Object.prototype
|
|
169
|
+
|| Object.keys(envelope).sort().join(',') !== 'cipher,kdf,version'
|
|
170
|
+
|| envelope.version !== VERSION) throw new Error(UNLOCK_ERROR);
|
|
171
|
+
const { kdf, cipher } = envelope;
|
|
172
|
+
if (!kdf || typeof kdf !== 'object' || Array.isArray(kdf)
|
|
173
|
+
|| Object.keys(kdf).sort().join(',') !== 'N,name,p,r,salt'
|
|
174
|
+
|| kdf.name !== 'scrypt' || kdf.N !== SCRYPT_N || kdf.r !== SCRYPT_R || kdf.p !== SCRYPT_P
|
|
175
|
+
|| !cipher || typeof cipher !== 'object' || Array.isArray(cipher)
|
|
176
|
+
|| Object.keys(cipher).sort().join(',') !== 'data,iv,name,tag' || cipher.name !== 'aes-256-gcm') {
|
|
177
|
+
throw new Error(UNLOCK_ERROR);
|
|
178
|
+
}
|
|
179
|
+
const salt = decodeBase64(kdf.salt, 16);
|
|
180
|
+
const iv = decodeBase64(cipher.iv, 12);
|
|
181
|
+
const tag = decodeBase64(cipher.tag, 16);
|
|
182
|
+
const ciphertext = decodeBase64(cipher.data);
|
|
183
|
+
if (!salt || !iv || !tag || !ciphertext || ciphertext.length > MAX_PAYLOAD_SIZE) {
|
|
184
|
+
for (const buffer of [salt, iv, tag, ciphertext]) buffer?.fill(0);
|
|
185
|
+
throw new Error(UNLOCK_ERROR);
|
|
186
|
+
}
|
|
187
|
+
return { salt, iv, tag, ciphertext };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function decryptEnvelope(raw, passphrase) {
|
|
191
|
+
let plaintext;
|
|
192
|
+
let key;
|
|
193
|
+
let parsed;
|
|
194
|
+
let pieces;
|
|
195
|
+
try {
|
|
196
|
+
if (raw.length > MAX_FILE_SIZE) throw new Error(UNLOCK_ERROR);
|
|
197
|
+
parsed = JSON.parse(raw.toString('utf8'));
|
|
198
|
+
pieces = validateEnvelope(parsed);
|
|
199
|
+
key = await deriveKey(passphrase, pieces.salt);
|
|
200
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, pieces.iv);
|
|
201
|
+
decipher.setAAD(AAD);
|
|
202
|
+
decipher.setAuthTag(pieces.tag);
|
|
203
|
+
plaintext = Buffer.concat([decipher.update(pieces.ciphertext), decipher.final()]);
|
|
204
|
+
if (plaintext.length > MAX_PAYLOAD_SIZE) throw new Error(UNLOCK_ERROR);
|
|
205
|
+
const value = JSON.parse(plaintext.toString('utf8'));
|
|
206
|
+
validatePayload(value);
|
|
207
|
+
return value;
|
|
208
|
+
} catch {
|
|
209
|
+
throw new Error(UNLOCK_ERROR);
|
|
210
|
+
} finally {
|
|
211
|
+
key?.fill(0);
|
|
212
|
+
plaintext?.fill(0);
|
|
213
|
+
if (pieces) for (const value of Object.values(pieces)) value.fill(0);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function encryptPayload(data, passphrase) {
|
|
218
|
+
const serialized = validatePayload(data);
|
|
219
|
+
const plaintext = Buffer.from(serialized, 'utf8');
|
|
220
|
+
const salt = crypto.randomBytes(16);
|
|
221
|
+
const iv = crypto.randomBytes(12);
|
|
222
|
+
let key;
|
|
223
|
+
let ciphertext;
|
|
224
|
+
try {
|
|
225
|
+
key = await deriveKey(passphrase, salt);
|
|
226
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
227
|
+
cipher.setAAD(AAD);
|
|
228
|
+
ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
229
|
+
const envelope = {
|
|
230
|
+
version: VERSION,
|
|
231
|
+
kdf: { name: 'scrypt', N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, salt: salt.toString('base64') },
|
|
232
|
+
cipher: { name: 'aes-256-gcm', iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), data: ciphertext.toString('base64') },
|
|
233
|
+
};
|
|
234
|
+
const output = Buffer.from(JSON.stringify(envelope), 'utf8');
|
|
235
|
+
if (output.length > MAX_FILE_SIZE) {
|
|
236
|
+
output.fill(0);
|
|
237
|
+
throw new Error('Signing vault data exceeds the maximum supported size.');
|
|
238
|
+
}
|
|
239
|
+
return output;
|
|
240
|
+
} finally {
|
|
241
|
+
plaintext.fill(0);
|
|
242
|
+
salt.fill(0);
|
|
243
|
+
iv.fill(0);
|
|
244
|
+
key?.fill(0);
|
|
245
|
+
ciphertext?.fill(0);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function clearData(data) {
|
|
250
|
+
if (!data?.profiles || typeof data.profiles !== 'object') return;
|
|
251
|
+
for (const profile of Object.values(data.profiles)) {
|
|
252
|
+
if (!profile || typeof profile !== 'object') continue;
|
|
253
|
+
for (const field of ['keystore', 'storePassword', 'keyPassword']) if (Object.hasOwn(profile, field)) profile[field] = '';
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
class SigningVault {
|
|
258
|
+
constructor({ filePath } = {}) {
|
|
259
|
+
this.filePath = path.resolve(filePath || vaultPath());
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async _read(passphrase) {
|
|
263
|
+
checkPassphrase(passphrase);
|
|
264
|
+
const parentExists = await ensureParent(path.dirname(this.filePath), false);
|
|
265
|
+
if (!parentExists) return { profiles: {} };
|
|
266
|
+
const stat = await checkVaultFile(this.filePath);
|
|
267
|
+
if (!stat) return { profiles: {} };
|
|
268
|
+
if (stat.size > MAX_FILE_SIZE) throw new Error(UNLOCK_ERROR);
|
|
269
|
+
let handle;
|
|
270
|
+
let raw;
|
|
271
|
+
try {
|
|
272
|
+
const noFollow = fs.constants?.O_NOFOLLOW || 0;
|
|
273
|
+
handle = await fs.open(this.filePath, require('node:fs').constants.O_RDONLY | noFollow);
|
|
274
|
+
const opened = await handle.stat();
|
|
275
|
+
assertSafeStat(opened);
|
|
276
|
+
if (opened.size > MAX_FILE_SIZE) throw new Error(UNLOCK_ERROR);
|
|
277
|
+
raw = await handle.readFile();
|
|
278
|
+
return await decryptEnvelope(raw, passphrase);
|
|
279
|
+
} finally {
|
|
280
|
+
raw?.fill(0);
|
|
281
|
+
await handle?.close();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async read(passphrase) {
|
|
286
|
+
return this._read(passphrase);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async _write(data, passphrase) {
|
|
290
|
+
const output = await encryptPayload(data, passphrase);
|
|
291
|
+
const parent = path.dirname(this.filePath);
|
|
292
|
+
let tempPath;
|
|
293
|
+
let handle;
|
|
294
|
+
try {
|
|
295
|
+
// The parent and target are checked immediately before creating the atomic replacement.
|
|
296
|
+
await ensureParent(parent, true);
|
|
297
|
+
await checkVaultFile(this.filePath);
|
|
298
|
+
tempPath = path.join(parent, `.${path.basename(this.filePath)}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`);
|
|
299
|
+
handle = await fs.open(tempPath, 'wx', 0o600);
|
|
300
|
+
if (process.platform !== 'win32') await handle.chmod(0o600);
|
|
301
|
+
await handle.writeFile(output);
|
|
302
|
+
await handle.sync();
|
|
303
|
+
await handle.close();
|
|
304
|
+
handle = null;
|
|
305
|
+
await checkVaultFile(this.filePath);
|
|
306
|
+
await fs.rename(tempPath, this.filePath);
|
|
307
|
+
tempPath = null;
|
|
308
|
+
if (process.platform !== 'win32') {
|
|
309
|
+
const dirHandle = await fs.open(parent, 'r');
|
|
310
|
+
try { await dirHandle.sync(); } finally { await dirHandle.close(); }
|
|
311
|
+
}
|
|
312
|
+
} finally {
|
|
313
|
+
output.fill(0);
|
|
314
|
+
await handle?.close().catch(() => {});
|
|
315
|
+
if (tempPath) await fs.unlink(tempPath).catch(() => {});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async _withLock(callback) {
|
|
320
|
+
await ensureParent(path.dirname(this.filePath), true);
|
|
321
|
+
const lockPath = `${this.filePath}.lock`;
|
|
322
|
+
let handle;
|
|
323
|
+
let lockStat;
|
|
324
|
+
try {
|
|
325
|
+
handle = await fs.open(lockPath, 'wx', 0o600);
|
|
326
|
+
if (process.platform !== 'win32') await handle.chmod(0o600);
|
|
327
|
+
lockStat = await handle.stat();
|
|
328
|
+
assertSafeStat(lockStat);
|
|
329
|
+
} catch (error) {
|
|
330
|
+
await handle?.close().catch(() => {});
|
|
331
|
+
if (error.code === 'EEXIST') throw new Error(LOCK_ERROR);
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
return await callback();
|
|
336
|
+
} finally {
|
|
337
|
+
await handle.close().catch(() => {});
|
|
338
|
+
try {
|
|
339
|
+
const current = await fs.lstat(lockPath);
|
|
340
|
+
if (!current.isSymbolicLink() && current.dev === lockStat.dev && current.ino === lockStat.ino) await fs.unlink(lockPath);
|
|
341
|
+
} catch { /* Best-effort cleanup; never remove a lock we do not own. */ }
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async mutate(passphrase, updater) {
|
|
346
|
+
checkPassphrase(passphrase);
|
|
347
|
+
if (typeof updater !== 'function') throw new Error('Signing vault mutation callback is required.');
|
|
348
|
+
return this._withLock(async () => {
|
|
349
|
+
const data = await this._read(passphrase);
|
|
350
|
+
let updated;
|
|
351
|
+
let detached;
|
|
352
|
+
try {
|
|
353
|
+
const replacement = await updater(data);
|
|
354
|
+
updated = replacement === undefined ? data : replacement;
|
|
355
|
+
const serialized = validatePayload(updated);
|
|
356
|
+
detached = JSON.parse(serialized);
|
|
357
|
+
await this._write(detached, passphrase);
|
|
358
|
+
} finally { clearData(data); clearData(updated); clearData(detached); }
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async rekey(oldPassphrase, newPassphrase) {
|
|
363
|
+
checkPassphrase(oldPassphrase);
|
|
364
|
+
checkPassphrase(newPassphrase);
|
|
365
|
+
return this._withLock(async () => {
|
|
366
|
+
const data = await this._read(oldPassphrase);
|
|
367
|
+
try { await this._write(data, newPassphrase); } finally { clearData(data); }
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
module.exports = { SigningVault, vaultPath };
|
package/package.json
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "storebase-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Publish signed Android apps to a versioned Telegram-backed StoreBase catalog",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
7
7
|
"type": "commonjs",
|
|
8
8
|
"bin": {
|
|
9
9
|
"storebase-publish": "bin/storebase-publish.js",
|
|
10
|
-
"storebase-telegram": "bin/storebase-telegram.js"
|
|
10
|
+
"storebase-telegram": "bin/storebase-telegram.js",
|
|
11
|
+
"storebase-keystore": "bin/storebase-keystore.js",
|
|
12
|
+
"storebase-skill": "bin/storebase-skill.js"
|
|
11
13
|
},
|
|
12
14
|
"files": [
|
|
13
|
-
"bin
|
|
14
|
-
"lib
|
|
15
|
-
"README.md"
|
|
15
|
+
"bin/*.js",
|
|
16
|
+
"lib/*.js",
|
|
17
|
+
"README.md",
|
|
18
|
+
"skills/storebase/SKILL.md",
|
|
19
|
+
"skills/storebase/references/cli-workflows.md",
|
|
20
|
+
"skills/storebase/references/runtime-and-android.md",
|
|
21
|
+
"skills/storebase/references/updates-and-development.md"
|
|
16
22
|
],
|
|
17
23
|
"engines": {
|
|
18
24
|
"node": ">=18"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: storebase
|
|
3
|
+
description: Operate StoreBase end to end. Use when installing or using storebase-cli, managing encrypted signing-keystore profiles, publishing signed Android APKs and icons to Telegram, listing or downloading immutable releases, pinning default versions, configuring the encrypted gateway or local Wasmer deployment, building the Apps/Explore Android frontend, or preparing and testing StoreBase incremental/full APK updates.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# StoreBase
|
|
7
|
+
|
|
8
|
+
## Start here
|
|
9
|
+
|
|
10
|
+
1. Identify the outcome: read a catalog, manage signing profiles, publish an approved app, operate a gateway, build Android, or release CLI software.
|
|
11
|
+
2. Read [CLI workflows](references/cli-workflows.md) for exact command syntax, configuration, signing, publishing, catalog operations, and troubleshooting.
|
|
12
|
+
3. Read [Runtime and Android](references/runtime-and-android.md) before gateway deployment, bootstrap changes, direct Telegram operations, or frontend work.
|
|
13
|
+
4. Read [Updates and development](references/updates-and-development.md) before patch generation, Android self-updates, source builds, or release testing.
|
|
14
|
+
5. Use the installed CLI's `--help` as the final authority when versions differ. Do not invent commands for unimplemented features.
|
|
15
|
+
|
|
16
|
+
Resolve references relative to this `SKILL.md`, not a fixed installation path. Install with `npm install --global storebase-cli`. Locate this skill with `storebase-skill path`; copy it into an agent's chosen skills root with `storebase-skill install --dir <skills-root>`. This copies files only; enable or reload the skill using that agent's own mechanism when required.
|
|
17
|
+
|
|
18
|
+
## Keep the architecture straight
|
|
19
|
+
|
|
20
|
+
- Treat the npm CLI release, an Android APK release, and a gateway deployment as separate operations with separate authorization.
|
|
21
|
+
- Use the gateway only to bootstrap encrypted runtime settings. Publish APKs, icons, optional deltas, immutable manifests, and default-version records directly to Telegram.
|
|
22
|
+
- Use the bot session for writes and a separate personal-user session for history reads. Catalog reads do not require the publisher key.
|
|
23
|
+
- Distinguish `--telegram-config local` (direct Telegram environment configuration) from `--storage local` (legacy local JSON/files storage).
|
|
24
|
+
- Distinguish signing profiles from signing itself. StoreBase **verifies already-signed APKs; it never signs or re-signs them**.
|
|
25
|
+
- Distinguish Apps/Explore demo listings from the Telegram-backed StoreBase self-updater. Do not promise a fully wired dynamic marketplace or general app installer where controls are only demo UI.
|
|
26
|
+
|
|
27
|
+
## Follow the common end-to-end flow
|
|
28
|
+
|
|
29
|
+
### First-time approved publisher
|
|
30
|
+
|
|
31
|
+
1. Install Node.js, Java `keytool`, Android SDK Build Tools `apksigner`, and the CLI; inspect command help.
|
|
32
|
+
2. Confirm owner approval for the exact APK release and metadata. Collect a signed APK, PNG/JPEG/WebP icon, stable app name, unique version, supported category, description, and matching keystore or existing profile.
|
|
33
|
+
3. Select gateway mode (default). Supply the publisher token through a secret manager/runtime environment or hidden prompt, never an argument. Do not ask for Telegram credentials if the configured gateway can supply them.
|
|
34
|
+
4. Import a named profile once with `storebase-keystore import`. Unlock the vault interactively; never put profile passwords/passphrases in arguments, environment variables, or files.
|
|
35
|
+
5. Run `storebase-publish --signing-profile <profile>` with app metadata. For StoreBase itself, include an increasing Android version code. Inspect actual success/failure rather than assuming an upload succeeded.
|
|
36
|
+
6. Verify with `storebase-telegram list`, `metadata <app-id> --version <version>`, and download into a chosen directory. Check version, certificate, artifact hashes/sizes, and message IDs.
|
|
37
|
+
7. Pin the approved version if it should be default; verify without `--version`. Preserve older immutable releases.
|
|
38
|
+
8. Report the app ID/version, new versus idempotent publication, manifest ID, verification results, and default state. Never return credentials.
|
|
39
|
+
|
|
40
|
+
### Routine app update
|
|
41
|
+
|
|
42
|
+
1. Preserve stable app identity and a compatible signer. Increment the release version; increment Android `versionCode` for StoreBase self-updates.
|
|
43
|
+
2. Build and sign outside the publisher. Reuse the matching profile, not an unrelated replacement key.
|
|
44
|
+
3. For StoreBase only, optionally prepare an `.sbd` patch from the exact installed base APK to the exact signed target APK; always keep the full APK fallback.
|
|
45
|
+
4. Publish a new immutable release, download/verify it, then change the default pointer if authorized.
|
|
46
|
+
5. Test a real Android device: unknown-source permission, system install approval, signer/version acceptance, and reopening. Do not promise silent installation or guaranteed foreground relaunch.
|
|
47
|
+
|
|
48
|
+
### Read/download only
|
|
49
|
+
|
|
50
|
+
1. Install the CLI and use default gateway mode; do not request a publisher token.
|
|
51
|
+
2. Run `storebase-telegram list`; use its app ID rather than an Android package name.
|
|
52
|
+
3. Inspect metadata, then download the default or explicit historical release. Downloads verify size and SHA-256.
|
|
53
|
+
4. Do not pin or publish during a read-only task.
|
|
54
|
+
|
|
55
|
+
### Keystore maintenance
|
|
56
|
+
|
|
57
|
+
1. List profiles and compare public fingerprints.
|
|
58
|
+
2. Import a new name for a separate key; use `import <profile> ... --replace` only for approved replacement.
|
|
59
|
+
3. Use `rekey` to change the vault master passphrase, not Android signing identity.
|
|
60
|
+
4. Securely back up the encrypted vault and original signing material before replacing/removing anything. Read the vault warnings in the CLI reference.
|
|
61
|
+
5. Preserve Android compatibility: profile replacement does not create signing lineage.
|
|
62
|
+
|
|
63
|
+
### Gateway, frontend, and source work
|
|
64
|
+
|
|
65
|
+
1. Read both runtime/development references before deployment or Android settings changes.
|
|
66
|
+
2. Keep secrets server-side and separate reader/publisher bootstrap. Verify `/api/v1/health` and encrypted bootstrap behavior without logging decrypted settings.
|
|
67
|
+
3. Build frontend assets before Android sync; preserve native plugins and manifest permissions.
|
|
68
|
+
4. Use local Wasmer deployment only when requested, never just to demonstrate this skill.
|
|
69
|
+
5. Test device behavior separately from unit tests; distinguish demo controls from working updater functionality.
|
|
70
|
+
|
|
71
|
+
## Protect secrets and release boundaries
|
|
72
|
+
|
|
73
|
+
- Never put `.env`, tokens, Telegram sessions, vaults, keystores, private keys, APKs, generated patches, or local credential files in Git/npm. Use documentation placeholders only.
|
|
74
|
+
- Restrict publisher keys and read sessions. Encrypted bootstrap is not production identity, device attestation, or protection against a compromised client.
|
|
75
|
+
- Never print decrypted Telegram settings or copy them into Android assets, frontend bundles, release metadata, shell history, diagnostics, or chat.
|
|
76
|
+
- Obtain owner approval before publishing third-party APKs; pending builds are not approval. Leave any unapproved SnapBase APK unpublished.
|
|
77
|
+
- Stop on signer mismatch, immutable-version conflict, failed integrity check, or ambiguous publication. Inspect existing releases before retrying.
|
|
78
|
+
- Never delete artifacts or rewrite manifests to fix releases; publish a new version. Pinning an old release does not make Android accept a downgrade.
|
|
79
|
+
- Report only verified outcomes. Unit tests do not prove device installation/relaunch; publishing a skill to npm/Git does not activate it automatically in every agent.
|
|
80
|
+
|
|
81
|
+
## Release this CLI and skill
|
|
82
|
+
|
|
83
|
+
1. Inspect all non-secret project changes and the target branch. Keep the skill in `cli/skills/storebase/` and its install command in `cli/bin/`.
|
|
84
|
+
2. Validate skill frontmatter when a validator is available; run root `npm test`, `npm run build`, and CLI package/install tests.
|
|
85
|
+
3. Bump public CLI package and lockfile together. Inspect `npm pack --dry-run --json --ignore-scripts`; permit only CLI JavaScript, README/package metadata, and explicitly listed skill Markdown files.
|
|
86
|
+
4. Test the exact tarball with an isolated install, `storebase-skill path`, `install`, and command help. Publish that inspected tarball with a runtime npm credential, never a repository token file.
|
|
87
|
+
5. Commit approved non-secret source/docs, push the intended branch, and verify GitHub commit and npm version. Do not publish APKs or deploy a gateway as side effects.
|
|
88
|
+
6. Return npm version, commit/link, skill location/install commands, passing tests, and any untested platforms or blockers.
|