storebase-cli 0.0.0-stage → 1.0.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 +69 -2
- package/bin/storebase-publish.js +170 -0
- package/lib/publisher.js +348 -0
- package/package.json +20 -4
package/README.md
CHANGED
|
@@ -1,3 +1,70 @@
|
|
|
1
|
-
#
|
|
1
|
+
# StoreBase App Publisher CLI
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Node.js 18 or newer
|
|
8
|
+
- Java `keytool`
|
|
9
|
+
- Android SDK Build Tools `apksigner` (on `PATH`, or under `$ANDROID_SDK_ROOT/build-tools/` or `$ANDROID_HOME/build-tools/`)
|
|
10
|
+
|
|
11
|
+
## Install from this repository
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install --global ./cli
|
|
15
|
+
storebase-publish
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The CLI prompts for the app name, avatar image, version, APK, category, description, and signing keystore. The supported categories match StoreBase Explore:
|
|
19
|
+
|
|
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
|
|
33
|
+
|
|
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`).
|
|
35
|
+
|
|
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.
|
|
37
|
+
|
|
38
|
+
## Non-interactive example
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
STOREBASE_KEYSTORE_PASSWORD="$KEYSTORE_PASSWORD" storebase-publish \
|
|
42
|
+
--name "Example App" \
|
|
43
|
+
--avatar ./icon.png \
|
|
44
|
+
--version 1.2.0 \
|
|
45
|
+
--apk ./app-release.apk \
|
|
46
|
+
--category Productivity \
|
|
47
|
+
--description "A short description of the app." \
|
|
48
|
+
--keystore ./release.jks \
|
|
49
|
+
--alias release
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Available options: `--name`, `--avatar`, `--version`, `--apk`, `--category`, `--description`, `--keystore`, `--alias`, `--catalog`, `--assets-dir`, and `--help`.
|
|
53
|
+
|
|
54
|
+
## Output
|
|
55
|
+
|
|
56
|
+
By default, running from a project directory creates or updates:
|
|
57
|
+
|
|
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.
|
|
60
|
+
|
|
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.
|
|
62
|
+
|
|
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.
|
|
64
|
+
|
|
65
|
+
## Development
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
cd cli
|
|
69
|
+
npm test
|
|
70
|
+
```
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { createInterface } = require('node:readline/promises');
|
|
5
|
+
const { stdin, stdout } = require('node:process');
|
|
6
|
+
const {
|
|
7
|
+
CATEGORIES,
|
|
8
|
+
inspectKeystore,
|
|
9
|
+
releaseApp,
|
|
10
|
+
validateInput,
|
|
11
|
+
verifyApkSignature,
|
|
12
|
+
} = require('../lib/publisher');
|
|
13
|
+
|
|
14
|
+
const OPTION_NAMES = new Set([
|
|
15
|
+
'name', 'avatar', 'version', 'apk', 'category', 'description', 'keystore', 'alias', 'catalog', 'assets-dir',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
function parseArguments(args) {
|
|
19
|
+
const options = {};
|
|
20
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
21
|
+
const argument = args[index];
|
|
22
|
+
if (argument === '--help' || argument === '-h') return { help: true, options };
|
|
23
|
+
if (!argument.startsWith('--')) throw new Error(`Unexpected argument: ${argument}`);
|
|
24
|
+
const equals = argument.indexOf('=');
|
|
25
|
+
const name = argument.slice(2, equals < 0 ? undefined : equals);
|
|
26
|
+
if (!OPTION_NAMES.has(name)) throw new Error(`Unknown option: --${name}`);
|
|
27
|
+
const value = equals < 0 ? args[++index] : argument.slice(equals + 1);
|
|
28
|
+
if (typeof value !== 'string' || !value.trim() || value.startsWith('--')) {
|
|
29
|
+
throw new Error(`Option --${name} requires a value.`);
|
|
30
|
+
}
|
|
31
|
+
options[name] = value;
|
|
32
|
+
}
|
|
33
|
+
return { help: false, options };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
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
|
+
}
|
|
39
|
+
|
|
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
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function collectInput(options) {
|
|
77
|
+
const required = [
|
|
78
|
+
['name', 'App name'],
|
|
79
|
+
['avatar', 'Avatar file path'],
|
|
80
|
+
['version', 'Version'],
|
|
81
|
+
['apk', 'APK file path'],
|
|
82
|
+
['category', 'Category'],
|
|
83
|
+
['description', 'Description'],
|
|
84
|
+
['keystore', 'Signed keystore file path'],
|
|
85
|
+
];
|
|
86
|
+
const missing = required.filter(([key]) => !options[key]);
|
|
87
|
+
if (missing.length && (!stdin.isTTY || !stdout.isTTY)) {
|
|
88
|
+
throw new Error(`Missing required options: ${missing.map(([key]) => `--${key}`).join(', ')}. Prompts require an interactive terminal.`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
92
|
+
try {
|
|
93
|
+
for (const [key, label] of required) {
|
|
94
|
+
if (!options[key]) {
|
|
95
|
+
if (key === 'category') {
|
|
96
|
+
stdout.write('\nAvailable categories:\n');
|
|
97
|
+
CATEGORIES.forEach((category, index) => stdout.write(` ${index + 1}. ${category}\n`));
|
|
98
|
+
}
|
|
99
|
+
options[key] = (await rl.question(`${label}${key === 'category' ? ' (number or name)' : ''}: `)).trim();
|
|
100
|
+
if (key === 'category' && /^\d+$/.test(options[key])) {
|
|
101
|
+
const category = CATEGORIES[Number(options[key]) - 1];
|
|
102
|
+
if (category) options[key] = category;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
rl.close();
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
name: options.name,
|
|
111
|
+
avatar: options.avatar,
|
|
112
|
+
version: options.version,
|
|
113
|
+
apk: options.apk,
|
|
114
|
+
category: options.category,
|
|
115
|
+
description: options.description,
|
|
116
|
+
keystore: options.keystore,
|
|
117
|
+
alias: options.alias,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function selectEntry(entries, aliasOption) {
|
|
122
|
+
if (aliasOption) {
|
|
123
|
+
const selected = entries.find((entry) => entry.alias === aliasOption);
|
|
124
|
+
if (!selected) throw new Error(`Keystore alias "${aliasOption}" is not a private-key entry. Available aliases: ${entries.map((entry) => entry.alias).join(', ')}.`);
|
|
125
|
+
return selected;
|
|
126
|
+
}
|
|
127
|
+
if (entries.length === 1) return entries[0];
|
|
128
|
+
if (!stdin.isTTY || !stdout.isTTY) {
|
|
129
|
+
throw new Error(`Keystore has multiple private-key aliases. Select one with --alias: ${entries.map((entry) => entry.alias).join(', ')}.`);
|
|
130
|
+
}
|
|
131
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
132
|
+
try {
|
|
133
|
+
stdout.write(`Keystore private-key aliases: ${entries.map((entry) => entry.alias).join(', ')}\n`);
|
|
134
|
+
const alias = (await rl.question('Signing alias: ')).trim();
|
|
135
|
+
const selected = entries.find((entry) => entry.alias === alias);
|
|
136
|
+
if (!selected) throw new Error(`Unknown signing alias: ${alias}`);
|
|
137
|
+
return selected;
|
|
138
|
+
} finally {
|
|
139
|
+
rl.close();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function main() {
|
|
144
|
+
const parsed = parseArguments(process.argv.slice(2));
|
|
145
|
+
if (parsed.help) return showHelp();
|
|
146
|
+
const options = parsed.options;
|
|
147
|
+
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);
|
|
153
|
+
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');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
main().catch((error) => {
|
|
168
|
+
process.stderr.write(`storebase-publish: ${error.message}\n`);
|
|
169
|
+
process.exitCode = 1;
|
|
170
|
+
});
|
package/lib/publisher.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
copyFile,
|
|
5
|
+
mkdir,
|
|
6
|
+
readFile,
|
|
7
|
+
rename,
|
|
8
|
+
rm,
|
|
9
|
+
stat,
|
|
10
|
+
writeFile,
|
|
11
|
+
} = require('node:fs/promises');
|
|
12
|
+
const { createReadStream } = require('node:fs');
|
|
13
|
+
const { createHash, randomBytes } = require('node:crypto');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const { spawnSync } = require('node:child_process');
|
|
16
|
+
const { createInterface } = require('node:readline/promises');
|
|
17
|
+
const { stdin, stdout } = require('node:process');
|
|
18
|
+
const { tmpdir } = require('node:os');
|
|
19
|
+
|
|
20
|
+
const CATEGORIES = Object.freeze([
|
|
21
|
+
'Education',
|
|
22
|
+
'Social',
|
|
23
|
+
'Productivity',
|
|
24
|
+
'Communication',
|
|
25
|
+
'Entertainment',
|
|
26
|
+
'Games',
|
|
27
|
+
'Business',
|
|
28
|
+
'Shopping',
|
|
29
|
+
'Finance',
|
|
30
|
+
'Utilities',
|
|
31
|
+
'Health & Fitness',
|
|
32
|
+
'Travel & Navigation',
|
|
33
|
+
'AI & Machine Learning',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
function fail(message) {
|
|
37
|
+
throw new Error(message);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeFingerprint(value) {
|
|
41
|
+
return String(value || '').replace(/[^a-f\d]/gi, '').toUpperCase();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function slugify(value) {
|
|
45
|
+
const slug = String(value)
|
|
46
|
+
.normalize('NFKD')
|
|
47
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
50
|
+
.replace(/^-+|-+$/g, '');
|
|
51
|
+
return slug || `app-${createHash('sha256').update(String(value)).digest('hex').slice(0, 10)}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function requireFile(value, label, cwd) {
|
|
55
|
+
if (typeof value !== 'string' || !value.trim()) fail(`${label} is required.`);
|
|
56
|
+
const resolved = path.resolve(cwd, value.trim());
|
|
57
|
+
let details;
|
|
58
|
+
try {
|
|
59
|
+
details = await stat(resolved);
|
|
60
|
+
} catch {
|
|
61
|
+
fail(`${label} does not exist: ${resolved}`);
|
|
62
|
+
}
|
|
63
|
+
if (!details.isFile() || details.size === 0) fail(`${label} must be a non-empty file: ${resolved}`);
|
|
64
|
+
return resolved;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function detectImageExtension(filePath) {
|
|
68
|
+
const handle = await require('node:fs/promises').open(filePath, 'r');
|
|
69
|
+
try {
|
|
70
|
+
const buffer = Buffer.alloc(12);
|
|
71
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
72
|
+
const head = buffer.subarray(0, bytesRead);
|
|
73
|
+
if (head.length >= 8 && head.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return '.png';
|
|
74
|
+
if (head.length >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return '.jpg';
|
|
75
|
+
if (head.length >= 12 && head.toString('ascii', 0, 4) === 'RIFF' && head.toString('ascii', 8, 12) === 'WEBP') return '.webp';
|
|
76
|
+
fail('Avatar must be a PNG, JPEG, or WebP image.');
|
|
77
|
+
} finally {
|
|
78
|
+
await handle.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function validateInput(raw, options = {}) {
|
|
83
|
+
const cwd = options.cwd || process.cwd();
|
|
84
|
+
const name = typeof raw.name === 'string' ? raw.name.trim() : '';
|
|
85
|
+
if (!name) fail('App name is required.');
|
|
86
|
+
if ([...name].length > 100) fail('App name must be 100 characters or fewer.');
|
|
87
|
+
|
|
88
|
+
const version = typeof raw.version === 'string' ? raw.version.trim() : '';
|
|
89
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9.+_-]{0,63}$/.test(version)) {
|
|
90
|
+
fail('Version is required and must be 1–64 safe characters (letters, numbers, dot, plus, underscore, or hyphen).');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const description = typeof raw.description === 'string' ? raw.description.trim() : '';
|
|
94
|
+
if (!description) fail('Description is required.');
|
|
95
|
+
if (description.length > 1000) fail('Description must be 1000 characters or fewer.');
|
|
96
|
+
|
|
97
|
+
const categoryText = typeof raw.category === 'string' ? raw.category.trim() : '';
|
|
98
|
+
const category = CATEGORIES.find((item) => item.toLocaleLowerCase('en') === categoryText.toLocaleLowerCase('en'));
|
|
99
|
+
if (!category) fail(`Category must match one of: ${CATEGORIES.join(', ')}.`);
|
|
100
|
+
|
|
101
|
+
const avatarPath = await requireFile(raw.avatar, 'Avatar file', cwd);
|
|
102
|
+
const avatarExtension = await detectImageExtension(avatarPath);
|
|
103
|
+
const apkPath = await requireFile(raw.apk, 'APK file', cwd);
|
|
104
|
+
if (path.extname(apkPath).toLowerCase() !== '.apk') fail('APK file must have the .apk extension.');
|
|
105
|
+
const apkHandle = await require('node:fs/promises').open(apkPath, 'r');
|
|
106
|
+
try {
|
|
107
|
+
const signature = Buffer.alloc(4);
|
|
108
|
+
const { bytesRead } = await apkHandle.read(signature, 0, 4, 0);
|
|
109
|
+
if (bytesRead !== 4 || signature.toString('hex') !== '504b0304') fail('APK file does not have a valid ZIP/APK header.');
|
|
110
|
+
} finally {
|
|
111
|
+
await apkHandle.close();
|
|
112
|
+
}
|
|
113
|
+
const keystorePath = await requireFile(raw.keystore, 'Signed keystore file', cwd);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
id: slugify(name),
|
|
117
|
+
name,
|
|
118
|
+
avatarPath,
|
|
119
|
+
avatarExtension,
|
|
120
|
+
version,
|
|
121
|
+
apkPath,
|
|
122
|
+
category,
|
|
123
|
+
description,
|
|
124
|
+
keystorePath,
|
|
125
|
+
alias: typeof raw.alias === 'string' && raw.alias.trim() ? raw.alias.trim() : undefined,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function runTool(command, args, options = {}) {
|
|
130
|
+
const result = spawnSync(command, args, {
|
|
131
|
+
encoding: 'utf8',
|
|
132
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
133
|
+
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
|
|
134
|
+
...options,
|
|
135
|
+
});
|
|
136
|
+
if (result.error) fail(`Could not run ${command}: ${result.error.message}`);
|
|
137
|
+
if (result.status !== 0) {
|
|
138
|
+
const details = [result.stderr, result.stdout].filter(Boolean).join('\n').trim();
|
|
139
|
+
fail(`${command} failed${details ? `:\n${details}` : '.'}`);
|
|
140
|
+
}
|
|
141
|
+
return `${result.stdout || ''}\n${result.stderr || ''}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseKeystoreEntries(output) {
|
|
145
|
+
const blocks = output.split(/(?=Alias name:\s*)/i).filter((block) => /Alias name:/i.test(block));
|
|
146
|
+
return blocks.flatMap((block) => {
|
|
147
|
+
const alias = block.match(/Alias name:\s*([^\r\n]+)/i)?.[1]?.trim();
|
|
148
|
+
const entryType = block.match(/Entry type:\s*([^\r\n]+)/i)?.[1]?.trim();
|
|
149
|
+
const fingerprints = [...block.matchAll(/SHA256:\s*([A-F\d:]+)/gi)].map((match) => normalizeFingerprint(match[1]));
|
|
150
|
+
if (!alias || !/PrivateKeyEntry/i.test(entryType || '') || fingerprints.length === 0) return [];
|
|
151
|
+
return [{ alias, fingerprint: fingerprints[0] }];
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function parseApkSignerFingerprints(output) {
|
|
156
|
+
return [...output.matchAll(/Signer\s+#\d+\s+certificate\s+SHA-256\s+digest:\s*([a-f\d:]+)/gi)]
|
|
157
|
+
.map((match) => normalizeFingerprint(match[1]))
|
|
158
|
+
.filter((fingerprint) => fingerprint.length === 64);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function inspectKeystore(keystorePath, password) {
|
|
162
|
+
if (typeof password !== 'string') fail('A keystore password is required to verify the signing certificate.');
|
|
163
|
+
if (password.includes('\n') || password.includes('\r')) fail('Keystore passwords cannot contain line breaks.');
|
|
164
|
+
const fs = require('node:fs');
|
|
165
|
+
const folder = await require('node:fs/promises').mkdtemp(path.join(tmpdir(), 'storebase-keystore-'));
|
|
166
|
+
const passwordFile = path.join(folder, 'password');
|
|
167
|
+
try {
|
|
168
|
+
await writeFile(passwordFile, `${password}\n`, { mode: 0o600, flag: 'wx' });
|
|
169
|
+
const output = runTool('keytool', [
|
|
170
|
+
'-J-Duser.language=en',
|
|
171
|
+
'-J-Duser.country=US',
|
|
172
|
+
'-list',
|
|
173
|
+
'-v',
|
|
174
|
+
'-keystore',
|
|
175
|
+
keystorePath,
|
|
176
|
+
'-storepass:file',
|
|
177
|
+
passwordFile,
|
|
178
|
+
]);
|
|
179
|
+
const entries = parseKeystoreEntries(output);
|
|
180
|
+
if (!entries.length) fail('The keystore has no readable private-key entry with a SHA-256 certificate.');
|
|
181
|
+
return entries;
|
|
182
|
+
} finally {
|
|
183
|
+
// Best-effort overwrite before removal; the temporary directory is private (0700).
|
|
184
|
+
try {
|
|
185
|
+
const fd = fs.openSync(passwordFile, 'r+');
|
|
186
|
+
const size = fs.fstatSync(fd).size;
|
|
187
|
+
fs.writeSync(fd, Buffer.alloc(size));
|
|
188
|
+
fs.closeSync(fd);
|
|
189
|
+
} catch {}
|
|
190
|
+
await rm(folder, { recursive: true, force: true });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function findApksigner() {
|
|
195
|
+
const fs = require('node:fs');
|
|
196
|
+
const pathCandidates = [];
|
|
197
|
+
if (process.env.PATH) {
|
|
198
|
+
for (const folder of process.env.PATH.split(path.delimiter)) pathCandidates.push(path.join(folder, 'apksigner'));
|
|
199
|
+
}
|
|
200
|
+
const sdkRoots = [
|
|
201
|
+
process.env.ANDROID_SDK_ROOT,
|
|
202
|
+
process.env.ANDROID_HOME,
|
|
203
|
+
path.join(require('node:os').homedir(), 'Android', 'Sdk'),
|
|
204
|
+
'/usr/lib/android-sdk',
|
|
205
|
+
'/opt/android-sdk',
|
|
206
|
+
].filter(Boolean);
|
|
207
|
+
for (const root of sdkRoots) {
|
|
208
|
+
const buildTools = path.join(root, 'build-tools');
|
|
209
|
+
try {
|
|
210
|
+
const versions = fs.readdirSync(buildTools).sort((a, b) => {
|
|
211
|
+
const aa = a.split('.').map((part) => Number.parseInt(part, 10) || 0);
|
|
212
|
+
const bb = b.split('.').map((part) => Number.parseInt(part, 10) || 0);
|
|
213
|
+
for (let i = 0; i < Math.max(aa.length, bb.length); i += 1) {
|
|
214
|
+
const difference = (bb[i] || 0) - (aa[i] || 0);
|
|
215
|
+
if (difference) return difference;
|
|
216
|
+
}
|
|
217
|
+
return 0;
|
|
218
|
+
});
|
|
219
|
+
for (const version of versions) pathCandidates.push(path.join(buildTools, version, 'apksigner'));
|
|
220
|
+
} catch {}
|
|
221
|
+
}
|
|
222
|
+
const found = pathCandidates.find((candidate) => {
|
|
223
|
+
try { return fs.statSync(candidate).isFile(); } catch { return false; }
|
|
224
|
+
});
|
|
225
|
+
if (!found) fail('Android apksigner was not found. Install Android SDK Build Tools or add apksigner to PATH.');
|
|
226
|
+
return found;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function verifyApkSignature(apkPath, keystoreEntry) {
|
|
230
|
+
const apksigner = findApksigner();
|
|
231
|
+
const output = runTool(apksigner, ['verify', '--verbose', '--print-certs', apkPath]);
|
|
232
|
+
const fingerprints = parseApkSignerFingerprints(output);
|
|
233
|
+
if (!fingerprints.length) fail('apksigner did not report a SHA-256 signer certificate for this APK.');
|
|
234
|
+
const matchingFingerprint = fingerprints.find((fingerprint) => fingerprint === normalizeFingerprint(keystoreEntry.fingerprint));
|
|
235
|
+
if (!matchingFingerprint) {
|
|
236
|
+
fail(`APK signer does not match keystore alias "${keystoreEntry.alias}". The APK was not added to the app list.`);
|
|
237
|
+
}
|
|
238
|
+
return matchingFingerprint;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function sha256File(filePath) {
|
|
242
|
+
const hash = createHash('sha256');
|
|
243
|
+
for await (const chunk of createReadStream(filePath)) hash.update(chunk);
|
|
244
|
+
return hash.digest('hex');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function toCatalogPath(fromDirectory, absolutePath) {
|
|
248
|
+
return path.relative(fromDirectory, absolutePath).split(path.sep).join('/');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function copyIfMissing(source, destination) {
|
|
252
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
253
|
+
try {
|
|
254
|
+
await stat(destination);
|
|
255
|
+
return;
|
|
256
|
+
} catch {}
|
|
257
|
+
const temporary = `${destination}.tmp-${process.pid}-${randomBytes(5).toString('hex')}`;
|
|
258
|
+
try {
|
|
259
|
+
await copyFile(source, temporary);
|
|
260
|
+
try {
|
|
261
|
+
await rename(temporary, destination);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
// A concurrent publish of the identical content-addressed asset is harmless.
|
|
264
|
+
try { await stat(destination); } catch { throw error; }
|
|
265
|
+
await rm(temporary, { force: true });
|
|
266
|
+
}
|
|
267
|
+
} finally {
|
|
268
|
+
await rm(temporary, { force: true });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function writeJsonAtomically(filePath, value) {
|
|
273
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
274
|
+
const temporary = `${filePath}.tmp-${process.pid}-${randomBytes(5).toString('hex')}`;
|
|
275
|
+
try {
|
|
276
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx', mode: 0o644 });
|
|
277
|
+
await rename(temporary, filePath);
|
|
278
|
+
} finally {
|
|
279
|
+
await rm(temporary, { force: true });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function releaseApp(input, options = {}) {
|
|
284
|
+
const catalogPath = path.resolve(options.catalogPath || path.join(process.cwd(), 'storebase-app-list.json'));
|
|
285
|
+
const catalogDirectory = path.dirname(catalogPath);
|
|
286
|
+
const assetsDirectory = path.resolve(options.assetsDir || path.join(catalogDirectory, 'storebase-app-assets'));
|
|
287
|
+
const certificate = normalizeFingerprint(options.signingCertificateSha256);
|
|
288
|
+
if (certificate.length !== 64) fail('A verified 64-character SHA-256 signing certificate is required before release.');
|
|
289
|
+
|
|
290
|
+
let current = { schemaVersion: 1, apps: [] };
|
|
291
|
+
try {
|
|
292
|
+
current = JSON.parse(await readFile(catalogPath, 'utf8'));
|
|
293
|
+
} catch (error) {
|
|
294
|
+
if (error.code !== 'ENOENT') fail(`Cannot read app-list catalog ${catalogPath}: ${error.message}`);
|
|
295
|
+
}
|
|
296
|
+
if (!current || current.schemaVersion !== 1 || !Array.isArray(current.apps)) {
|
|
297
|
+
fail(`Catalog ${catalogPath} must be JSON with schemaVersion: 1 and an apps array.`);
|
|
298
|
+
}
|
|
299
|
+
const conflicting = current.apps.find((app) => app.id === input.id && app.name !== input.name);
|
|
300
|
+
if (conflicting) fail(`App id "${input.id}" is already used by "${conflicting.name}".`);
|
|
301
|
+
|
|
302
|
+
const apkHash = await sha256File(input.apkPath);
|
|
303
|
+
const avatarHash = await sha256File(input.avatarPath);
|
|
304
|
+
const versionDirectory = path.join(assetsDirectory, input.id, input.version);
|
|
305
|
+
const apkDestination = path.join(versionDirectory, `app-${apkHash}.apk`);
|
|
306
|
+
const avatarDestination = path.join(versionDirectory, `avatar-${avatarHash}${input.avatarExtension}`);
|
|
307
|
+
|
|
308
|
+
// Content-addressed paths ensure that a failed catalog write never corrupts the previous listing.
|
|
309
|
+
await copyIfMissing(input.apkPath, apkDestination);
|
|
310
|
+
await copyIfMissing(input.avatarPath, avatarDestination);
|
|
311
|
+
|
|
312
|
+
const appRecord = {
|
|
313
|
+
id: input.id,
|
|
314
|
+
name: input.name,
|
|
315
|
+
avatar: toCatalogPath(catalogDirectory, avatarDestination),
|
|
316
|
+
version: input.version,
|
|
317
|
+
apk: toCatalogPath(catalogDirectory, apkDestination),
|
|
318
|
+
category: input.category,
|
|
319
|
+
description: input.description,
|
|
320
|
+
signingCertificateSha256: certificate,
|
|
321
|
+
publishedAt: options.publishedAt || new Date().toISOString(),
|
|
322
|
+
};
|
|
323
|
+
const apps = current.apps.filter((app) => app.id !== input.id);
|
|
324
|
+
apps.push(appRecord);
|
|
325
|
+
apps.sort((a, b) => a.name.localeCompare(b.name));
|
|
326
|
+
const nextCatalog = { ...current, schemaVersion: 1, updatedAt: appRecord.publishedAt, apps };
|
|
327
|
+
await writeJsonAtomically(catalogPath, nextCatalog);
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
app: appRecord,
|
|
331
|
+
catalogPath,
|
|
332
|
+
assetsDirectory,
|
|
333
|
+
catalog: nextCatalog,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
module.exports = {
|
|
338
|
+
CATEGORIES,
|
|
339
|
+
findApksigner,
|
|
340
|
+
normalizeFingerprint,
|
|
341
|
+
parseApkSignerFingerprints,
|
|
342
|
+
parseKeystoreEntries,
|
|
343
|
+
releaseApp,
|
|
344
|
+
slugify,
|
|
345
|
+
validateInput,
|
|
346
|
+
verifyApkSignature,
|
|
347
|
+
inspectKeystore,
|
|
348
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "storebase-cli",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Publish signed Android apps into a local StoreBase app-list catalog",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"private": false,
|
|
7
|
+
"type": "commonjs",
|
|
8
|
+
"bin": {
|
|
9
|
+
"storebase-publish": "bin/storebase-publish.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin/",
|
|
13
|
+
"lib/",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test"
|
|
21
|
+
}
|
|
22
|
+
}
|