react-native-3rddigital-appupdate 1.0.11 → 1.0.13

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 (2) hide show
  1. package/package.json +1 -1
  2. package/scripts/bundle.js +750 -152
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-3rddigital-appupdate",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "description": "A React Native library for seamless over-the-air (OTA) updates with version checks, automatic bundle download, and customizable user prompts for iOS and Android.",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
package/scripts/bundle.js CHANGED
@@ -15,17 +15,14 @@ const APPUPDATE_AWS_SECRET_ACCESS_KEY =
15
15
  process.env.APPUPDATE_AWS_SECRET_ACCESS_KEY;
16
16
  const APPUPDATE_AWS_BUCKET_NAME = process.env.APPUPDATE_AWS_BUCKET_NAME;
17
17
 
18
- /**
19
- * Decrypt helper logic
20
- */
21
18
  function DecriptEnv(wrappedKey) {
22
19
  if (!wrappedKey) return '';
23
20
  if (typeof wrappedKey !== 'string')
24
21
  throw new TypeError('wrappedKey must be a string');
25
22
  if (wrappedKey.length <= 8) throw new Error('wrappedKey too short to unwrap');
23
+
26
24
  const trimmed = wrappedKey.slice(4, -2);
27
- const result = trimmed.slice(0, 2) + trimmed.slice(4);
28
- return result;
25
+ return trimmed.slice(0, 2) + trimmed.slice(4);
29
26
  }
30
27
 
31
28
  const s3Client = new S3Client({
@@ -36,9 +33,17 @@ const s3Client = new S3Client({
36
33
  },
37
34
  });
38
35
 
39
- /**
40
- * Uploads local file to S3
41
- */
36
+ function run(command, cwd = process.cwd()) {
37
+ try {
38
+ console.log(`\n➡️ Running: ${command}\n`);
39
+ execSync(command, { stdio: 'inherit', cwd });
40
+ } catch (err) {
41
+ console.error(`❌ Command failed: ${command}`);
42
+ console.error(err.message);
43
+ process.exit(1);
44
+ }
45
+ }
46
+
42
47
  async function uploadFileToS3(filePath, bucketName, folder) {
43
48
  const fileName = path.basename(filePath);
44
49
  const cleanFileName = fileName.replace(/\s+/g, '_');
@@ -67,24 +72,6 @@ async function uploadFileToS3(filePath, bucketName, folder) {
67
72
  }
68
73
  }
69
74
 
70
- /**
71
- * Run a shell command synchronously.
72
- */
73
- function run(command) {
74
- try {
75
- console.log(`\n➡️ Running: ${command}\n`);
76
- execSync(command, { stdio: 'inherit' });
77
- } catch (err) {
78
- console.error(`❌ Command failed: ${command}`);
79
- console.error(err.message);
80
- process.exit(1);
81
- }
82
- }
83
-
84
- /**
85
- * Step 1: Upload to S3
86
- * Step 2: Register with Backend
87
- */
88
75
  async function uploadBundle({ filePath, platform, config }) {
89
76
  console.log(`📤 Starting upload process for ${platform}...`);
90
77
 
@@ -94,7 +81,6 @@ async function uploadBundle({ filePath, platform, config }) {
94
81
  }
95
82
 
96
83
  try {
97
- // 1. Upload to S3
98
84
  const s3Result = await uploadFileToS3(
99
85
  filePath,
100
86
  DecriptEnv(APPUPDATE_AWS_BUCKET_NAME),
@@ -105,12 +91,11 @@ async function uploadBundle({ filePath, platform, config }) {
105
91
 
106
92
  console.log(`✅ S3 Upload Complete: ${s3Result.Key}`);
107
93
 
108
- // 2. Prepare Payload for Backend
109
94
  const stats = fs.statSync(filePath);
110
95
  const payload = {
111
96
  projectId: config.PROJECT_ID,
112
97
  environment: config.ENVIRONMENT,
113
- platform: platform,
98
+ platform,
114
99
  version: config.VERSION,
115
100
  forceUpdate: config.FORCE_UPDATE,
116
101
  s3Key: s3Result.Key,
@@ -142,151 +127,668 @@ async function uploadBundle({ filePath, platform, config }) {
142
127
  }
143
128
  }
144
129
 
145
- /**
146
- * Build Android JS bundle and zip it.
147
- */
148
- function buildAndroid() {
149
- console.log('📦 Building Android bundle...');
150
- const outputPath = path.join('android', 'index.android.bundle.zip');
151
- run(
152
- `mkdir -p android/output && ` +
153
- `react-native bundle --platform android --dev false --entry-file index.js ` +
154
- `--bundle-output android/output/index.android.bundle --assets-dest android/output ` +
155
- `--sourcemap-output android/sourcemap.js && ` +
156
- `cd android && find output -type f | zip index.android.bundle.zip -@ && ` +
157
- `zip sourcemap.zip sourcemap.js && cd .. && rm -rf android/output && rm -rf android/sourcemap.js`
158
- );
159
- console.log(`✅ Android bundle created at ${outputPath}`);
160
- return outputPath;
130
+ function getProjectRoot() {
131
+ const cwdPackageJson = path.join(process.cwd(), 'package.json');
132
+ if (fs.existsSync(cwdPackageJson)) {
133
+ return process.cwd();
134
+ }
135
+
136
+ let projectRoot = path.resolve(__dirname);
137
+ while (
138
+ projectRoot.includes('node_modules') &&
139
+ !fs.existsSync(path.join(projectRoot, 'package.json'))
140
+ ) {
141
+ projectRoot = path.resolve(projectRoot, '..');
142
+ }
143
+
144
+ if (projectRoot.includes('node_modules')) {
145
+ projectRoot = path.resolve(projectRoot, '../../');
146
+ }
147
+
148
+ return projectRoot;
161
149
  }
162
150
 
163
- /**
164
- * Build iOS JS bundle and zip it.
165
- */
166
- function buildIOS() {
167
- console.log('📦 Building iOS bundle...');
168
- const outputPath = path.join('ios', 'main.jsbundle.zip');
169
- run(
170
- `mkdir -p ios/output && ` +
171
- `react-native bundle --platform ios --dev false --entry-file index.js ` +
172
- `--bundle-output ios/output/main.jsbundle --assets-dest ios/output ` +
173
- `--sourcemap-output ios/sourcemap.js && ` +
174
- `cd ios && find output -type f | zip main.jsbundle.zip -@ && ` +
175
- `zip sourcemap.zip sourcemap.js && cd .. && rm -rf ios/output && rm -rf ios/sourcemap.js`
176
- );
177
- console.log(`✅ iOS bundle created at ${outputPath}`);
178
- return outputPath;
151
+ function findFirstExistingPath(possiblePaths) {
152
+ return possiblePaths.find((item) => fs.existsSync(item)) ?? null;
179
153
  }
180
154
 
181
- /**
182
- * Automatically detect app version from native files.
183
- */
184
- function getAppVersion(platform) {
185
- try {
186
- // Find the actual React Native project root (two levels up from node_modules)
187
- let projectRoot = path.resolve(__dirname);
188
- while (
189
- projectRoot.includes('node_modules') &&
190
- !fs.existsSync(path.join(projectRoot, 'package.json'))
191
- ) {
192
- projectRoot = path.resolve(projectRoot, '..');
155
+ function findFirstXcodeProj(dir) {
156
+ const files = fs.readdirSync(dir);
157
+
158
+ for (const file of files) {
159
+ const fullPath = path.join(dir, file);
160
+ const stat = fs.statSync(fullPath);
161
+
162
+ if (stat.isDirectory()) {
163
+ if (file.endsWith('.xcodeproj')) return fullPath;
164
+ const nested = findFirstXcodeProj(fullPath);
165
+ if (nested) return nested;
193
166
  }
167
+ }
194
168
 
195
- // Once we exit node_modules, ensure we’re at the React Native app root
196
- if (projectRoot.includes('node_modules')) {
197
- projectRoot = path.resolve(projectRoot, '../../');
169
+ return null;
170
+ }
171
+
172
+ function walkFiles(dir, matcher, result = []) {
173
+ if (!fs.existsSync(dir)) return result;
174
+
175
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
176
+ for (const entry of entries) {
177
+ const fullPath = path.join(dir, entry.name);
178
+ if (entry.isDirectory()) {
179
+ walkFiles(fullPath, matcher, result);
180
+ continue;
198
181
  }
199
182
 
200
- if (platform === 'android') {
201
- const gradlePath = path.join(
202
- projectRoot,
203
- 'android',
204
- 'app',
205
- 'build.gradle'
206
- );
207
- if (!fs.existsSync(gradlePath)) {
208
- console.warn(`⚠️ Android build.gradle not found at ${gradlePath}`);
209
- return null;
210
- }
211
- const gradleContent = fs.readFileSync(gradlePath, 'utf8');
212
- const match = gradleContent.match(/versionName\s+"([\d.]+)"/);
213
- if (match && match[1]) {
214
- return match[1];
215
- } else {
216
- console.warn('⚠️ Could not find versionName in build.gradle.');
183
+ if (matcher(fullPath)) {
184
+ result.push(fullPath);
185
+ }
186
+ }
187
+
188
+ return result;
189
+ }
190
+
191
+ function extractBracedBlock(content, startIndex) {
192
+ const openIndex = content.indexOf('{', startIndex);
193
+ if (openIndex === -1) return null;
194
+
195
+ let depth = 0;
196
+ let inSingleQuote = false;
197
+ let inDoubleQuote = false;
198
+ let inTemplate = false;
199
+ let inLineComment = false;
200
+ let inBlockComment = false;
201
+
202
+ for (let index = openIndex; index < content.length; index += 1) {
203
+ const char = content[index];
204
+ const nextChar = content[index + 1];
205
+ const prevChar = content[index - 1];
206
+
207
+ if (inLineComment) {
208
+ if (char === '\n') inLineComment = false;
209
+ continue;
210
+ }
211
+
212
+ if (inBlockComment) {
213
+ if (prevChar === '*' && char === '/') inBlockComment = false;
214
+ continue;
215
+ }
216
+
217
+ if (!inSingleQuote && !inDoubleQuote && !inTemplate) {
218
+ if (char === '/' && nextChar === '/') {
219
+ inLineComment = true;
220
+ index += 1;
221
+ continue;
217
222
  }
218
- } else if (platform === 'ios') {
219
- const iosDir = path.join(projectRoot, 'ios');
220
- if (!fs.existsSync(iosDir)) {
221
- console.warn(`⚠️ iOS folder not found at ${iosDir}`);
222
- return null;
223
+
224
+ if (char === '/' && nextChar === '*') {
225
+ inBlockComment = true;
226
+ index += 1;
227
+ continue;
223
228
  }
229
+ }
224
230
 
225
- const projectDir = fs
226
- .readdirSync(iosDir)
227
- .find((d) => d.endsWith('.xcodeproj'));
231
+ if (!inDoubleQuote && !inTemplate && char === "'" && prevChar !== '\\') {
232
+ inSingleQuote = !inSingleQuote;
233
+ continue;
234
+ }
228
235
 
229
- if (!projectDir) {
230
- console.warn('⚠️ .xcodeproj not found inside ios directory.');
231
- return null;
236
+ if (!inSingleQuote && !inTemplate && char === '"' && prevChar !== '\\') {
237
+ inDoubleQuote = !inDoubleQuote;
238
+ continue;
239
+ }
240
+
241
+ if (!inSingleQuote && !inDoubleQuote && char === '`' && prevChar !== '\\') {
242
+ inTemplate = !inTemplate;
243
+ continue;
244
+ }
245
+
246
+ if (inSingleQuote || inDoubleQuote || inTemplate) continue;
247
+
248
+ if (char === '{') depth += 1;
249
+ if (char === '}') {
250
+ depth -= 1;
251
+ if (depth === 0) {
252
+ return {
253
+ content: content.slice(openIndex + 1, index),
254
+ start: openIndex,
255
+ end: index,
256
+ };
232
257
  }
258
+ }
259
+ }
260
+
261
+ return null;
262
+ }
263
+
264
+ function extractNamedBlock(content, blockName) {
265
+ const blockRegex = new RegExp(`\\b${blockName}\\b\\s*\\{`, 'm');
266
+ const match = blockRegex.exec(content);
267
+ if (!match) return null;
268
+ return extractBracedBlock(content, match.index);
269
+ }
270
+
271
+ function parseTopLevelNamedBlocks(content) {
272
+ const blocks = [];
273
+ let cursor = 0;
274
+
275
+ while (cursor < content.length) {
276
+ const remainder = content.slice(cursor);
277
+ const nameMatch = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\{/.exec(remainder);
278
+
279
+ if (!nameMatch) {
280
+ cursor += 1;
281
+ continue;
282
+ }
283
+
284
+ const nameIndex = cursor + nameMatch.index;
285
+ const name = nameMatch[1];
286
+ const block = extractBracedBlock(content, nameIndex);
287
+
288
+ if (!block) break;
289
+
290
+ blocks.push({ name, content: block.content });
291
+ cursor = block.end + 1;
292
+ }
293
+
294
+ return blocks;
295
+ }
296
+
297
+ function readQuotedGradleValue(blockContent, key) {
298
+ const match = blockContent.match(
299
+ new RegExp(`\\b${key}\\b\\s+["']([^"']+)["']`)
300
+ );
301
+ return match?.[1] ?? null;
302
+ }
303
+
304
+ function cleanPbxString(value) {
305
+ if (!value) return null;
306
+ return value.replace(/^"(.*)"$/, '$1').trim();
307
+ }
308
+
309
+ function readPbxValue(body, key) {
310
+ const match = body.match(new RegExp(`\\b${key}\\s*=\\s*([^;]+);`));
311
+ return match?.[1]?.trim() ?? null;
312
+ }
313
+
314
+ function parsePbxArray(body, key) {
315
+ const match = body.match(new RegExp(`\\b${key}\\s*=\\s*\\(([\\s\\S]*?)\\);`));
316
+ if (!match) return [];
317
+
318
+ return match[1]
319
+ .split('\n')
320
+ .map((line) => line.trim())
321
+ .filter(Boolean)
322
+ .map((line) => line.replace(/,$/, ''))
323
+ .map((line) => {
324
+ const idMatch = line.match(/^([A-F0-9]{24})/);
325
+ return idMatch?.[1] ?? null;
326
+ })
327
+ .filter(Boolean);
328
+ }
329
+
330
+ function parsePbxprojObjectsByIsa(pbxprojContent, isa) {
331
+ const objectRegex = new RegExp(
332
+ `([A-F0-9]{24}) /\\* ([^*]+) \\*/ = \\{[\\s\\S]*?isa = ${isa};([\\s\\S]*?)\\n\\s*\\};`,
333
+ 'g'
334
+ );
335
+ const objects = [];
336
+ let match;
337
+
338
+ while ((match = objectRegex.exec(pbxprojContent)) !== null) {
339
+ objects.push({
340
+ id: match[1],
341
+ comment: match[2].trim(),
342
+ body: match[3],
343
+ });
344
+ }
345
+
346
+ return objects;
347
+ }
348
+
349
+ function isAppLikeTarget(targetName, productType) {
350
+ if (productType?.includes('application')) return true;
351
+
352
+ return ![
353
+ 'Tests',
354
+ 'UITests',
355
+ 'UnitTests',
356
+ 'NotificationService',
357
+ 'Extension',
358
+ 'Widget',
359
+ ].some((suffix) => targetName.endsWith(suffix));
360
+ }
361
+
362
+ function getAndroidBuildGradlePath(projectRoot) {
363
+ return findFirstExistingPath([
364
+ path.join(projectRoot, 'android', 'app', 'build.gradle'),
365
+ path.join(projectRoot, 'android', 'app', 'build.gradle.kts'),
366
+ ]);
367
+ }
368
+
369
+ function getAndroidProjectMetadata() {
370
+ const projectRoot = getProjectRoot();
371
+ const gradlePath = getAndroidBuildGradlePath(projectRoot);
372
+ if (!gradlePath) {
373
+ console.warn('⚠️ Android build.gradle not found.');
374
+ return null;
375
+ }
376
+
377
+ const gradleContent = fs.readFileSync(gradlePath, 'utf8');
378
+ const defaultConfigBlock = extractNamedBlock(gradleContent, 'defaultConfig');
379
+ const productFlavorsBlock = extractNamedBlock(
380
+ gradleContent,
381
+ 'productFlavors'
382
+ );
383
+
384
+ const defaultAppId =
385
+ readQuotedGradleValue(defaultConfigBlock?.content ?? '', 'applicationId') ??
386
+ null;
387
+ const defaultVersion =
388
+ readQuotedGradleValue(defaultConfigBlock?.content ?? '', 'versionName') ??
389
+ null;
390
+
391
+ const flavors = parseTopLevelNamedBlocks(
392
+ productFlavorsBlock?.content ?? ''
393
+ ).map(({ name, content }) => {
394
+ const flavorAppId = readQuotedGradleValue(content, 'applicationId');
395
+ const flavorAppIdSuffix = readQuotedGradleValue(
396
+ content,
397
+ 'applicationIdSuffix'
398
+ );
399
+ const flavorVersion = readQuotedGradleValue(content, 'versionName');
400
+ const flavorVersionSuffix = readQuotedGradleValue(
401
+ content,
402
+ 'versionNameSuffix'
403
+ );
404
+
405
+ return {
406
+ name,
407
+ label: name,
408
+ appId: flavorAppId ?? `${defaultAppId ?? ''}${flavorAppIdSuffix ?? ''}`,
409
+ version:
410
+ flavorVersion ??
411
+ (defaultVersion
412
+ ? `${defaultVersion}${flavorVersionSuffix ?? ''}`
413
+ : null),
414
+ };
415
+ });
416
+
417
+ return {
418
+ defaultConfig: {
419
+ name: 'default',
420
+ label: 'Default',
421
+ appId: defaultAppId,
422
+ version: defaultVersion,
423
+ },
424
+ flavors,
425
+ };
426
+ }
427
+
428
+ async function getAndroidFlavorSelection() {
429
+ const metadata = getAndroidProjectMetadata();
430
+ if (!metadata) return null;
431
+
432
+ if (!metadata.flavors.length) {
433
+ return metadata.defaultConfig;
434
+ }
233
435
 
234
- const pbxprojPath = path.join(iosDir, projectDir, 'project.pbxproj');
235
- if (!fs.existsSync(pbxprojPath)) {
236
- console.warn('⚠️ project.pbxproj not found.');
436
+ let selectedFlavor;
437
+ let isFlavorConfirmed = false;
438
+
439
+ while (!isFlavorConfirmed) {
440
+ selectedFlavor = await select({
441
+ message: 'Select Android flavor:',
442
+ choices: [
443
+ {
444
+ name: `Default (${metadata.defaultConfig.appId ?? 'unknown app id'} / ${metadata.defaultConfig.version ?? 'unknown version'})`,
445
+ value: metadata.defaultConfig,
446
+ },
447
+ ...metadata.flavors.map((flavor) => ({
448
+ name: `${flavor.name} (${flavor.appId ?? 'unknown app id'} / ${flavor.version ?? 'unknown version'})`,
449
+ value: flavor,
450
+ })),
451
+ ],
452
+ });
453
+
454
+ isFlavorConfirmed = await confirm({
455
+ message: `Continue with Android flavor ${selectedFlavor.label ?? selectedFlavor.name}?`,
456
+ default: true,
457
+ });
458
+ }
459
+
460
+ return selectedFlavor;
461
+ }
462
+
463
+ function getIosProjectFiles() {
464
+ const projectRoot = getProjectRoot();
465
+ const iosDir = path.join(projectRoot, 'ios');
466
+ if (!fs.existsSync(iosDir)) {
467
+ console.warn(`⚠️ iOS folder not found at ${iosDir}`);
468
+ return null;
469
+ }
470
+
471
+ const xcodeProjPath = findFirstXcodeProj(iosDir);
472
+ if (!xcodeProjPath) {
473
+ console.warn('⚠️ .xcodeproj not found inside ios directory.');
474
+ return null;
475
+ }
476
+
477
+ const pbxprojPath = path.join(xcodeProjPath, 'project.pbxproj');
478
+ if (!fs.existsSync(pbxprojPath)) {
479
+ console.warn('⚠️ project.pbxproj not found.');
480
+ return null;
481
+ }
482
+
483
+ return { iosDir, xcodeProjPath, pbxprojPath };
484
+ }
485
+
486
+ function getIosTargetMetadata() {
487
+ const projectFiles = getIosProjectFiles();
488
+ if (!projectFiles) return null;
489
+
490
+ const pbxprojContent = fs.readFileSync(projectFiles.pbxprojPath, 'utf8');
491
+ const configObjects = parsePbxprojObjectsByIsa(
492
+ pbxprojContent,
493
+ 'XCBuildConfiguration'
494
+ );
495
+ const configMap = new Map(
496
+ configObjects.map((config) => [
497
+ config.id,
498
+ {
499
+ name: cleanPbxString(readPbxValue(config.body, 'name')),
500
+ version: cleanPbxString(readPbxValue(config.body, 'MARKETING_VERSION')),
501
+ appId: cleanPbxString(
502
+ readPbxValue(config.body, 'PRODUCT_BUNDLE_IDENTIFIER')
503
+ ),
504
+ productName: cleanPbxString(readPbxValue(config.body, 'PRODUCT_NAME')),
505
+ },
506
+ ])
507
+ );
508
+
509
+ const configListObjects = parsePbxprojObjectsByIsa(
510
+ pbxprojContent,
511
+ 'XCConfigurationList'
512
+ );
513
+ const configListMap = new Map(
514
+ configListObjects.map((configList) => [
515
+ configList.id,
516
+ {
517
+ defaultName: cleanPbxString(
518
+ readPbxValue(configList.body, 'defaultConfigurationName')
519
+ ),
520
+ buildConfigurations: parsePbxArray(
521
+ configList.body,
522
+ 'buildConfigurations'
523
+ ),
524
+ },
525
+ ])
526
+ );
527
+
528
+ const targetObjects = parsePbxprojObjectsByIsa(
529
+ pbxprojContent,
530
+ 'PBXNativeTarget'
531
+ );
532
+ const targets = targetObjects
533
+ .map((target) => {
534
+ const targetName = cleanPbxString(readPbxValue(target.body, 'name'));
535
+ const productType = cleanPbxString(
536
+ readPbxValue(target.body, 'productType')
537
+ );
538
+ const configListId = cleanPbxString(
539
+ readPbxValue(target.body, 'buildConfigurationList')
540
+ )?.match(/^([A-F0-9]{24})/)?.[1];
541
+
542
+ if (
543
+ !targetName ||
544
+ !configListId ||
545
+ !isAppLikeTarget(targetName, productType)
546
+ ) {
237
547
  return null;
238
548
  }
239
549
 
240
- const pbxprojContent = fs.readFileSync(pbxprojPath, 'utf8');
241
- const match = pbxprojContent.match(/MARKETING_VERSION\s*=\s*([\d.]+);/);
550
+ const configList = configListMap.get(configListId);
551
+ const buildConfigs =
552
+ configList?.buildConfigurations
553
+ .map((configId) => configMap.get(configId))
554
+ .filter(Boolean) ?? [];
242
555
 
243
- if (match && match[1]) {
244
- return match[1];
245
- } else {
246
- console.warn('⚠️ Could not find MARKETING_VERSION in project.pbxproj.');
247
- }
556
+ const configsByName = new Map(
557
+ buildConfigs
558
+ .filter((config) => config.name)
559
+ .map((config) => [config.name, config])
560
+ );
561
+
562
+ const preferredConfig =
563
+ buildConfigs.find(
564
+ (config) => config.name === configList?.defaultName
565
+ ) ??
566
+ buildConfigs.find((config) => config.name === 'Release') ??
567
+ buildConfigs[0];
568
+
569
+ if (!preferredConfig) return null;
570
+
571
+ return {
572
+ name: targetName,
573
+ label: targetName,
574
+ appId: preferredConfig.appId ?? null,
575
+ version: preferredConfig.version ?? null,
576
+ productName: preferredConfig.productName ?? targetName,
577
+ buildConfiguration: preferredConfig.name ?? null,
578
+ configsByName,
579
+ };
580
+ })
581
+ .filter(Boolean);
582
+
583
+ const uniqueTargets = targets.filter(
584
+ (target, index, allTargets) =>
585
+ allTargets.findIndex((candidate) => candidate.name === target.name) ===
586
+ index
587
+ );
588
+
589
+ return {
590
+ projectFiles,
591
+ defaultConfig: uniqueTargets[0] ?? null,
592
+ targets: uniqueTargets,
593
+ };
594
+ }
595
+
596
+ function parseSchemeFile(filePath) {
597
+ const content = fs.readFileSync(filePath, 'utf8');
598
+ const schemeName = path.basename(filePath, '.xcscheme');
599
+
600
+ const blueprintName =
601
+ content.match(/BlueprintName\s*=\s*"([^"]+)"/)?.[1] ?? schemeName;
602
+ const buildConfiguration =
603
+ content.match(
604
+ /ArchiveAction[^>]*buildConfiguration\s*=\s*"([^"]+)"/
605
+ )?.[1] ??
606
+ content.match(/LaunchAction[^>]*buildConfiguration\s*=\s*"([^"]+)"/)?.[1] ??
607
+ content.match(
608
+ /ProfileAction[^>]*buildConfiguration\s*=\s*"([^"]+)"/
609
+ )?.[1] ??
610
+ 'Release';
611
+
612
+ return {
613
+ scheme: schemeName,
614
+ targetName: blueprintName,
615
+ buildConfiguration,
616
+ };
617
+ }
618
+
619
+ function getIosSchemeMetadata() {
620
+ const iosMetadata = getIosTargetMetadata();
621
+ if (!iosMetadata) return null;
622
+
623
+ const schemeFiles = walkFiles(iosMetadata.projectFiles.iosDir, (filePath) =>
624
+ filePath.endsWith('.xcscheme')
625
+ );
626
+
627
+ const targetsByName = new Map(
628
+ iosMetadata.targets.map((target) => [target.name, target])
629
+ );
630
+
631
+ const schemes = schemeFiles
632
+ .map(parseSchemeFile)
633
+ .map((scheme) => {
634
+ const target = targetsByName.get(scheme.targetName);
635
+ if (!target) return null;
636
+
637
+ const config = target.configsByName.get(scheme.buildConfiguration) ??
638
+ target.configsByName.get('Release') ?? {
639
+ appId: target.appId,
640
+ version: target.version,
641
+ productName: target.productName,
642
+ name: target.buildConfiguration,
643
+ };
644
+
645
+ return {
646
+ name: scheme.scheme,
647
+ label: scheme.scheme,
648
+ targetName: target.name,
649
+ buildConfiguration: scheme.buildConfiguration,
650
+ appId: config.appId ?? target.appId ?? null,
651
+ version: config.version ?? target.version ?? null,
652
+ productName: config.productName ?? target.productName,
653
+ };
654
+ })
655
+ .filter(Boolean);
656
+
657
+ const schemesByTargetName = new Map(
658
+ schemes.map((scheme) => [scheme.targetName, scheme])
659
+ );
660
+
661
+ const mergedSchemes = iosMetadata.targets.map((target) => {
662
+ const matchedScheme = schemesByTargetName.get(target.name);
663
+ if (matchedScheme) {
664
+ return matchedScheme;
248
665
  }
249
- } catch (err) {
250
- console.warn(`⚠️ Failed to read ${platform} version:`, err.message);
666
+
667
+ return {
668
+ name: target.name,
669
+ label: `${target.label}${target.buildConfiguration ? ` [${target.buildConfiguration}]` : ''}`,
670
+ targetName: target.name,
671
+ buildConfiguration: target.buildConfiguration,
672
+ appId: target.appId,
673
+ version: target.version,
674
+ productName: target.productName,
675
+ };
676
+ });
677
+
678
+ const uniqueSchemes = mergedSchemes.filter(
679
+ (scheme, index, allSchemes) =>
680
+ allSchemes.findIndex((candidate) => candidate.name === scheme.name) ===
681
+ index
682
+ );
683
+
684
+ return {
685
+ defaultConfig: uniqueSchemes[0] ?? iosMetadata.defaultConfig,
686
+ schemes: uniqueSchemes,
687
+ };
688
+ }
689
+
690
+ async function getIosSchemeSelection() {
691
+ const metadata = getIosSchemeMetadata();
692
+ if (!metadata) return null;
693
+
694
+ if (metadata.schemes.length <= 1) {
695
+ return metadata.defaultConfig;
696
+ }
697
+
698
+ let selectedScheme;
699
+ let isSchemeConfirmed = false;
700
+
701
+ while (!isSchemeConfirmed) {
702
+ selectedScheme = await select({
703
+ message: 'Select iOS scheme:',
704
+ choices: metadata.schemes.map((scheme) => ({
705
+ name: `${scheme.label} (${scheme.appId ?? 'unknown app id'} / ${scheme.version ?? 'unknown version'})`,
706
+ value: scheme,
707
+ })),
708
+ });
709
+
710
+ isSchemeConfirmed = await confirm({
711
+ message: `Continue with iOS scheme ${selectedScheme.label ?? selectedScheme.name}?`,
712
+ default: true,
713
+ });
714
+ }
715
+
716
+ return selectedScheme;
717
+ }
718
+
719
+ function getPlatformAppVersion(platform, selection) {
720
+ if (selection?.version) return selection.version;
721
+
722
+ if (platform === 'android') {
723
+ const metadata = getAndroidProjectMetadata();
724
+ return metadata?.defaultConfig.version ?? null;
725
+ }
726
+
727
+ if (platform === 'ios') {
728
+ const metadata = getIosSchemeMetadata();
729
+ return metadata?.defaultConfig.version ?? null;
251
730
  }
252
731
 
253
732
  return null;
254
733
  }
255
734
 
256
- /**
257
- * Get common configuration (API token, project ID, env).
258
- */
259
735
  async function getCommonConfig() {
260
- console.log(`\n⚙️ Enter common configuration (applies to both platforms)\n`);
736
+ console.log(`\n⚙️ Enter common configuration for the app\n`);
261
737
 
262
738
  const API_TOKEN = await input({
263
- message: `Enter API Token:`,
739
+ message: 'Enter API Token:',
264
740
  validate: (val) => (val.trim() ? true : 'API Token is required'),
265
741
  });
266
742
 
267
743
  const PROJECT_ID = await input({
268
- message: `Enter Project ID:`,
744
+ message: 'Enter Project ID:',
269
745
  validate: (val) => (val.trim() ? true : 'Project ID is required'),
270
746
  });
271
747
 
272
- const ENVIRONMENT = await select({
273
- message: `Select Environment:`,
274
- choices: [
275
- { name: 'development', value: 'development' },
276
- { name: 'production', value: 'production' },
277
- ],
278
- });
748
+ let ENVIRONMENT;
749
+ let isEnvironmentConfirmed = false;
750
+
751
+ while (!isEnvironmentConfirmed) {
752
+ ENVIRONMENT = await select({
753
+ message: 'Select Environment:',
754
+ choices: [
755
+ { name: 'development', value: 'development' },
756
+ { name: 'production', value: 'production' },
757
+ ],
758
+ });
759
+
760
+ isEnvironmentConfirmed = await confirm({
761
+ message: `Continue with ${ENVIRONMENT} environment?`,
762
+ default: true,
763
+ });
764
+ }
279
765
 
280
766
  return { API_TOKEN, PROJECT_ID, ENVIRONMENT };
281
767
  }
282
768
 
283
- /**
284
- * Get platform-specific configuration with version auto-detection.
285
- */
286
- async function getPlatformSpecificConfig(platform) {
769
+ async function getPlatformConfig(platform) {
287
770
  console.log(`\n🔧 Configuring ${platform.toUpperCase()}...\n`);
288
771
 
289
- let detectedVersion = getAppVersion(platform);
772
+ const selection =
773
+ platform === 'android'
774
+ ? await getAndroidFlavorSelection()
775
+ : platform === 'ios'
776
+ ? await getIosSchemeSelection()
777
+ : null;
778
+
779
+ if (platform === 'android' && selection?.label) {
780
+ console.log(
781
+ `📦 Selected Android flavor: ${selection.label} (${selection.appId ?? 'unknown app id'})`
782
+ );
783
+ }
784
+
785
+ if (platform === 'ios' && selection?.label) {
786
+ console.log(
787
+ `🍎 Selected iOS scheme: ${selection.label} (${selection.appId ?? 'unknown app id'})`
788
+ );
789
+ }
790
+
791
+ let detectedVersion = getPlatformAppVersion(platform, selection);
290
792
  if (detectedVersion) {
291
793
  console.log(`📱 Detected ${platform} version: ${detectedVersion}`);
292
794
  } else {
@@ -302,12 +804,106 @@ async function getPlatformSpecificConfig(platform) {
302
804
  default: false,
303
805
  });
304
806
 
305
- return { VERSION: detectedVersion, FORCE_UPDATE };
807
+ return {
808
+ VERSION: detectedVersion,
809
+ FORCE_UPDATE,
810
+ APP_ID: selection?.appId ?? null,
811
+ VARIANT: selection?.name ?? null,
812
+ };
813
+ }
814
+
815
+ function getAndroidOutputPaths(projectRoot) {
816
+ return {
817
+ bundleZipPath: path.join(
818
+ projectRoot,
819
+ 'android',
820
+ 'index.android.bundle.zip'
821
+ ),
822
+ outputDir: path.join(projectRoot, 'android', 'output'),
823
+ sourceMapPath: path.join(projectRoot, 'android', 'sourcemap.js'),
824
+ sourceMapZipPath: path.join(projectRoot, 'android', 'sourcemap.zip'),
825
+ };
826
+ }
827
+
828
+ function getIosOutputPaths(projectRoot) {
829
+ return {
830
+ bundleZipPath: path.join(projectRoot, 'ios', 'main.jsbundle.zip'),
831
+ outputDir: path.join(projectRoot, 'ios', 'output'),
832
+ sourceMapPath: path.join(projectRoot, 'ios', 'sourcemap.js'),
833
+ sourceMapZipPath: path.join(projectRoot, 'ios', 'sourcemap.zip'),
834
+ };
835
+ }
836
+
837
+ function buildAndroid(selection) {
838
+ const projectRoot = getProjectRoot();
839
+ const paths = getAndroidOutputPaths(projectRoot);
840
+
841
+ console.log('📦 Building Android bundle...');
842
+ if (selection?.label) {
843
+ console.log(`📦 Using Android flavor: ${selection.label}`);
844
+ }
845
+
846
+ fs.rmSync(paths.outputDir, { recursive: true, force: true });
847
+ fs.rmSync(paths.bundleZipPath, { force: true });
848
+ fs.rmSync(paths.sourceMapPath, { force: true });
849
+ fs.rmSync(paths.sourceMapZipPath, { force: true });
850
+ fs.mkdirSync(paths.outputDir, { recursive: true });
851
+
852
+ run(
853
+ `react-native bundle --platform android --dev false --entry-file index.js ` +
854
+ `--bundle-output ${path.relative(projectRoot, path.join(paths.outputDir, 'index.android.bundle'))} ` +
855
+ `--assets-dest ${path.relative(projectRoot, paths.outputDir)} ` +
856
+ `--sourcemap-output ${path.relative(projectRoot, paths.sourceMapPath)}`,
857
+ projectRoot
858
+ );
859
+ run(
860
+ `find output -type f | zip index.android.bundle.zip -@`,
861
+ path.join(projectRoot, 'android')
862
+ );
863
+ run(`zip sourcemap.zip sourcemap.js`, path.join(projectRoot, 'android'));
864
+
865
+ fs.rmSync(paths.outputDir, { recursive: true, force: true });
866
+ fs.rmSync(paths.sourceMapPath, { force: true });
867
+
868
+ console.log(`✅ Android bundle created at ${paths.bundleZipPath}`);
869
+ return paths.bundleZipPath;
870
+ }
871
+
872
+ function buildIOS(selection) {
873
+ const projectRoot = getProjectRoot();
874
+ const paths = getIosOutputPaths(projectRoot);
875
+
876
+ console.log('📦 Building iOS bundle...');
877
+ if (selection?.label) {
878
+ console.log(`🍎 Using iOS scheme: ${selection.label}`);
879
+ }
880
+
881
+ fs.rmSync(paths.outputDir, { recursive: true, force: true });
882
+ fs.rmSync(paths.bundleZipPath, { force: true });
883
+ fs.rmSync(paths.sourceMapPath, { force: true });
884
+ fs.rmSync(paths.sourceMapZipPath, { force: true });
885
+ fs.mkdirSync(paths.outputDir, { recursive: true });
886
+
887
+ run(
888
+ `react-native bundle --platform ios --dev false --entry-file index.js ` +
889
+ `--bundle-output ${path.relative(projectRoot, path.join(paths.outputDir, 'main.jsbundle'))} ` +
890
+ `--assets-dest ${path.relative(projectRoot, paths.outputDir)} ` +
891
+ `--sourcemap-output ${path.relative(projectRoot, paths.sourceMapPath)}`,
892
+ projectRoot
893
+ );
894
+ run(
895
+ `find output -type f | zip main.jsbundle.zip -@`,
896
+ path.join(projectRoot, 'ios')
897
+ );
898
+ run(`zip sourcemap.zip sourcemap.js`, path.join(projectRoot, 'ios'));
899
+
900
+ fs.rmSync(paths.outputDir, { recursive: true, force: true });
901
+ fs.rmSync(paths.sourceMapPath, { force: true });
902
+
903
+ console.log(`✅ iOS bundle created at ${paths.bundleZipPath}`);
904
+ return paths.bundleZipPath;
306
905
  }
307
906
 
308
- /**
309
- * Entry point
310
- */
311
907
  (async () => {
312
908
  try {
313
909
  const platform = process.argv[2];
@@ -319,34 +915,36 @@ async function getPlatformSpecificConfig(platform) {
319
915
  const commonConfig = await getCommonConfig();
320
916
 
321
917
  if (platform === 'android') {
322
- const androidExtra = await getPlatformSpecificConfig('android');
323
- const config = { ...commonConfig, ...androidExtra };
324
- const androidFile = buildAndroid();
918
+ const androidConfig = await getPlatformConfig('android');
919
+ const androidFile = buildAndroid({ label: androidConfig.VARIANT });
325
920
  await uploadBundle({
326
921
  filePath: androidFile,
327
922
  platform: 'android',
328
- config,
923
+ config: { ...commonConfig, ...androidConfig },
329
924
  });
330
925
  } else if (platform === 'ios') {
331
- const iosExtra = await getPlatformSpecificConfig('ios');
332
- const config = { ...commonConfig, ...iosExtra };
333
- const iosFile = buildIOS();
334
- await uploadBundle({ filePath: iosFile, platform: 'ios', config });
926
+ const iosConfig = await getPlatformConfig('ios');
927
+ const iosFile = buildIOS({ label: iosConfig.VARIANT });
928
+ await uploadBundle({
929
+ filePath: iosFile,
930
+ platform: 'ios',
931
+ config: { ...commonConfig, ...iosConfig },
932
+ });
335
933
  } else if (platform === 'all') {
336
- const androidExtra = await getPlatformSpecificConfig('android');
337
- const androidFile = buildAndroid();
934
+ const androidConfig = await getPlatformConfig('android');
935
+ const androidFile = buildAndroid({ label: androidConfig.VARIANT });
338
936
  await uploadBundle({
339
937
  filePath: androidFile,
340
938
  platform: 'android',
341
- config: { ...commonConfig, ...androidExtra },
939
+ config: { ...commonConfig, ...androidConfig },
342
940
  });
343
941
 
344
- const iosExtra = await getPlatformSpecificConfig('ios');
345
- const iosFile = buildIOS();
942
+ const iosConfig = await getPlatformConfig('ios');
943
+ const iosFile = buildIOS({ label: iosConfig.VARIANT });
346
944
  await uploadBundle({
347
945
  filePath: iosFile,
348
946
  platform: 'ios',
349
- config: { ...commonConfig, ...iosExtra },
947
+ config: { ...commonConfig, ...iosConfig },
350
948
  });
351
949
  } else {
352
950
  console.log('❌ Invalid option. Use: android | ios | all');