@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.
Files changed (56) hide show
  1. package/README.md +38 -0
  2. package/android/build.gradle +48 -0
  3. package/android/src/main/java/com/viettelpost/otakit/OTAHashUtils.kt +21 -0
  4. package/android/src/main/java/com/viettelpost/otakit/OTATestReceiver.kt +51 -0
  5. package/android/src/main/java/com/viettelpost/otakit/OTAUpdateBundleResolver.kt +405 -0
  6. package/android/src/main/java/com/viettelpost/otakit/OTAUpdateCleanup.kt +186 -0
  7. package/android/src/main/java/com/viettelpost/otakit/OTAUpdateDownloader.kt +649 -0
  8. package/android/src/main/java/com/viettelpost/otakit/OTAUpdateMetadata.kt +72 -0
  9. package/android/src/main/java/com/viettelpost/otakit/OTAUpdateModule.kt +140 -0
  10. package/android/src/main/java/com/viettelpost/otakit/OTAUpdatePackage.kt +30 -0
  11. package/android/src/main/java/com/viettelpost/otakit/OTAUpdateSignatureVerifier.kt +63 -0
  12. package/android/src/main/java/com/viettelpost/otakit/OTAUpdateStorage.kt +62 -0
  13. package/android/src/main/java/com/viettelpost/otakit/OTAZipUtils.kt +100 -0
  14. package/android/src/main/res/raw/ota_public_key.pem +9 -0
  15. package/bin/cli/assets-zip.js +77 -0
  16. package/bin/cli/bundle.js +72 -0
  17. package/bin/cli/deploy.js +224 -0
  18. package/bin/cli/sign.js +97 -0
  19. package/bin/cli/upload.js +109 -0
  20. package/bin/ota.js +200 -0
  21. package/docs/BACKEND_CONTRACT.md +93 -0
  22. package/docs/DEPLOY_CLI.md +39 -0
  23. package/docs/INTEGRATION_ANDROID.md +20 -0
  24. package/docs/INTEGRATION_IOS.md +21 -0
  25. package/docs/RELEASE_WORKFLOW.md +14 -0
  26. package/ios/OTAHashUtils.swift +22 -0
  27. package/ios/OTAUpdateBundleResolver.swift +359 -0
  28. package/ios/OTAUpdateCleanup.swift +269 -0
  29. package/ios/OTAUpdateDownloader.swift +709 -0
  30. package/ios/OTAUpdateMetadata.swift +47 -0
  31. package/ios/OTAUpdateModule.mm +190 -0
  32. package/ios/OTAUpdateSignatureVerifier.swift +81 -0
  33. package/ios/OTAUpdateStorage.swift +83 -0
  34. package/ios/OTAZipUtils.swift +103 -0
  35. package/ios/ota_public_key.pem +9 -0
  36. package/lib/NativeOTAUpdate.d.ts +77 -0
  37. package/lib/NativeOTAUpdate.js +59 -0
  38. package/lib/OTAClient.d.ts +27 -0
  39. package/lib/OTAClient.js +101 -0
  40. package/lib/config.d.ts +14 -0
  41. package/lib/config.js +29 -0
  42. package/lib/devtools.d.ts +10 -0
  43. package/lib/devtools.js +54 -0
  44. package/lib/index.d.ts +15 -0
  45. package/lib/index.js +32 -0
  46. package/lib/spec/NativeOTAUpdate.d.ts +16 -0
  47. package/lib/spec/NativeOTAUpdate.js +4 -0
  48. package/package.json +82 -0
  49. package/react-native-ota.podspec +21 -0
  50. package/scripts/run-bin.js +67 -0
  51. package/src/NativeOTAUpdate.ts +144 -0
  52. package/src/OTAClient.ts +151 -0
  53. package/src/config.ts +41 -0
  54. package/src/devtools.ts +64 -0
  55. package/src/index.ts +69 -0
  56. 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 };
@@ -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
+ });