@viettelpost/react-native-ota 0.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.
- package/README.md +38 -0
- package/android/build.gradle +48 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAHashUtils.kt +21 -0
- package/android/src/main/java/com/viettelpost/otakit/OTATestReceiver.kt +51 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateBundleResolver.kt +405 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateCleanup.kt +186 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateDownloader.kt +649 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateMetadata.kt +72 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateModule.kt +140 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdatePackage.kt +30 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateSignatureVerifier.kt +63 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateStorage.kt +62 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAZipUtils.kt +100 -0
- package/android/src/main/res/raw/ota_public_key.pem +9 -0
- package/bin/cli/assets-zip.js +77 -0
- package/bin/cli/bundle.js +72 -0
- package/bin/cli/deploy.js +224 -0
- package/bin/cli/sign.js +97 -0
- package/bin/cli/upload.js +109 -0
- package/bin/ota.js +200 -0
- package/docs/BACKEND_CONTRACT.md +93 -0
- package/docs/DEPLOY_CLI.md +39 -0
- package/docs/INTEGRATION_ANDROID.md +20 -0
- package/docs/INTEGRATION_IOS.md +21 -0
- package/docs/RELEASE_WORKFLOW.md +14 -0
- package/ios/OTAHashUtils.swift +22 -0
- package/ios/OTAUpdateBundleResolver.swift +359 -0
- package/ios/OTAUpdateCleanup.swift +269 -0
- package/ios/OTAUpdateDownloader.swift +709 -0
- package/ios/OTAUpdateMetadata.swift +47 -0
- package/ios/OTAUpdateModule.mm +190 -0
- package/ios/OTAUpdateSignatureVerifier.swift +81 -0
- package/ios/OTAUpdateStorage.swift +83 -0
- package/ios/OTAZipUtils.swift +103 -0
- package/ios/ota_public_key.pem +9 -0
- package/lib/NativeOTAUpdate.d.ts +77 -0
- package/lib/NativeOTAUpdate.js +59 -0
- package/lib/OTAClient.d.ts +27 -0
- package/lib/OTAClient.js +101 -0
- package/lib/config.d.ts +14 -0
- package/lib/config.js +29 -0
- package/lib/devtools.d.ts +10 -0
- package/lib/devtools.js +54 -0
- package/lib/index.d.ts +15 -0
- package/lib/index.js +32 -0
- package/lib/spec/NativeOTAUpdate.d.ts +16 -0
- package/lib/spec/NativeOTAUpdate.js +4 -0
- package/package.json +82 -0
- package/react-native-ota.podspec +21 -0
- package/scripts/run-bin.js +67 -0
- package/src/NativeOTAUpdate.ts +144 -0
- package/src/OTAClient.ts +151 -0
- package/src/config.ts +41 -0
- package/src/devtools.ts +64 -0
- package/src/index.ts +69 -0
- package/src/spec/NativeOTAUpdate.ts +21 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* OTA deploy orchestrator — the core of `ota deploy`.
|
|
4
|
+
*
|
|
5
|
+
* Per-platform pipeline:
|
|
6
|
+
* 1. Resolve version (YYYY.MM.DD-NNN; auto-increments NNN on 409 Conflict)
|
|
7
|
+
* 2. Build — react-native bundle → bundle file + assets-src/
|
|
8
|
+
* 3. Zip — assets-src/ → assets.zip (platform-correct structure)
|
|
9
|
+
* 4. Hash — SHA-256(bundle) + SHA-256(assets.zip)
|
|
10
|
+
* 5. Sign — RSA-SHA256 over canonical payload; self-verify against public key
|
|
11
|
+
* 6. Upload — multipart POST to /api/ota/releases (skipped with --dry-run)
|
|
12
|
+
* 7. Report — print summary with version, sizes, hashes, releaseId, URLs, status
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
const { buildBundle } = require('./bundle');
|
|
20
|
+
const { zipAssets } = require('./assets-zip');
|
|
21
|
+
const { sha256File, sign } = require('./sign');
|
|
22
|
+
const { upload, publish } = require('./upload');
|
|
23
|
+
|
|
24
|
+
/** Generate a date-base prefix: YYYY.MM.DD */
|
|
25
|
+
function todayPrefix() {
|
|
26
|
+
const d = new Date();
|
|
27
|
+
const y = d.getFullYear();
|
|
28
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
29
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
30
|
+
return `${y}.${m}.${day}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Build a version string from a date prefix and a 3-digit sequence number. */
|
|
34
|
+
function makeVersion(base, seq) {
|
|
35
|
+
return `${base}-${String(seq).padStart(3, '0')}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Run the full deploy pipeline for one platform.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} opts
|
|
42
|
+
* @param {'ios'|'android'} opts.platform
|
|
43
|
+
* @param {string} opts.version - starting version (will bump on 409)
|
|
44
|
+
* @param {string} opts.entryFile
|
|
45
|
+
* @param {string} opts.api - required unless dryRun
|
|
46
|
+
* @param {string} opts.token - required unless dryRun
|
|
47
|
+
* @param {string} opts.privateKey - path to PEM private key
|
|
48
|
+
* @param {string} opts.publicKey - path to PEM public key (for self-verify)
|
|
49
|
+
* @param {boolean} opts.dryRun
|
|
50
|
+
* @param {boolean} opts.activate - publish to ACTIVE after upload
|
|
51
|
+
* @param {string} opts.workDir - temp dir for this platform
|
|
52
|
+
* @returns {Promise<object>}
|
|
53
|
+
*/
|
|
54
|
+
async function deployPlatform({
|
|
55
|
+
platform, version, entryFile, api, token,
|
|
56
|
+
privateKey, publicKey, dryRun, activate, workDir,
|
|
57
|
+
}) {
|
|
58
|
+
const log = msg => console.log(` ${msg}`);
|
|
59
|
+
|
|
60
|
+
// ── 1. Build ──────────────────────────────────────────────────────────────
|
|
61
|
+
log('→ building bundle...');
|
|
62
|
+
const { bundlePath, assetsDir, fileName } = buildBundle({ platform, entryFile, workDir });
|
|
63
|
+
const bundleSize = fs.statSync(bundlePath).size;
|
|
64
|
+
log(` bundle: ${fileName} (${(bundleSize / 1024).toFixed(1)} KB)`);
|
|
65
|
+
|
|
66
|
+
// ── 2. Zip assets ─────────────────────────────────────────────────────────
|
|
67
|
+
log('→ zipping assets...');
|
|
68
|
+
const zipPath = path.join(workDir, 'assets.zip');
|
|
69
|
+
const zipped = await zipAssets({ assetsDir, zipPath });
|
|
70
|
+
const assetsSize = zipped ? fs.statSync(zipped).size : 0;
|
|
71
|
+
if (zipped) {
|
|
72
|
+
log(` assets.zip: ${(assetsSize / 1024).toFixed(1)} KB`);
|
|
73
|
+
} else {
|
|
74
|
+
log(' (no assets — bundle-only update)');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── 3. Hash ───────────────────────────────────────────────────────────────
|
|
78
|
+
log('→ hashing...');
|
|
79
|
+
const sha256 = sha256File(bundlePath).toLowerCase();
|
|
80
|
+
const assetsSha256 = zipped ? sha256File(zipped).toLowerCase() : null;
|
|
81
|
+
log(` sha256: ${sha256}`);
|
|
82
|
+
if (assetsSha256) log(` assetsSha256: ${assetsSha256}`);
|
|
83
|
+
|
|
84
|
+
// ── 4. Sign (with self-verify) ────────────────────────────────────────────
|
|
85
|
+
log('→ signing...');
|
|
86
|
+
const { signature, canonicalPayload } = sign({
|
|
87
|
+
version,
|
|
88
|
+
platform,
|
|
89
|
+
fileName,
|
|
90
|
+
sha256,
|
|
91
|
+
assetsSha256,
|
|
92
|
+
privateKeyPath: privateKey,
|
|
93
|
+
publicKeyPath: publicKey,
|
|
94
|
+
});
|
|
95
|
+
log(` signature: ${signature.slice(0, 16)}… (self-verified ✓)`);
|
|
96
|
+
|
|
97
|
+
const summary = {
|
|
98
|
+
version,
|
|
99
|
+
platform,
|
|
100
|
+
fileName,
|
|
101
|
+
sha256,
|
|
102
|
+
assetsSha256: assetsSha256 || undefined,
|
|
103
|
+
signature,
|
|
104
|
+
bundleSize,
|
|
105
|
+
assetsSize: assetsSize || undefined,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// ── 5. Dry run exit ───────────────────────────────────────────────────────
|
|
109
|
+
if (dryRun) {
|
|
110
|
+
log('→ dry-run: skipping upload\n');
|
|
111
|
+
const dryResult = { dryRun: true, ...summary };
|
|
112
|
+
console.log(JSON.stringify(dryResult, null, 2));
|
|
113
|
+
return dryResult;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── 6. Upload (with 409 version-bump retry) ───────────────────────────────
|
|
117
|
+
const versionBase = version.includes('-') ? version.replace(/-\d{3}$/, '') : version;
|
|
118
|
+
let seq = parseInt((version.match(/-(\d+)$/) || ['', '1'])[1], 10);
|
|
119
|
+
let result = null;
|
|
120
|
+
|
|
121
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
122
|
+
const v = makeVersion(versionBase, seq);
|
|
123
|
+
log(`→ uploading version ${v}...`);
|
|
124
|
+
try {
|
|
125
|
+
result = await upload({
|
|
126
|
+
api, token,
|
|
127
|
+
version: v, platform, fileName,
|
|
128
|
+
sha256, assetsSha256, signature, canonicalPayload,
|
|
129
|
+
bundlePath, assetsZipPath: zipped,
|
|
130
|
+
});
|
|
131
|
+
result.version = v;
|
|
132
|
+
break;
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (err.status === 409) {
|
|
135
|
+
log(` version ${v} already exists — incrementing to -${String(seq + 1).padStart(3, '0')}...`);
|
|
136
|
+
seq++;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
// Non-409 errors: show the BE response body to help debugging
|
|
140
|
+
if (err.responseBody) {
|
|
141
|
+
log(` BE response: ${err.responseBody}`);
|
|
142
|
+
}
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── 7. Activate (optional) ────────────────────────────────────────────────
|
|
148
|
+
if (activate && result?.releaseId) {
|
|
149
|
+
log('→ activating release...');
|
|
150
|
+
await publish({ api, token, releaseId: result.releaseId });
|
|
151
|
+
result.status = 'ACTIVE';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const final = {
|
|
155
|
+
...summary,
|
|
156
|
+
version: result.version,
|
|
157
|
+
releaseId: result?.releaseId,
|
|
158
|
+
bundleUrl: result?.bundleUrl,
|
|
159
|
+
assetsUrl: result?.assetsUrl,
|
|
160
|
+
status: result?.status || 'DRAFT',
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
console.log('\n ✓ Released:');
|
|
164
|
+
console.log(JSON.stringify(final, null, 2));
|
|
165
|
+
return final;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Top-level deploy entry point — handles multi-platform and shared temp dir.
|
|
170
|
+
*
|
|
171
|
+
* @param {object} opts
|
|
172
|
+
* @param {'ios'|'android'|'all'} opts.platform
|
|
173
|
+
* @param {string} [opts.version] - override; auto-generated when omitted
|
|
174
|
+
* @param {string} opts.entryFile
|
|
175
|
+
* @param {string} opts.api
|
|
176
|
+
* @param {string} opts.token
|
|
177
|
+
* @param {string} opts.privateKey
|
|
178
|
+
* @param {string} opts.publicKey
|
|
179
|
+
* @param {boolean} opts.dryRun
|
|
180
|
+
* @param {boolean} opts.activate
|
|
181
|
+
* @returns {Promise<object[]>} one result per platform
|
|
182
|
+
*/
|
|
183
|
+
async function deploy(opts) {
|
|
184
|
+
const platforms = opts.platform === 'all' ? ['ios', 'android'] : [opts.platform];
|
|
185
|
+
const versionBase = opts.version || todayPrefix();
|
|
186
|
+
// If a bare YYYY.MM.DD was supplied, start at sequence 001
|
|
187
|
+
const version = /^\d{4}\.\d{2}\.\d{2}$/.test(versionBase)
|
|
188
|
+
? makeVersion(versionBase, 1)
|
|
189
|
+
: versionBase;
|
|
190
|
+
|
|
191
|
+
const sessionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ota-deploy-'));
|
|
192
|
+
const cleanup = () => {
|
|
193
|
+
try {
|
|
194
|
+
fs.rmSync(sessionDir, { recursive: true, force: true });
|
|
195
|
+
} catch {
|
|
196
|
+
// best-effort
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
process.on('exit', cleanup);
|
|
200
|
+
process.on('SIGINT', () => { cleanup(); process.exit(1); });
|
|
201
|
+
process.on('SIGTERM', () => { cleanup(); process.exit(1); });
|
|
202
|
+
|
|
203
|
+
console.log(`\n[ota deploy] version=${version} platforms=${platforms.join(',')}`);
|
|
204
|
+
if (opts.dryRun) console.log('[ota deploy] DRY RUN — no upload will occur');
|
|
205
|
+
|
|
206
|
+
const results = [];
|
|
207
|
+
for (const platform of platforms) {
|
|
208
|
+
console.log(`\n[ota deploy:${platform}]`);
|
|
209
|
+
const workDir = path.join(sessionDir, platform);
|
|
210
|
+
fs.mkdirSync(workDir, { recursive: true });
|
|
211
|
+
const result = await deployPlatform({ ...opts, platform, version, workDir });
|
|
212
|
+
results.push(result);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
cleanup();
|
|
216
|
+
process.removeAllListeners('exit');
|
|
217
|
+
process.removeAllListeners('SIGINT');
|
|
218
|
+
process.removeAllListeners('SIGTERM');
|
|
219
|
+
|
|
220
|
+
console.log('\n[ota deploy] done.');
|
|
221
|
+
return results;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = { deploy };
|
package/bin/cli/sign.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* OTA signing utilities — canonical-payload, SHA-256 file hashing, RSA-SHA256 sign/verify.
|
|
4
|
+
*
|
|
5
|
+
* The canonical payload format is a published contract shared by this CLI,
|
|
6
|
+
* OTAUpdateSignatureVerifier.kt, and OTAUpdateSignatureVerifier.swift.
|
|
7
|
+
* Do NOT change field order or formatting without updating all three verifiers.
|
|
8
|
+
*
|
|
9
|
+
* Canonical payload (fixed order, \n-joined, NO trailing newline, lowercase hex):
|
|
10
|
+
* version=<v>
|
|
11
|
+
* platform=<ios|android>
|
|
12
|
+
* fileName=<main.jsbundle|index.android.bundle>
|
|
13
|
+
* sha256=<hex>
|
|
14
|
+
* assetsSha256=<hex> ← only when assets are present
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Compute lowercase-hex SHA-256 of a file.
|
|
22
|
+
* Byte-identical to OTAHashUtils on both native platforms.
|
|
23
|
+
*/
|
|
24
|
+
function sha256File(filePath) {
|
|
25
|
+
const hash = crypto.createHash('sha256');
|
|
26
|
+
hash.update(fs.readFileSync(filePath));
|
|
27
|
+
return hash.digest('hex');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build the canonical payload string exactly as the native verifiers expect.
|
|
32
|
+
* @param {object} p
|
|
33
|
+
* @param {string} p.version
|
|
34
|
+
* @param {string} p.platform
|
|
35
|
+
* @param {string} p.fileName
|
|
36
|
+
* @param {string} p.sha256 - lowercase hex
|
|
37
|
+
* @param {string|null} [p.assetsSha256] - lowercase hex, omit when no assets
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
function canonicalPayload({ version, platform, fileName, sha256, assetsSha256 }) {
|
|
41
|
+
const lines = [
|
|
42
|
+
`version=${version}`,
|
|
43
|
+
`platform=${platform}`,
|
|
44
|
+
`fileName=${fileName}`,
|
|
45
|
+
`sha256=${sha256}`,
|
|
46
|
+
];
|
|
47
|
+
if (assetsSha256) {
|
|
48
|
+
lines.push(`assetsSha256=${assetsSha256}`);
|
|
49
|
+
}
|
|
50
|
+
return lines.join('\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Sign a bundle's metadata with RSA-SHA256 (PKCS#1 v1.5).
|
|
55
|
+
* If publicKeyPath is provided the signature is self-verified before returning —
|
|
56
|
+
* this catches key-pair mismatches before any upload happens.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} opts
|
|
59
|
+
* @param {string} opts.version
|
|
60
|
+
* @param {string} opts.platform
|
|
61
|
+
* @param {string} opts.fileName
|
|
62
|
+
* @param {string} opts.sha256
|
|
63
|
+
* @param {string|null} [opts.assetsSha256]
|
|
64
|
+
* @param {string} opts.privateKeyPath - path to PEM private key (gitignored, never committed)
|
|
65
|
+
* @param {string} [opts.publicKeyPath] - path to PEM public key, for self-verify
|
|
66
|
+
* @returns {{ signature: string, canonicalPayload: string }}
|
|
67
|
+
*/
|
|
68
|
+
function sign({ version, platform, fileName, sha256, assetsSha256, privateKeyPath, publicKeyPath }) {
|
|
69
|
+
if (!fs.existsSync(privateKeyPath)) {
|
|
70
|
+
throw new Error(`OTA private key not found at ${privateKeyPath}\nNever commit the private key — keep it in CI secrets or keys/ (gitignored).`);
|
|
71
|
+
}
|
|
72
|
+
const privateKey = fs.readFileSync(privateKeyPath, 'utf8');
|
|
73
|
+
const payload = canonicalPayload({ version, platform, fileName, sha256, assetsSha256 });
|
|
74
|
+
const signature = crypto
|
|
75
|
+
.sign('RSA-SHA256', Buffer.from(payload, 'utf8'), privateKey)
|
|
76
|
+
.toString('base64');
|
|
77
|
+
|
|
78
|
+
if (publicKeyPath) {
|
|
79
|
+
if (!fs.existsSync(publicKeyPath)) {
|
|
80
|
+
throw new Error(`OTA public key not found at ${publicKeyPath}`);
|
|
81
|
+
}
|
|
82
|
+
const publicKey = fs.readFileSync(publicKeyPath, 'utf8');
|
|
83
|
+
const verified = crypto.verify(
|
|
84
|
+
'RSA-SHA256',
|
|
85
|
+
Buffer.from(payload, 'utf8'),
|
|
86
|
+
publicKey,
|
|
87
|
+
Buffer.from(signature, 'base64'),
|
|
88
|
+
);
|
|
89
|
+
if (!verified) {
|
|
90
|
+
throw new Error('OTA signature self-verification failed — public key does not match the private key used for signing');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { signature, canonicalPayload: payload };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { sha256File, canonicalPayload, sign };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Multipart upload to the OTA CMS backend.
|
|
4
|
+
*
|
|
5
|
+
* Uses Node 18+ native fetch + FormData + Blob — no extra HTTP dependencies.
|
|
6
|
+
*
|
|
7
|
+
* POST /api/ota/releases
|
|
8
|
+
* Creates a new release in DRAFT status.
|
|
9
|
+
* The BE re-validates sha256 hashes and the RSA signature before persisting.
|
|
10
|
+
* Returns: { releaseId, version, platform, bundleUrl, assetsUrl?, status: "DRAFT" }
|
|
11
|
+
* 409 Conflict when version+platform already exists (CLI handles by bumping NNN).
|
|
12
|
+
*
|
|
13
|
+
* POST /api/ota/releases/:id/publish
|
|
14
|
+
* Flips DRAFT → ACTIVE with optional rolloutPercent.
|
|
15
|
+
* Returns: { id, status: "ACTIVE" }
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Upload a signed bundle + optional assets.zip to the OTA CMS.
|
|
23
|
+
*
|
|
24
|
+
* @param {object} opts
|
|
25
|
+
* @param {string} opts.api - base URL (e.g. https://ota.example.com)
|
|
26
|
+
* @param {string} opts.token - Bearer token
|
|
27
|
+
* @param {string} opts.version
|
|
28
|
+
* @param {string} opts.platform
|
|
29
|
+
* @param {string} opts.fileName
|
|
30
|
+
* @param {string} opts.sha256 - lowercase hex
|
|
31
|
+
* @param {string|null} opts.assetsSha256
|
|
32
|
+
* @param {string} opts.signature - Base64 RSA-SHA256
|
|
33
|
+
* @param {string} [opts.canonicalPayload] - for audit, optional
|
|
34
|
+
* @param {string} opts.bundlePath - local path to the bundle file
|
|
35
|
+
* @param {string|null} opts.assetsZipPath - local path to assets.zip, or null
|
|
36
|
+
* @returns {Promise<{ releaseId: string, version: string, platform: string,
|
|
37
|
+
* bundleUrl: string, assetsUrl?: string, status: string }>}
|
|
38
|
+
*/
|
|
39
|
+
async function upload({
|
|
40
|
+
api, token,
|
|
41
|
+
version, platform, fileName,
|
|
42
|
+
sha256, assetsSha256, signature, canonicalPayload,
|
|
43
|
+
bundlePath, assetsZipPath,
|
|
44
|
+
}) {
|
|
45
|
+
const form = new FormData();
|
|
46
|
+
form.set('version', version);
|
|
47
|
+
form.set('platform', platform);
|
|
48
|
+
form.set('fileName', fileName);
|
|
49
|
+
form.set('sha256', sha256);
|
|
50
|
+
if (assetsSha256) form.set('assetsSha256', assetsSha256);
|
|
51
|
+
form.set('signature', signature);
|
|
52
|
+
if (canonicalPayload) form.set('canonicalPayload', canonicalPayload);
|
|
53
|
+
|
|
54
|
+
// Attach bundle file
|
|
55
|
+
const bundleBytes = fs.readFileSync(bundlePath);
|
|
56
|
+
form.set('bundle', new Blob([bundleBytes], { type: 'application/octet-stream' }), fileName);
|
|
57
|
+
|
|
58
|
+
// Attach assets.zip when present
|
|
59
|
+
if (assetsZipPath) {
|
|
60
|
+
const assetsBytes = fs.readFileSync(assetsZipPath);
|
|
61
|
+
form.set('assets', new Blob([assetsBytes], { type: 'application/zip' }), 'assets.zip');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const response = await fetch(`${api}/api/ota/releases`, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
67
|
+
body: form,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
const body = await response.text().catch(() => '');
|
|
72
|
+
const err = new Error(`Upload failed: HTTP ${response.status} ${response.statusText}`);
|
|
73
|
+
err.status = response.status;
|
|
74
|
+
err.responseBody = body;
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return response.json();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Publish a DRAFT release to ACTIVE (optional, triggered by --activate).
|
|
83
|
+
*
|
|
84
|
+
* @param {object} opts
|
|
85
|
+
* @param {string} opts.api
|
|
86
|
+
* @param {string} opts.token
|
|
87
|
+
* @param {string} opts.releaseId
|
|
88
|
+
* @param {number} [opts.rolloutPercent=100]
|
|
89
|
+
* @returns {Promise<{ id: string, status: string }>}
|
|
90
|
+
*/
|
|
91
|
+
async function publish({ api, token, releaseId, rolloutPercent = 100 }) {
|
|
92
|
+
const response = await fetch(`${api}/api/ota/releases/${releaseId}/publish`, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: {
|
|
95
|
+
Authorization: `Bearer ${token}`,
|
|
96
|
+
'Content-Type': 'application/json',
|
|
97
|
+
},
|
|
98
|
+
body: JSON.stringify({ rolloutPercent }),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
const body = await response.text().catch(() => '');
|
|
103
|
+
throw new Error(`Publish failed: HTTP ${response.status} ${response.statusText}\n${body}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return response.json();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { upload, publish };
|
package/bin/ota.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* ota — OTA deploy CLI entry point
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* yarn ota:deploy # deploy all platforms (uses ota.config.js defaults)
|
|
8
|
+
* yarn ota:deploy --dry-run # build + sign only, no upload
|
|
9
|
+
* ota deploy --platform ios --dry-run
|
|
10
|
+
* ota sign --platform ios --bundle ./build/main.jsbundle ...
|
|
11
|
+
*
|
|
12
|
+
* Config file: ota.config.js in the project root (see ota.config.example.js).
|
|
13
|
+
* CLI flags always override config file values.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
// ── Arg parser ────────────────────────────────────────────────────────────────
|
|
20
|
+
// Hand-rolled to keep the same style as scripts/ota-sign.js (no commander/yargs).
|
|
21
|
+
function parseArgs(argv) {
|
|
22
|
+
const args = {};
|
|
23
|
+
for (let i = 0; i < argv.length; i++) {
|
|
24
|
+
const key = argv[i];
|
|
25
|
+
if (key.startsWith('--')) {
|
|
26
|
+
const name = key.slice(2);
|
|
27
|
+
const next = argv[i + 1];
|
|
28
|
+
if (!next || next.startsWith('--')) {
|
|
29
|
+
args[name] = true; // flag with no value
|
|
30
|
+
} else {
|
|
31
|
+
args[name] = next;
|
|
32
|
+
i++;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return args;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Config loader ─────────────────────────────────────────────────────────────
|
|
40
|
+
function loadConfig() {
|
|
41
|
+
const configPath = path.join(process.cwd(), 'ota.config.js');
|
|
42
|
+
if (fs.existsSync(configPath)) {
|
|
43
|
+
// eslint-disable-next-line import/no-dynamic-require
|
|
44
|
+
return require(configPath);
|
|
45
|
+
}
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Help text ─────────────────────────────────────────────────────────────────
|
|
50
|
+
const HELP = `
|
|
51
|
+
ota <command> [options]
|
|
52
|
+
|
|
53
|
+
Commands:
|
|
54
|
+
deploy Build bundles for one or both platforms, sign them, and upload to the OTA CMS.
|
|
55
|
+
sign Sign a pre-built bundle file and print the signed metadata JSON.
|
|
56
|
+
|
|
57
|
+
Options for \`deploy\`:
|
|
58
|
+
--platform ios | android | all (default: all)
|
|
59
|
+
--version Override auto-generated version string (YYYY.MM.DD-NNN)
|
|
60
|
+
--api OTA CMS base URL (e.g. https://ota.example.com)
|
|
61
|
+
--token Bearer token for the /api/ota/releases upload endpoint
|
|
62
|
+
--private-key Path to OTA RSA private key PEM (default: ./keys/ota_private_key.pem)
|
|
63
|
+
--public-key Path to OTA RSA public key PEM for self-verify (default: ./ios/ota_public_key.pem)
|
|
64
|
+
--entry-file React Native JS entry file (default: index.js)
|
|
65
|
+
--dry-run Build + sign only; skip upload. Prints signed metadata.
|
|
66
|
+
--activate Auto-publish to ACTIVE after upload (default: leaves release as DRAFT)
|
|
67
|
+
|
|
68
|
+
Options for \`sign\`:
|
|
69
|
+
--version Required — OTA version string
|
|
70
|
+
--platform Required — ios | android
|
|
71
|
+
--bundle Path to the pre-built bundle file (computes --sha256 automatically)
|
|
72
|
+
--sha256 Override sha256 (use --bundle OR --sha256)
|
|
73
|
+
--file-name Bundle filename (inferred from --bundle when omitted)
|
|
74
|
+
--assets-sha256 SHA-256 of assets.zip (optional; omit for bundle-only updates)
|
|
75
|
+
--private-key Path to OTA private key PEM
|
|
76
|
+
--public-key Path to OTA public key PEM (for self-verify)
|
|
77
|
+
|
|
78
|
+
Config file:
|
|
79
|
+
Create ota.config.js in the project root with defaults (see ota.config.example.js).
|
|
80
|
+
CLI flags override all config file values.
|
|
81
|
+
|
|
82
|
+
Examples:
|
|
83
|
+
yarn ota:deploy # full deploy, both platforms
|
|
84
|
+
yarn ota:deploy --platform ios --dry-run # iOS bundle, print signed metadata
|
|
85
|
+
yarn ota:deploy --activate # deploy + immediately publish to ACTIVE
|
|
86
|
+
ota sign --platform ios --bundle ./build/main.jsbundle \\
|
|
87
|
+
--private-key ./keys/ota_private_key.pem --version 2026.06.23-001
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
91
|
+
async function main() {
|
|
92
|
+
const [, , subcommand, ...rest] = process.argv;
|
|
93
|
+
|
|
94
|
+
if (!subcommand || subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
|
|
95
|
+
console.log(HELP);
|
|
96
|
+
process.exit(0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const config = loadConfig();
|
|
100
|
+
const flags = parseArgs(rest);
|
|
101
|
+
|
|
102
|
+
// Merge config + flags (flags win). Normalize kebab-case flag names → camelCase.
|
|
103
|
+
const opts = {
|
|
104
|
+
...config,
|
|
105
|
+
...flags,
|
|
106
|
+
privateKey: flags['private-key'] || flags.privateKey || config.privateKey || './keys/ota_private_key.pem',
|
|
107
|
+
publicKey: flags['public-key'] || flags.publicKey || config.publicKey || './ios/ota_public_key.pem',
|
|
108
|
+
entryFile: flags['entry-file'] || flags.entryFile || config.entryFile || 'index.js',
|
|
109
|
+
token: flags.token || config.token || process.env.OTA_TOKEN,
|
|
110
|
+
api: flags.api || config.api,
|
|
111
|
+
platform: flags.platform || config.platform || 'all',
|
|
112
|
+
dryRun: !!(flags['dry-run'] || flags.dryRun),
|
|
113
|
+
activate: !!(flags.activate),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// ── deploy ─────────────────────────────────────────────────────────────────
|
|
117
|
+
if (subcommand === 'deploy') {
|
|
118
|
+
if (!opts.dryRun) {
|
|
119
|
+
if (!opts.api) {
|
|
120
|
+
console.error('[ota deploy] Error: --api is required (or set api in ota.config.js)\nTip: yarn ota:deploy --dry-run to test without uploading.');
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
if (!opts.token) {
|
|
124
|
+
console.error('[ota deploy] Error: --token is required (or set token/OTA_TOKEN in ota.config.js)');
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const { deploy } = require('./cli/deploy');
|
|
129
|
+
await deploy(opts);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── sign ───────────────────────────────────────────────────────────────────
|
|
134
|
+
if (subcommand === 'sign') {
|
|
135
|
+
const { sha256File, sign } = require('./cli/sign');
|
|
136
|
+
|
|
137
|
+
const bundlePath = flags.bundle || flags['bundle-path'];
|
|
138
|
+
const providedSha256 = flags.sha256;
|
|
139
|
+
|
|
140
|
+
if (!bundlePath && !providedSha256) {
|
|
141
|
+
console.error('[ota sign] Error: --bundle or --sha256 is required');
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
if (!opts.version) {
|
|
145
|
+
console.error('[ota sign] Error: --version is required');
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
if (!opts.platform) {
|
|
149
|
+
console.error('[ota sign] Error: --platform (ios|android) is required');
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const sha256 = providedSha256
|
|
154
|
+
? providedSha256.toLowerCase()
|
|
155
|
+
: sha256File(bundlePath).toLowerCase();
|
|
156
|
+
|
|
157
|
+
const fileName = flags['file-name'] || flags.fileName
|
|
158
|
+
|| (bundlePath ? path.basename(bundlePath) : undefined);
|
|
159
|
+
if (!fileName) {
|
|
160
|
+
console.error('[ota sign] Error: --file-name is required when --bundle is not provided');
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const assetsSha256 = (flags['assets-sha256'] || flags.assetsSha256 || null)?.toLowerCase() || null;
|
|
165
|
+
|
|
166
|
+
const { signature, canonicalPayload } = sign({
|
|
167
|
+
version: opts.version,
|
|
168
|
+
platform: opts.platform.toLowerCase(),
|
|
169
|
+
fileName,
|
|
170
|
+
sha256,
|
|
171
|
+
assetsSha256,
|
|
172
|
+
privateKeyPath: opts.privateKey,
|
|
173
|
+
publicKeyPath: opts.publicKey,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const result = {
|
|
177
|
+
version: opts.version,
|
|
178
|
+
platform: opts.platform,
|
|
179
|
+
fileName,
|
|
180
|
+
sha256,
|
|
181
|
+
...(assetsSha256 ? { assetsSha256 } : {}),
|
|
182
|
+
signature,
|
|
183
|
+
signatureVerified: !!opts.publicKey,
|
|
184
|
+
canonicalPayload,
|
|
185
|
+
};
|
|
186
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
console.error(`[ota] Unknown command: ${subcommand}\nRun \`ota help\` for usage.`);
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
main().catch(err => {
|
|
195
|
+
console.error('\n[ota] Fatal error:', err.message);
|
|
196
|
+
if (err.responseBody) {
|
|
197
|
+
console.error(' Backend response:', err.responseBody);
|
|
198
|
+
}
|
|
199
|
+
process.exit(1);
|
|
200
|
+
});
|