@teardown/cli 2.0.79 → 2.0.80

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teardown/cli",
3
- "version": "2.0.79",
3
+ "version": "2.0.80",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -76,7 +76,7 @@
76
76
  },
77
77
  "devDependencies": {
78
78
  "@biomejs/biome": "2.3.11",
79
- "@teardown/tsconfig": "2.0.79",
79
+ "@teardown/tsconfig": "2.0.80",
80
80
  "@types/bun": "1.3.5",
81
81
  "@types/ejs": "^3.1.5",
82
82
  "typescript": "5.9.3"
@@ -97,6 +97,73 @@ function hasPodsInstalled(): boolean {
97
97
  }
98
98
  }
99
99
 
100
+ /**
101
+ * Parse CocoaPods output line to extract activity status
102
+ */
103
+ function parsePodOutputLine(line: string): string | null {
104
+ const trimmed = line.trim();
105
+ if (!trimmed) return null;
106
+
107
+ if (trimmed.startsWith("Analyzing dependencies")) {
108
+ return "analyzing dependencies";
109
+ }
110
+ if (trimmed.startsWith("Downloading dependencies")) {
111
+ return "downloading dependencies";
112
+ }
113
+ if (trimmed.startsWith("Installing")) {
114
+ const match = trimmed.match(/Installing (\S+)/);
115
+ return match ? `installing ${match[1]}` : null;
116
+ }
117
+ if (trimmed.startsWith("Generating Pods project")) {
118
+ return "generating Pods project";
119
+ }
120
+ if (trimmed.startsWith("Integrating client project")) {
121
+ return "integrating project";
122
+ }
123
+ if (trimmed.includes("Pod installation complete")) {
124
+ return "completing";
125
+ }
126
+ return null;
127
+ }
128
+
129
+ /**
130
+ * Get spinner text based on elapsed time
131
+ */
132
+ function getPodSpinnerText(elapsed: number, lastActivity: string): string {
133
+ const activity = lastActivity ? ` - ${lastActivity}` : "";
134
+ const base = `Installing CocoaPods dependencies... (${elapsed}s)`;
135
+
136
+ if (elapsed === 60) {
137
+ return `${base} - still working, this can take a while`;
138
+ }
139
+ if (elapsed === 120) {
140
+ return `${base} - updating pod repos`;
141
+ }
142
+ if (elapsed === 180) {
143
+ return `${base} - this is taking longer than usual`;
144
+ }
145
+ return `${base}${activity}`;
146
+ }
147
+
148
+ /**
149
+ * Handle pod install failure output
150
+ */
151
+ function handlePodInstallFailure(stderr: string, stdout: string, isTimeout: boolean): void {
152
+ if (isTimeout) {
153
+ console.log(chalk.yellow("\nThis usually means:"));
154
+ console.log(chalk.gray(" - Network connection issues"));
155
+ console.log(chalk.gray(" - Slow CocoaPods CDN"));
156
+ console.log(chalk.gray(" - Large number of pods to install"));
157
+ } else {
158
+ console.error(chalk.red(stderr));
159
+ }
160
+ if (stdout) {
161
+ console.log(chalk.gray("\nLast output:"), stdout.slice(-500));
162
+ }
163
+ console.log(chalk.yellow("\nTry running manually:"));
164
+ console.log(chalk.gray(" cd ios && pod install --repo-update"));
165
+ }
166
+
100
167
  /**
101
168
  * Run pod install manually
102
169
  */
@@ -120,48 +187,19 @@ async function runPodInstall(): Promise<boolean> {
120
187
  let stderr = "";
121
188
  let stdout = "";
122
189
 
123
- // Update spinner with elapsed time and activity
124
190
  const progressInterval = setInterval(() => {
125
191
  const elapsed = Math.round((Date.now() - startTime) / 1000);
126
- const activity = lastActivity ? ` - ${lastActivity}` : "";
127
- spinner.text = `Installing CocoaPods dependencies... (${elapsed}s)${activity}`;
128
-
129
- // Warn if taking too long
130
- if (elapsed === 60) {
131
- spinner.text = `Installing CocoaPods dependencies... (${elapsed}s) - still working, this can take a while`;
132
- } else if (elapsed === 120) {
133
- spinner.text = `Installing CocoaPods dependencies... (${elapsed}s) - updating pod repos`;
134
- } else if (elapsed === 180) {
135
- spinner.text = `Installing CocoaPods dependencies... (${elapsed}s) - this is taking longer than usual`;
136
- }
192
+ spinner.text = getPodSpinnerText(elapsed, lastActivity);
137
193
  }, 1000);
138
194
 
139
195
  proc.stdout?.on("data", (data) => {
140
196
  const chunk = data.toString();
141
197
  stdout += chunk;
142
198
 
143
- // Parse CocoaPods output for status
144
199
  for (const line of chunk.split("\n")) {
145
- const trimmed = line.trim();
146
- if (!trimmed) continue;
147
-
148
- // Extract useful status from pod output
149
- if (trimmed.startsWith("Analyzing dependencies")) {
150
- lastActivity = "analyzing dependencies";
151
- } else if (trimmed.startsWith("Downloading dependencies")) {
152
- lastActivity = "downloading dependencies";
153
- } else if (trimmed.startsWith("Installing")) {
154
- // Extract pod name: "Installing PodName (1.0.0)"
155
- const match = trimmed.match(/Installing (\S+)/);
156
- if (match) {
157
- lastActivity = `installing ${match[1]}`;
158
- }
159
- } else if (trimmed.startsWith("Generating Pods project")) {
160
- lastActivity = "generating Pods project";
161
- } else if (trimmed.startsWith("Integrating client project")) {
162
- lastActivity = "integrating project";
163
- } else if (trimmed.includes("Pod installation complete")) {
164
- lastActivity = "completing";
200
+ const activity = parsePodOutputLine(line);
201
+ if (activity) {
202
+ lastActivity = activity;
165
203
  }
166
204
  }
167
205
  });
@@ -179,12 +217,7 @@ async function runPodInstall(): Promise<boolean> {
179
217
  resolve(true);
180
218
  } else {
181
219
  spinner.fail(`Failed to install CocoaPods dependencies after ${elapsed}s`);
182
- console.error(chalk.red(stderr));
183
- if (stdout) {
184
- console.log(chalk.gray("Last output:"), stdout.slice(-500));
185
- }
186
- console.log(chalk.yellow("\nTry running manually:"));
187
- console.log(chalk.gray(" cd ios && pod install --repo-update"));
220
+ handlePodInstallFailure(stderr, stdout, false);
188
221
  resolve(false);
189
222
  }
190
223
  });
@@ -195,21 +228,13 @@ async function runPodInstall(): Promise<boolean> {
195
228
  resolve(false);
196
229
  });
197
230
 
198
- // Timeout after 10 minutes (pod install can be slow)
199
231
  const timeout = setTimeout(
200
232
  () => {
201
233
  clearInterval(progressInterval);
202
234
  const elapsed = Math.round((Date.now() - startTime) / 1000);
203
235
  proc.kill("SIGTERM");
204
236
  spinner.fail(`pod install timed out after ${elapsed}s`);
205
- console.log(chalk.yellow("\nThis usually means:"));
206
- console.log(chalk.gray(" - Network connection issues"));
207
- console.log(chalk.gray(" - Slow CocoaPods CDN"));
208
- console.log(chalk.gray(" - Large number of pods to install"));
209
- console.log(chalk.gray("\nTry running manually: cd ios && pod install --repo-update"));
210
- if (stdout) {
211
- console.log(chalk.gray("\nLast output:"), stdout.slice(-500));
212
- }
237
+ handlePodInstallFailure(stderr, stdout, true);
213
238
  resolve(false);
214
239
  },
215
240
  10 * 60 * 1000
@@ -293,6 +318,289 @@ interface RunOptions {
293
318
  clean?: boolean;
294
319
  }
295
320
 
321
+ /**
322
+ * Pre-generate routes if navigation-metro is installed
323
+ */
324
+ function preGenerateRoutes(projectRoot: string): void {
325
+ const navigationMetroPath = join(projectRoot, "node_modules/@teardown/navigation-metro");
326
+ const navConfig = getNavigationConfig(projectRoot);
327
+
328
+ if (!existsSync(navigationMetroPath) || !navConfig) {
329
+ return;
330
+ }
331
+
332
+ const routesDir = resolve(projectRoot, navConfig.routesDir);
333
+ const generatedDir = resolve(projectRoot, navConfig.generatedDir);
334
+
335
+ if (!existsSync(routesDir)) {
336
+ return;
337
+ }
338
+
339
+ try {
340
+ // Dynamic require from user's node_modules
341
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
342
+ const { generateAllRouteFiles } = require(navigationMetroPath) as {
343
+ generateAllRouteFiles: (opts: {
344
+ routesDir: string;
345
+ generatedDir: string;
346
+ prefixes: string[];
347
+ verbose: boolean;
348
+ }) => void;
349
+ };
350
+ const slug = getAppScheme(projectRoot) || "app";
351
+ generateAllRouteFiles({
352
+ routesDir,
353
+ generatedDir,
354
+ prefixes: [`${slug}://`],
355
+ verbose: false,
356
+ });
357
+ } catch {
358
+ // Ignore - Metro will regenerate
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Select iOS device based on options
364
+ */
365
+ async function selectIOSDevice(devices: iOSDevice[], options: RunOptions): Promise<iOSDevice> {
366
+ if (options.device) {
367
+ const found = devices.find(
368
+ (d) => d.name.toLowerCase() === options.device?.toLowerCase() || d.udid === options.device
369
+ );
370
+ if (!found) {
371
+ console.error(chalk.red(`Device not found: ${options.device}`));
372
+ console.log(chalk.gray("\nAvailable devices:"));
373
+ for (const d of devices) {
374
+ console.log(chalk.gray(` - ${d.name} (${d.state})`));
375
+ }
376
+ process.exit(1);
377
+ }
378
+ return found;
379
+ }
380
+
381
+ if (!options.picker) {
382
+ return devices.find((d) => d.state === "Booted") || devices[0];
383
+ }
384
+
385
+ return pickDevice(devices, "iOS");
386
+ }
387
+
388
+ /**
389
+ * Build iOS command arguments
390
+ */
391
+ function buildIOSArgs(options: RunOptions, deviceName: string): string[] {
392
+ const args = ["react-native", "run-ios", "--simulator", deviceName, "--port", options.port, "--no-packager"];
393
+
394
+ const configuration = options.configuration || (options.release ? "Release" : "Debug");
395
+ args.push("--mode", configuration);
396
+
397
+ if (options.scheme) {
398
+ args.push("--scheme", options.scheme);
399
+ }
400
+
401
+ return args;
402
+ }
403
+
404
+ /**
405
+ * Build Android command arguments
406
+ */
407
+ function buildAndroidArgs(
408
+ device: AndroidDevice,
409
+ options: { release?: boolean; port: string; variant?: string }
410
+ ): string[] {
411
+ const args = ["react-native", "run-android", "--port", options.port, "--no-packager"];
412
+
413
+ if (device.type === "device") {
414
+ args.push("--deviceId", device.id);
415
+ }
416
+
417
+ if (options.variant) {
418
+ args.push("--variant", options.variant);
419
+ } else if (options.release) {
420
+ args.push("--mode", "release");
421
+ }
422
+
423
+ return args;
424
+ }
425
+
426
+ /**
427
+ * Clean build directory if requested
428
+ */
429
+ async function cleanBuildIfNeeded(platform: "ios" | "android", shouldClean?: boolean): Promise<void> {
430
+ if (!shouldClean) {
431
+ return;
432
+ }
433
+
434
+ if (platform === "ios") {
435
+ console.log(chalk.blue("Cleaning iOS build..."));
436
+ try {
437
+ await execAsync("xcodebuild clean", { cwd: join(process.cwd(), "ios") });
438
+ } catch {
439
+ // Ignore clean errors
440
+ }
441
+ } else {
442
+ console.log(chalk.blue("Cleaning Android build..."));
443
+ try {
444
+ await execAsync("./gradlew clean", { cwd: join(process.cwd(), "android") });
445
+ } catch {
446
+ // Ignore clean errors
447
+ }
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Launch app with bundler connection via deep link
453
+ */
454
+ async function launchWithBundlerIfNeeded(
455
+ platform: "ios" | "android",
456
+ metroProcess: ChildProcess | null,
457
+ options: { release?: boolean; port: string },
458
+ deviceId: string,
459
+ deviceType?: "emulator" | "device"
460
+ ): Promise<void> {
461
+ if (!metroProcess || options.release) {
462
+ return;
463
+ }
464
+
465
+ const scheme = getAppScheme(process.cwd());
466
+ if (!scheme) {
467
+ return;
468
+ }
469
+
470
+ let bundlerUrl: string;
471
+ if (platform === "android" && deviceType === "emulator") {
472
+ bundlerUrl = `http://10.0.2.2:${options.port}`;
473
+ } else {
474
+ bundlerUrl = `http://localhost:${options.port}`;
475
+ }
476
+
477
+ console.log(chalk.blue("\nLaunching app with bundler connection..."));
478
+ try {
479
+ await launchAppWithBundler(platform, scheme, bundlerUrl, deviceId);
480
+ console.log(chalk.green(`✓ App launched with bundler: ${bundlerUrl}`));
481
+ } catch (error) {
482
+ console.log(
483
+ chalk.yellow(`Note: Deep link launch skipped - ${error instanceof Error ? error.message : "unknown error"}`)
484
+ );
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Select Android device based on options
490
+ */
491
+ async function selectAndroidDevice(devices: AndroidDevice[], options: RunOptions): Promise<AndroidDevice> {
492
+ if (options.device) {
493
+ const found = devices.find(
494
+ (d) => d.name.toLowerCase() === options.device?.toLowerCase() || d.id === options.device
495
+ );
496
+ if (!found) {
497
+ console.error(chalk.red(`Device not found: ${options.device}`));
498
+ console.log(chalk.gray("\nAvailable devices:"));
499
+ for (const d of devices) {
500
+ console.log(chalk.gray(` - ${d.name} (${d.type})`));
501
+ }
502
+ process.exit(1);
503
+ }
504
+ return found;
505
+ }
506
+
507
+ if (!options.picker || devices.length === 1) {
508
+ return devices[0];
509
+ }
510
+
511
+ return pickAndroidDevice(devices);
512
+ }
513
+
514
+ /**
515
+ * Handle case when no Android devices are running but AVDs are available
516
+ * Returns true if an emulator was started and app should be launched
517
+ */
518
+ async function handleNoDevicesWithAVDs(options: RunOptions, metroProcess: ChildProcess | null): Promise<boolean> {
519
+ const avds = await getAndroidAVDs();
520
+ if (avds.length === 0) {
521
+ return false;
522
+ }
523
+
524
+ console.log(chalk.gray("\nAvailable emulators (not running):"));
525
+ for (const avd of avds) {
526
+ console.log(chalk.gray(` - ${avd}`));
527
+ }
528
+
529
+ const selectedAVD = await pickAVD(avds);
530
+ if (!selectedAVD) {
531
+ return false;
532
+ }
533
+
534
+ const bootSpinner = ora(`Starting ${selectedAVD}...`).start();
535
+ await startAndroidEmulator(selectedAVD);
536
+ bootSpinner.succeed(`${selectedAVD} is starting`);
537
+
538
+ await waitForAndroidDevice();
539
+
540
+ const newDevices = await getAndroidDevices();
541
+ if (newDevices.length === 0) {
542
+ return false;
543
+ }
544
+
545
+ await launchAndroidApp(
546
+ newDevices[0],
547
+ {
548
+ release: options.release,
549
+ port: options.port,
550
+ variant: options.variant,
551
+ clean: options.clean,
552
+ },
553
+ metroProcess
554
+ );
555
+ return true;
556
+ }
557
+
558
+ /**
559
+ * Execute the run command for a platform
560
+ */
561
+ async function executeRunCommand(platform: "ios" | "android", options: RunOptions): Promise<void> {
562
+ let metroProcess: ChildProcess | null = null;
563
+
564
+ const cleanup = () => {
565
+ if (metroProcess) {
566
+ metroProcess.kill("SIGTERM");
567
+ }
568
+ process.exit(0);
569
+ };
570
+ process.on("SIGINT", cleanup);
571
+ process.on("SIGTERM", cleanup);
572
+
573
+ try {
574
+ const hasProject = await ensureNativeProject(platform);
575
+ if (!hasProject) {
576
+ process.exit(1);
577
+ }
578
+
579
+ if (options.bundler && !options.release) {
580
+ preGenerateRoutes(process.cwd());
581
+ metroProcess = await startMetroAndWait(options.port, platform);
582
+ }
583
+
584
+ if (platform === "ios") {
585
+ await runIOS(options, metroProcess);
586
+ } else {
587
+ await runAndroid(options, metroProcess);
588
+ }
589
+
590
+ if (metroProcess) {
591
+ console.log(chalk.blue("\n─".repeat(50)));
592
+ console.log(chalk.blue("Metro bundler running. Press Ctrl+C to stop.\n"));
593
+ attachBundlerToForeground(metroProcess);
594
+ }
595
+ } catch (error) {
596
+ if (metroProcess) {
597
+ metroProcess.kill("SIGTERM");
598
+ }
599
+ console.error(chalk.red(`Failed to run app: ${error instanceof Error ? error.message : error}`));
600
+ process.exit(1);
601
+ }
602
+ }
603
+
296
604
  /**
297
605
  * Create the run command
298
606
  */
@@ -314,87 +622,7 @@ export function createRunCommand(): Command {
314
622
  console.error(chalk.red(`Invalid platform: ${platform}. Use 'ios' or 'android'.`));
315
623
  process.exit(1);
316
624
  }
317
-
318
- // Track Metro process for cleanup
319
- let metroProcess: ChildProcess | null = null;
320
-
321
- // Handle SIGINT (Ctrl+C) to clean up Metro
322
- const cleanup = () => {
323
- if (metroProcess) {
324
- metroProcess.kill("SIGTERM");
325
- }
326
- process.exit(0);
327
- };
328
- process.on("SIGINT", cleanup);
329
- process.on("SIGTERM", cleanup);
330
-
331
- try {
332
- // Ensure native project exists (auto-prebuild if needed)
333
- const hasProject = await ensureNativeProject(platform);
334
- if (!hasProject) {
335
- process.exit(1);
336
- }
337
-
338
- // Start Metro bundler FIRST (unless --no-bundler or --release)
339
- if (options.bundler && !options.release) {
340
- // Pre-generate routes if navigation-metro is installed
341
- const projectRoot = process.cwd();
342
- const navigationMetroPath = join(projectRoot, "node_modules/@teardown/navigation-metro");
343
- const navConfig = getNavigationConfig(projectRoot);
344
-
345
- if (existsSync(navigationMetroPath) && navConfig) {
346
- const routesDir = resolve(projectRoot, navConfig.routesDir);
347
- const generatedDir = resolve(projectRoot, navConfig.generatedDir);
348
-
349
- if (existsSync(routesDir)) {
350
- try {
351
- // Dynamic require from user's node_modules
352
- // eslint-disable-next-line @typescript-eslint/no-require-imports
353
- const { generateAllRouteFiles } = require(navigationMetroPath) as {
354
- generateAllRouteFiles: (opts: {
355
- routesDir: string;
356
- generatedDir: string;
357
- prefixes: string[];
358
- verbose: boolean;
359
- }) => void;
360
- };
361
- const slug = getAppScheme(projectRoot) || "app";
362
- generateAllRouteFiles({
363
- routesDir,
364
- generatedDir,
365
- prefixes: [`${slug}://`],
366
- verbose: false,
367
- });
368
- } catch {
369
- // Ignore - Metro will regenerate
370
- }
371
- }
372
- }
373
-
374
- metroProcess = await startMetroAndWait(options.port, platform);
375
- }
376
-
377
- // Run platform-specific build and launch
378
- if (platform === "ios") {
379
- await runIOS(options, metroProcess);
380
- } else {
381
- await runAndroid(options, metroProcess);
382
- }
383
-
384
- // If Metro is running, attach to foreground
385
- if (metroProcess) {
386
- console.log(chalk.blue("\n─".repeat(50)));
387
- console.log(chalk.blue("Metro bundler running. Press Ctrl+C to stop.\n"));
388
- attachBundlerToForeground(metroProcess);
389
- }
390
- } catch (error) {
391
- // Clean up Metro on error
392
- if (metroProcess) {
393
- metroProcess.kill("SIGTERM");
394
- }
395
- console.error(chalk.red(`Failed to run app: ${error instanceof Error ? error.message : error}`));
396
- process.exit(1);
397
- }
625
+ await executeRunCommand(platform, options);
398
626
  });
399
627
 
400
628
  return run;
@@ -452,30 +680,7 @@ async function runIOS(options: RunOptions, metroProcess: ChildProcess | null): P
452
680
  process.exit(1);
453
681
  }
454
682
 
455
- let selectedDevice: iOSDevice;
456
-
457
- if (options.device) {
458
- // Find device by name or udid
459
- const found = devices.find(
460
- (d) => d.name.toLowerCase() === options.device?.toLowerCase() || d.udid === options.device
461
- );
462
- if (!found) {
463
- console.error(chalk.red(`Device not found: ${options.device}`));
464
- console.log(chalk.gray("\nAvailable devices:"));
465
- for (const d of devices) {
466
- console.log(chalk.gray(` - ${d.name} (${d.state})`));
467
- }
468
- process.exit(1);
469
- }
470
- selectedDevice = found;
471
- } else if (!options.picker) {
472
- // Use first booted device, or first available
473
- selectedDevice = devices.find((d) => d.state === "Booted") || devices[0];
474
- } else {
475
- // Show device picker
476
- selectedDevice = await pickDevice(devices, "iOS");
477
- }
478
-
683
+ const selectedDevice = await selectIOSDevice(devices, options);
479
684
  console.log(chalk.blue(`\nSelected device: ${selectedDevice.name}`));
480
685
 
481
686
  // Boot device if needed
@@ -488,54 +693,12 @@ async function runIOS(options: RunOptions, metroProcess: ChildProcess | null): P
488
693
  // Build the app
489
694
  console.log(chalk.blue("\nBuilding iOS app...\n"));
490
695
 
491
- const args = [
492
- "react-native",
493
- "run-ios",
494
- "--simulator",
495
- selectedDevice.name,
496
- "--port",
497
- options.port,
498
- "--no-packager",
499
- ];
500
-
501
- // Handle configuration/release mode
502
- const configuration = options.configuration || (options.release ? "Release" : "Debug");
503
- args.push("--mode", configuration);
504
-
505
- // Handle scheme if specified
506
- if (options.scheme) {
507
- args.push("--scheme", options.scheme);
508
- }
509
-
510
- // Handle clean build
511
- if (options.clean) {
512
- console.log(chalk.blue("Cleaning iOS build..."));
513
- try {
514
- await execAsync("xcodebuild clean", { cwd: join(process.cwd(), "ios") });
515
- } catch {
516
- // Ignore clean errors
517
- }
518
- }
696
+ await cleanBuildIfNeeded("ios", options.clean);
519
697
 
698
+ const args = buildIOSArgs(options, selectedDevice.name);
520
699
  await runCommand("npx", args);
521
700
 
522
- // If Metro is running, launch app via deep link to connect to bundler
523
- if (metroProcess && !options.release) {
524
- const scheme = getAppScheme(process.cwd());
525
- if (scheme) {
526
- const bundlerUrl = `http://localhost:${options.port}`;
527
- console.log(chalk.blue("\nLaunching app with bundler connection..."));
528
- try {
529
- await launchAppWithBundler("ios", scheme, bundlerUrl, selectedDevice.udid);
530
- console.log(chalk.green(`✓ App launched with bundler: ${bundlerUrl}`));
531
- } catch (error) {
532
- // Deep link launch failed, but app should still work
533
- console.log(
534
- chalk.yellow(`Note: Deep link launch skipped - ${error instanceof Error ? error.message : "unknown error"}`)
535
- );
536
- }
537
- }
538
- }
701
+ await launchWithBundlerIfNeeded("ios", metroProcess, options, selectedDevice.udid);
539
702
  } catch (error) {
540
703
  spinner.fail("Failed to detect iOS devices");
541
704
  throw error;
@@ -555,65 +718,16 @@ async function runAndroid(options: RunOptions, metroProcess: ChildProcess | null
555
718
  if (devices.length === 0) {
556
719
  console.log(chalk.yellow("\nNo Android devices or emulators found."));
557
720
 
558
- // Check for available AVDs
559
- const avds = await getAndroidAVDs();
560
- if (avds.length > 0) {
561
- console.log(chalk.gray("\nAvailable emulators (not running):"));
562
- for (const avd of avds) {
563
- console.log(chalk.gray(` - ${avd}`));
564
- }
565
-
566
- const selectedAVD = await pickAVD(avds);
567
- if (selectedAVD) {
568
- const bootSpinner = ora(`Starting ${selectedAVD}...`).start();
569
- await startAndroidEmulator(selectedAVD);
570
- bootSpinner.succeed(`${selectedAVD} is starting`);
571
-
572
- // Wait for device to be ready
573
- await waitForAndroidDevice();
574
-
575
- // Re-fetch devices
576
- const newDevices = await getAndroidDevices();
577
- if (newDevices.length > 0) {
578
- await launchAndroidApp(
579
- newDevices[0],
580
- {
581
- release: options.release,
582
- port: options.port,
583
- variant: options.variant,
584
- clean: options.clean,
585
- },
586
- metroProcess
587
- );
588
- return;
589
- }
590
- }
721
+ const handled = await handleNoDevicesWithAVDs(options, metroProcess);
722
+ if (handled) {
723
+ return;
591
724
  }
592
725
 
593
726
  console.log(chalk.gray("\nTo create an emulator, run: Android Studio > Tools > Device Manager"));
594
727
  process.exit(1);
595
728
  }
596
729
 
597
- let selectedDevice: AndroidDevice;
598
-
599
- if (options.device) {
600
- const found = devices.find(
601
- (d) => d.name.toLowerCase() === options.device?.toLowerCase() || d.id === options.device
602
- );
603
- if (!found) {
604
- console.error(chalk.red(`Device not found: ${options.device}`));
605
- console.log(chalk.gray("\nAvailable devices:"));
606
- for (const d of devices) {
607
- console.log(chalk.gray(` - ${d.name} (${d.type})`));
608
- }
609
- process.exit(1);
610
- }
611
- selectedDevice = found;
612
- } else if (!options.picker || devices.length === 1) {
613
- selectedDevice = devices[0];
614
- } else {
615
- selectedDevice = await pickAndroidDevice(devices);
616
- }
730
+ const selectedDevice = await selectAndroidDevice(devices, options);
617
731
 
618
732
  await launchAndroidApp(
619
733
  selectedDevice,
@@ -642,50 +756,12 @@ async function launchAndroidApp(
642
756
  console.log(chalk.blue(`\nSelected device: ${device.name} (${device.type})`));
643
757
  console.log(chalk.blue("\nBuilding Android app...\n"));
644
758
 
645
- // Handle clean build
646
- if (options.clean) {
647
- console.log(chalk.blue("Cleaning Android build..."));
648
- try {
649
- await execAsync("./gradlew clean", { cwd: join(process.cwd(), "android") });
650
- } catch {
651
- // Ignore clean errors
652
- }
653
- }
654
-
655
- const args = ["react-native", "run-android", "--port", options.port, "--no-packager"];
656
-
657
- if (device.type === "device") {
658
- args.push("--deviceId", device.id);
659
- }
660
-
661
- // Handle variant or release mode
662
- if (options.variant) {
663
- args.push("--variant", options.variant);
664
- } else if (options.release) {
665
- args.push("--mode", "release");
666
- }
759
+ await cleanBuildIfNeeded("android", options.clean);
667
760
 
761
+ const args = buildAndroidArgs(device, options);
668
762
  await runCommand("npx", args);
669
763
 
670
- // If Metro is running, launch app via deep link to connect to bundler
671
- if (metroProcess && !options.release) {
672
- const scheme = getAppScheme(process.cwd());
673
- if (scheme) {
674
- // Android emulator uses 10.0.2.2 to reach host machine
675
- const bundlerUrl =
676
- device.type === "emulator" ? `http://10.0.2.2:${options.port}` : `http://localhost:${options.port}`;
677
- console.log(chalk.blue("\nLaunching app with bundler connection..."));
678
- try {
679
- await launchAppWithBundler("android", scheme, bundlerUrl, device.id);
680
- console.log(chalk.green(`✓ App launched with bundler: ${bundlerUrl}`));
681
- } catch (error) {
682
- // Deep link launch failed, but app should still work
683
- console.log(
684
- chalk.yellow(`Note: Deep link launch skipped - ${error instanceof Error ? error.message : "unknown error"}`)
685
- );
686
- }
687
- }
688
- }
764
+ await launchWithBundlerIfNeeded("android", metroProcess, options, device.id, device.type);
689
765
  }
690
766
 
691
767
  /**