@teardown/cli 2.0.71 → 2.0.72

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.71",
3
+ "version": "2.0.72",
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.71",
79
+ "@teardown/tsconfig": "2.0.72",
80
80
  "@types/bun": "1.3.5",
81
81
  "@types/ejs": "^3.1.5",
82
82
  "typescript": "5.9.3"
@@ -1,8 +1,16 @@
1
1
  /**
2
2
  * Run command - runs the app on iOS or Android devices/simulators
3
+ *
4
+ * Flow:
5
+ * 1. Ensure native project exists (prebuild if needed)
6
+ * 2. Start Metro bundler in background
7
+ * 3. Wait for bundler to be ready
8
+ * 4. Build and install the app
9
+ * 5. Launch app via deep link with bundler URL
10
+ * 6. Attach Metro to foreground
3
11
  */
4
12
 
5
- import { exec, spawn } from "node:child_process";
13
+ import { type ChildProcess, exec, spawn } from "node:child_process";
6
14
  import { existsSync } from "node:fs";
7
15
  import { join } from "node:path";
8
16
  import { createInterface } from "node:readline";
@@ -10,6 +18,8 @@ import { promisify } from "node:util";
10
18
  import chalk from "chalk";
11
19
  import { Command } from "commander";
12
20
  import ora from "ora";
21
+ import { attachBundlerToForeground, startBundlerBackground, waitForBundlerReady } from "../../utils/bundler";
22
+ import { getAppScheme, launchAppWithBundler } from "../../utils/deep-link";
13
23
 
14
24
  const execAsync = promisify(exec);
15
25
 
@@ -266,33 +276,6 @@ async function ensureNativeProject(platform: "ios" | "android"): Promise<boolean
266
276
  return true;
267
277
  }
268
278
 
269
- /**
270
- * Start Metro bundler in foreground (runs after build completes)
271
- */
272
- function startBundlerInForeground(port: string): void {
273
- console.log(chalk.blue(`\nStarting Metro bundler on port ${port} with --reset-cache...\n`));
274
-
275
- const proc = spawn("npx", ["react-native", "start", "--port", port, "--reset-cache"], {
276
- stdio: "inherit",
277
- shell: true,
278
- cwd: process.cwd(),
279
- env: {
280
- ...process.env,
281
- LANG: "en_US.UTF-8",
282
- },
283
- });
284
-
285
- proc.on("close", (code) => {
286
- if (code !== 0) {
287
- console.error(chalk.red(`Metro bundler exited with code ${code}`));
288
- }
289
- });
290
-
291
- proc.on("error", (err) => {
292
- console.error(chalk.red(`Failed to start Metro bundler: ${err.message}`));
293
- });
294
- }
295
-
296
279
  interface RunOptions {
297
280
  device?: string;
298
281
  picker: boolean;
@@ -331,6 +314,19 @@ export function createRunCommand(): Command {
331
314
  process.exit(1);
332
315
  }
333
316
 
317
+ // Track Metro process for cleanup
318
+ let metroProcess: ChildProcess | null = null;
319
+
320
+ // Handle SIGINT (Ctrl+C) to clean up Metro
321
+ const cleanup = () => {
322
+ if (metroProcess) {
323
+ metroProcess.kill("SIGTERM");
324
+ }
325
+ process.exit(0);
326
+ };
327
+ process.on("SIGINT", cleanup);
328
+ process.on("SIGTERM", cleanup);
329
+
334
330
  try {
335
331
  // Ensure native project exists (auto-prebuild if needed)
336
332
  const hasProject = await ensureNativeProject(platform);
@@ -338,17 +334,29 @@ export function createRunCommand(): Command {
338
334
  process.exit(1);
339
335
  }
340
336
 
337
+ // Start Metro bundler FIRST (unless --no-bundler or --release)
338
+ if (options.bundler && !options.release) {
339
+ metroProcess = await startMetroAndWait(options.port, platform);
340
+ }
341
+
342
+ // Run platform-specific build and launch
341
343
  if (platform === "ios") {
342
- await runIOS(options);
344
+ await runIOS(options, metroProcess);
343
345
  } else {
344
- await runAndroid(options);
346
+ await runAndroid(options, metroProcess);
345
347
  }
346
348
 
347
- // Start bundler after build completes (unless --no-bundler or --release)
348
- if (options.bundler && !options.release) {
349
- startBundlerInForeground(options.port);
349
+ // If Metro is running, attach to foreground
350
+ if (metroProcess) {
351
+ console.log(chalk.blue("\n─".repeat(50)));
352
+ console.log(chalk.blue("Metro bundler running. Press Ctrl+C to stop.\n"));
353
+ attachBundlerToForeground(metroProcess);
350
354
  }
351
355
  } catch (error) {
356
+ // Clean up Metro on error
357
+ if (metroProcess) {
358
+ metroProcess.kill("SIGTERM");
359
+ }
352
360
  console.error(chalk.red(`Failed to run app: ${error instanceof Error ? error.message : error}`));
353
361
  process.exit(1);
354
362
  }
@@ -357,10 +365,43 @@ export function createRunCommand(): Command {
357
365
  return run;
358
366
  }
359
367
 
368
+ /**
369
+ * Start Metro bundler in background and wait for it to be ready
370
+ */
371
+ async function startMetroAndWait(port: string, _platform: "ios" | "android"): Promise<ChildProcess> {
372
+ // For local development, always use localhost for the bundler check
373
+ const checkUrl = `http://localhost:${port}`;
374
+
375
+ console.log(chalk.blue(`\nStarting Metro bundler on port ${port}...`));
376
+
377
+ const metroProcess = startBundlerBackground({
378
+ port,
379
+ resetCache: true,
380
+ cwd: process.cwd(),
381
+ });
382
+
383
+ // Wait for bundler to be ready with progress updates
384
+ const spinner = ora("Waiting for Metro bundler to start...").start();
385
+
386
+ const result = await waitForBundlerReady(checkUrl, 60000, (_attempt, elapsed) => {
387
+ const seconds = Math.floor(elapsed / 1000);
388
+ spinner.text = `Waiting for Metro bundler... (${seconds}s)`;
389
+ });
390
+
391
+ if (!result.ready) {
392
+ spinner.fail("Metro bundler failed to start");
393
+ metroProcess.kill("SIGTERM");
394
+ throw new Error(result.error || "Bundler timeout");
395
+ }
396
+
397
+ spinner.succeed(`Metro bundler ready at ${checkUrl}`);
398
+ return metroProcess;
399
+ }
400
+
360
401
  /**
361
402
  * Run on iOS
362
403
  */
363
- async function runIOS(options: RunOptions): Promise<void> {
404
+ async function runIOS(options: RunOptions, metroProcess: ChildProcess | null): Promise<void> {
364
405
  const spinner = ora("Detecting iOS devices...").start();
365
406
 
366
407
  try {
@@ -406,8 +447,8 @@ async function runIOS(options: RunOptions): Promise<void> {
406
447
  bootSpinner.succeed(`${selectedDevice.name} is now running`);
407
448
  }
408
449
 
409
- // Run the app
410
- console.log(chalk.blue("\nStarting app on iOS simulator...\n"));
450
+ // Build the app
451
+ console.log(chalk.blue("\nBuilding iOS app...\n"));
411
452
 
412
453
  const args = [
413
454
  "react-native",
@@ -439,6 +480,24 @@ async function runIOS(options: RunOptions): Promise<void> {
439
480
  }
440
481
 
441
482
  await runCommand("npx", args);
483
+
484
+ // If Metro is running, launch app via deep link to connect to bundler
485
+ if (metroProcess && !options.release) {
486
+ const scheme = getAppScheme(process.cwd());
487
+ if (scheme) {
488
+ const bundlerUrl = `http://localhost:${options.port}`;
489
+ console.log(chalk.blue("\nLaunching app with bundler connection..."));
490
+ try {
491
+ await launchAppWithBundler("ios", scheme, bundlerUrl, selectedDevice.udid);
492
+ console.log(chalk.green(`✓ App launched with bundler: ${bundlerUrl}`));
493
+ } catch (error) {
494
+ // Deep link launch failed, but app should still work
495
+ console.log(
496
+ chalk.yellow(`Note: Deep link launch skipped - ${error instanceof Error ? error.message : "unknown error"}`)
497
+ );
498
+ }
499
+ }
500
+ }
442
501
  } catch (error) {
443
502
  spinner.fail("Failed to detect iOS devices");
444
503
  throw error;
@@ -448,7 +507,7 @@ async function runIOS(options: RunOptions): Promise<void> {
448
507
  /**
449
508
  * Run on Android
450
509
  */
451
- async function runAndroid(options: RunOptions): Promise<void> {
510
+ async function runAndroid(options: RunOptions, metroProcess: ChildProcess | null): Promise<void> {
452
511
  const spinner = ora("Detecting Android devices...").start();
453
512
 
454
513
  try {
@@ -478,12 +537,16 @@ async function runAndroid(options: RunOptions): Promise<void> {
478
537
  // Re-fetch devices
479
538
  const newDevices = await getAndroidDevices();
480
539
  if (newDevices.length > 0) {
481
- await launchAndroidApp(newDevices[0], {
482
- release: options.release,
483
- port: options.port,
484
- variant: options.variant,
485
- clean: options.clean,
486
- });
540
+ await launchAndroidApp(
541
+ newDevices[0],
542
+ {
543
+ release: options.release,
544
+ port: options.port,
545
+ variant: options.variant,
546
+ clean: options.clean,
547
+ },
548
+ metroProcess
549
+ );
487
550
  return;
488
551
  }
489
552
  }
@@ -514,12 +577,16 @@ async function runAndroid(options: RunOptions): Promise<void> {
514
577
  selectedDevice = await pickAndroidDevice(devices);
515
578
  }
516
579
 
517
- await launchAndroidApp(selectedDevice, {
518
- release: options.release,
519
- port: options.port,
520
- variant: options.variant,
521
- clean: options.clean,
522
- });
580
+ await launchAndroidApp(
581
+ selectedDevice,
582
+ {
583
+ release: options.release,
584
+ port: options.port,
585
+ variant: options.variant,
586
+ clean: options.clean,
587
+ },
588
+ metroProcess
589
+ );
523
590
  } catch (error) {
524
591
  spinner.fail("Failed to detect Android devices");
525
592
  throw error;
@@ -531,10 +598,11 @@ async function runAndroid(options: RunOptions): Promise<void> {
531
598
  */
532
599
  async function launchAndroidApp(
533
600
  device: AndroidDevice,
534
- options: { release?: boolean; port: string; variant?: string; clean?: boolean }
601
+ options: { release?: boolean; port: string; variant?: string; clean?: boolean },
602
+ metroProcess: ChildProcess | null
535
603
  ): Promise<void> {
536
604
  console.log(chalk.blue(`\nSelected device: ${device.name} (${device.type})`));
537
- console.log(chalk.blue("\nStarting app on Android...\n"));
605
+ console.log(chalk.blue("\nBuilding Android app...\n"));
538
606
 
539
607
  // Handle clean build
540
608
  if (options.clean) {
@@ -560,6 +628,26 @@ async function launchAndroidApp(
560
628
  }
561
629
 
562
630
  await runCommand("npx", args);
631
+
632
+ // If Metro is running, launch app via deep link to connect to bundler
633
+ if (metroProcess && !options.release) {
634
+ const scheme = getAppScheme(process.cwd());
635
+ if (scheme) {
636
+ // Android emulator uses 10.0.2.2 to reach host machine
637
+ const bundlerUrl =
638
+ device.type === "emulator" ? `http://10.0.2.2:${options.port}` : `http://localhost:${options.port}`;
639
+ console.log(chalk.blue("\nLaunching app with bundler connection..."));
640
+ try {
641
+ await launchAppWithBundler("android", scheme, bundlerUrl, device.id);
642
+ console.log(chalk.green(`✓ App launched with bundler: ${bundlerUrl}`));
643
+ } catch (error) {
644
+ // Deep link launch failed, but app should still work
645
+ console.log(
646
+ chalk.yellow(`Note: Deep link launch skipped - ${error instanceof Error ? error.message : "unknown error"}`)
647
+ );
648
+ }
649
+ }
650
+ }
563
651
  }
564
652
 
565
653
  /**
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Metro Bundler Utilities
3
+ *
4
+ * Provides functions for starting Metro bundler in background
5
+ * and checking when it's ready to serve bundles.
6
+ */
7
+
8
+ import { type ChildProcess, spawn } from "node:child_process";
9
+ import chalk from "chalk";
10
+
11
+ /**
12
+ * Default Metro bundler port
13
+ */
14
+ export const DEFAULT_BUNDLER_PORT = 8081;
15
+
16
+ /**
17
+ * Default timeout for waiting for bundler to be ready (60 seconds)
18
+ */
19
+ export const DEFAULT_BUNDLER_TIMEOUT = 60000;
20
+
21
+ /**
22
+ * Polling interval for checking bundler status (1 second)
23
+ */
24
+ const POLL_INTERVAL = 1000;
25
+
26
+ /**
27
+ * Result of bundler ready check
28
+ */
29
+ export interface BundlerReadyResult {
30
+ /** Whether bundler is ready */
31
+ ready: boolean;
32
+ /** Bundler URL if ready */
33
+ url?: string;
34
+ /** Error message if not ready */
35
+ error?: string;
36
+ }
37
+
38
+ /**
39
+ * Options for starting bundler in background
40
+ */
41
+ export interface StartBundlerOptions {
42
+ /** Port to run Metro on */
43
+ port?: string | number;
44
+ /** Whether to reset Metro cache */
45
+ resetCache?: boolean;
46
+ /** Project root directory */
47
+ cwd?: string;
48
+ /** Whether to enable verbose logging */
49
+ verbose?: boolean;
50
+ }
51
+
52
+ /**
53
+ * Start Metro bundler in background
54
+ *
55
+ * Returns the child process so it can be managed (attached to foreground, killed, etc.)
56
+ *
57
+ * @param options - Bundler options
58
+ * @returns Child process running Metro
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * const metro = startBundlerBackground({ port: 8081 });
63
+ *
64
+ * // Wait for bundler to be ready
65
+ * await waitForBundlerReady('http://localhost:8081');
66
+ *
67
+ * // Later, attach to foreground
68
+ * metro.stdout?.pipe(process.stdout);
69
+ * metro.stderr?.pipe(process.stderr);
70
+ * ```
71
+ */
72
+ export function startBundlerBackground(options: StartBundlerOptions = {}): ChildProcess {
73
+ const { port = DEFAULT_BUNDLER_PORT, resetCache = false, cwd = process.cwd(), verbose = false } = options;
74
+
75
+ const args = ["react-native", "start", "--port", String(port)];
76
+
77
+ if (resetCache) {
78
+ args.push("--reset-cache");
79
+ }
80
+
81
+ if (verbose) {
82
+ args.push("--verbose");
83
+ }
84
+
85
+ const proc = spawn("npx", args, {
86
+ cwd,
87
+ shell: true,
88
+ stdio: ["ignore", "pipe", "pipe"],
89
+ detached: false,
90
+ env: {
91
+ ...process.env,
92
+ LANG: "en_US.UTF-8",
93
+ // Force colors in Metro output
94
+ FORCE_COLOR: "1",
95
+ },
96
+ });
97
+
98
+ // Handle process errors
99
+ proc.on("error", (err) => {
100
+ console.error(chalk.red(`Failed to start Metro bundler: ${err.message}`));
101
+ });
102
+
103
+ return proc;
104
+ }
105
+
106
+ /**
107
+ * Check if Metro bundler is running and ready
108
+ *
109
+ * @param url - Bundler URL (e.g., http://localhost:8081)
110
+ * @returns Whether bundler is ready
111
+ */
112
+ export async function isBundlerReady(url: string): Promise<boolean> {
113
+ try {
114
+ const statusUrl = `${url}/status`;
115
+ const controller = new AbortController();
116
+ const timeout = setTimeout(() => controller.abort(), 5000);
117
+
118
+ const response = await fetch(statusUrl, {
119
+ method: "GET",
120
+ signal: controller.signal,
121
+ });
122
+
123
+ clearTimeout(timeout);
124
+
125
+ if (response.ok) {
126
+ const text = await response.text();
127
+ // Metro returns "packager-status:running" when ready
128
+ return text.includes("packager-status:running") || response.status === 200;
129
+ }
130
+
131
+ return false;
132
+ } catch {
133
+ return false;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Wait for Metro bundler to be ready
139
+ *
140
+ * Polls the bundler status endpoint until it responds successfully
141
+ * or the timeout is reached.
142
+ *
143
+ * @param url - Bundler URL (e.g., http://localhost:8081)
144
+ * @param timeoutMs - Maximum time to wait in milliseconds
145
+ * @param onProgress - Optional callback for progress updates
146
+ * @returns Result indicating if bundler is ready
147
+ *
148
+ * @example
149
+ * ```ts
150
+ * const result = await waitForBundlerReady('http://localhost:8081', 60000, (attempt) => {
151
+ * console.log(`Attempt ${attempt}...`);
152
+ * });
153
+ *
154
+ * if (result.ready) {
155
+ * console.log('Bundler ready at', result.url);
156
+ * } else {
157
+ * console.error('Failed:', result.error);
158
+ * }
159
+ * ```
160
+ */
161
+ export async function waitForBundlerReady(
162
+ url: string,
163
+ timeoutMs: number = DEFAULT_BUNDLER_TIMEOUT,
164
+ onProgress?: (attempt: number, elapsed: number) => void
165
+ ): Promise<BundlerReadyResult> {
166
+ const startTime = Date.now();
167
+ let attempt = 0;
168
+
169
+ while (Date.now() - startTime < timeoutMs) {
170
+ attempt++;
171
+ const elapsed = Date.now() - startTime;
172
+
173
+ if (onProgress) {
174
+ onProgress(attempt, elapsed);
175
+ }
176
+
177
+ const ready = await isBundlerReady(url);
178
+ if (ready) {
179
+ return {
180
+ ready: true,
181
+ url,
182
+ };
183
+ }
184
+
185
+ // Wait before next attempt
186
+ await sleep(POLL_INTERVAL);
187
+ }
188
+
189
+ return {
190
+ ready: false,
191
+ error: `Bundler did not become ready within ${timeoutMs / 1000} seconds`,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Get bundler URL for a given port and platform
197
+ *
198
+ * @param port - Metro port
199
+ * @param platform - Target platform
200
+ * @returns Bundler URL
201
+ */
202
+ export function getBundlerUrl(port: string | number, platform: "ios" | "android" = "ios"): string {
203
+ // Android emulator needs 10.0.2.2 to reach host machine
204
+ const host = platform === "android" ? "10.0.2.2" : "localhost";
205
+ return `http://${host}:${port}`;
206
+ }
207
+
208
+ /**
209
+ * Kill a bundler process gracefully
210
+ *
211
+ * @param proc - Child process to kill
212
+ * @param timeoutMs - Time to wait before force killing
213
+ */
214
+ export async function killBundler(proc: ChildProcess, timeoutMs = 5000): Promise<void> {
215
+ return new Promise((resolve) => {
216
+ if (!proc.pid) {
217
+ resolve();
218
+ return;
219
+ }
220
+
221
+ let killed = false;
222
+
223
+ proc.on("exit", () => {
224
+ killed = true;
225
+ resolve();
226
+ });
227
+
228
+ // Try graceful shutdown first
229
+ proc.kill("SIGTERM");
230
+
231
+ // Force kill after timeout
232
+ setTimeout(() => {
233
+ if (!killed) {
234
+ proc.kill("SIGKILL");
235
+ resolve();
236
+ }
237
+ }, timeoutMs);
238
+ });
239
+ }
240
+
241
+ /**
242
+ * Attach bundler process to foreground (pipe stdout/stderr)
243
+ *
244
+ * @param proc - Metro process
245
+ */
246
+ export function attachBundlerToForeground(proc: ChildProcess): void {
247
+ proc.stdout?.pipe(process.stdout);
248
+ proc.stderr?.pipe(process.stderr);
249
+
250
+ // Handle process exit
251
+ proc.on("close", (code) => {
252
+ if (code !== 0 && code !== null) {
253
+ console.error(chalk.red(`Metro bundler exited with code ${code}`));
254
+ }
255
+ });
256
+ }
257
+
258
+ /**
259
+ * Sleep for a given duration
260
+ */
261
+ function sleep(ms: number): Promise<void> {
262
+ return new Promise((resolve) => setTimeout(resolve, ms));
263
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Deep Link Utilities
3
+ *
4
+ * Provides functions for reading app scheme from config,
5
+ * building deep link URLs, and launching apps via deep links.
6
+ */
7
+
8
+ import { exec } from "node:child_process";
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { resolve } from "node:path";
11
+ import { promisify } from "node:util";
12
+ import chalk from "chalk";
13
+
14
+ const execAsync = promisify(exec);
15
+
16
+ /**
17
+ * Config file names to search for (in order of priority)
18
+ */
19
+ const CONFIG_FILE_NAMES = [
20
+ "teardown.config.ts",
21
+ "teardown.config.js",
22
+ "teardown.config.mjs",
23
+ "app.config.ts",
24
+ "app.config.js",
25
+ ];
26
+
27
+ /**
28
+ * Deep link parameters for dev mode
29
+ */
30
+ export interface DevDeepLinkParams {
31
+ /** Metro bundler URL */
32
+ bundler: string;
33
+ }
34
+
35
+ /**
36
+ * Read the app scheme from the Teardown/Expo config file
37
+ *
38
+ * @param projectRoot - Project root directory
39
+ * @returns App scheme or null if not found
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * const scheme = getAppScheme('/path/to/project');
44
+ * console.log(scheme); // 'myapp'
45
+ * ```
46
+ */
47
+ export function getAppScheme(projectRoot: string): string | null {
48
+ for (const fileName of CONFIG_FILE_NAMES) {
49
+ const configPath = resolve(projectRoot, fileName);
50
+ if (existsSync(configPath)) {
51
+ try {
52
+ const content = readFileSync(configPath, "utf-8");
53
+ // Extract scheme using regex - handles both single and double quotes
54
+ const schemeMatch = content.match(/scheme:\s*['"]([^'"]+)['"]/);
55
+ if (schemeMatch) {
56
+ return schemeMatch[1];
57
+ }
58
+ } catch {
59
+ // Ignore read errors and try next config file
60
+ }
61
+ }
62
+ }
63
+
64
+ // Try to read from slug as fallback (slug is used as default scheme)
65
+ for (const fileName of CONFIG_FILE_NAMES) {
66
+ const configPath = resolve(projectRoot, fileName);
67
+ if (existsSync(configPath)) {
68
+ try {
69
+ const content = readFileSync(configPath, "utf-8");
70
+ const slugMatch = content.match(/slug:\s*['"]([^'"]+)['"]/);
71
+ if (slugMatch) {
72
+ return slugMatch[1];
73
+ }
74
+ } catch {
75
+ // Ignore read errors
76
+ }
77
+ }
78
+ }
79
+
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * Build a development deep link URL with bundler information
85
+ *
86
+ * @param scheme - App scheme (e.g., 'myapp')
87
+ * @param bundlerUrl - Metro bundler URL (e.g., 'http://localhost:8081')
88
+ * @returns Deep link URL
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * const link = buildDevDeepLink('myapp', 'http://localhost:8081');
93
+ * // Returns: 'myapp://dev?bundler=http%3A%2F%2Flocalhost%3A8081'
94
+ * ```
95
+ */
96
+ export function buildDevDeepLink(scheme: string, bundlerUrl: string): string {
97
+ const encodedUrl = encodeURIComponent(bundlerUrl);
98
+ return `${scheme}://dev?bundler=${encodedUrl}`;
99
+ }
100
+
101
+ /**
102
+ * Parse bundler URL from a deep link
103
+ *
104
+ * @param url - Deep link URL
105
+ * @returns Bundler URL or null if not found
106
+ *
107
+ * @example
108
+ * ```ts
109
+ * const bundlerUrl = parseBundlerFromDeepLink('myapp://dev?bundler=http%3A%2F%2Flocalhost%3A8081');
110
+ * // Returns: 'http://localhost:8081'
111
+ * ```
112
+ */
113
+ export function parseBundlerFromDeepLink(url: string): string | null {
114
+ try {
115
+ // Handle custom scheme URLs by replacing scheme with http for URL parsing
116
+ const normalizedUrl = url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "http://");
117
+ const parsed = new URL(normalizedUrl);
118
+ const bundler = parsed.searchParams.get("bundler");
119
+ return bundler ? decodeURIComponent(bundler) : null;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Open a deep link URL on iOS Simulator
127
+ *
128
+ * @param udid - Simulator UDID
129
+ * @param url - Deep link URL to open
130
+ *
131
+ * @example
132
+ * ```ts
133
+ * await openDeepLinkIOS('XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX', 'myapp://dev?bundler=...');
134
+ * ```
135
+ */
136
+ export async function openDeepLinkIOS(udid: string, url: string): Promise<void> {
137
+ // Escape URL for shell
138
+ const escapedUrl = url.replace(/"/g, '\\"');
139
+ const command = `xcrun simctl openurl ${udid} "${escapedUrl}"`;
140
+
141
+ try {
142
+ await execAsync(command);
143
+ } catch (error) {
144
+ const message = error instanceof Error ? error.message : String(error);
145
+ throw new Error(`Failed to open deep link on iOS simulator: ${message}`);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Open a deep link URL on Android device/emulator
151
+ *
152
+ * @param deviceId - Android device ID (optional, uses default if not provided)
153
+ * @param url - Deep link URL to open
154
+ *
155
+ * @example
156
+ * ```ts
157
+ * await openDeepLinkAndroid('emulator-5554', 'myapp://dev?bundler=...');
158
+ * ```
159
+ */
160
+ export async function openDeepLinkAndroid(deviceId: string | undefined, url: string): Promise<void> {
161
+ // Escape URL for shell
162
+ const escapedUrl = url.replace(/"/g, '\\"');
163
+
164
+ // Build adb command
165
+ let command = "adb";
166
+ if (deviceId) {
167
+ command += ` -s ${deviceId}`;
168
+ }
169
+ command += ` shell am start -a android.intent.action.VIEW -d "${escapedUrl}"`;
170
+
171
+ try {
172
+ await execAsync(command);
173
+ } catch (error) {
174
+ const message = error instanceof Error ? error.message : String(error);
175
+ throw new Error(`Failed to open deep link on Android: ${message}`);
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Launch app via deep link with bundler URL
181
+ *
182
+ * This is a convenience function that builds the deep link and opens it
183
+ * on the appropriate platform.
184
+ *
185
+ * @param platform - Target platform
186
+ * @param scheme - App scheme
187
+ * @param bundlerUrl - Metro bundler URL
188
+ * @param deviceId - Device identifier (UDID for iOS, device ID for Android)
189
+ *
190
+ * @example
191
+ * ```ts
192
+ * await launchAppWithBundler('ios', 'myapp', 'http://localhost:8081', 'XXXX-UDID-XXXX');
193
+ * ```
194
+ */
195
+ export async function launchAppWithBundler(
196
+ platform: "ios" | "android",
197
+ scheme: string,
198
+ bundlerUrl: string,
199
+ deviceId: string
200
+ ): Promise<void> {
201
+ const deepLink = buildDevDeepLink(scheme, bundlerUrl);
202
+
203
+ console.log(chalk.gray(` Opening: ${deepLink}`));
204
+
205
+ if (platform === "ios") {
206
+ await openDeepLinkIOS(deviceId, deepLink);
207
+ } else {
208
+ await openDeepLinkAndroid(deviceId, deepLink);
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Check if an app with the given scheme is installed on iOS Simulator
214
+ *
215
+ * @param udid - Simulator UDID
216
+ * @param bundleId - App bundle identifier
217
+ * @returns Whether the app is installed
218
+ */
219
+ export async function isAppInstalledIOS(udid: string, bundleId: string): Promise<boolean> {
220
+ try {
221
+ const { stdout } = await execAsync(`xcrun simctl get_app_container ${udid} ${bundleId}`);
222
+ return stdout.trim().length > 0;
223
+ } catch {
224
+ return false;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Check if an app with the given package is installed on Android
230
+ *
231
+ * @param deviceId - Android device ID
232
+ * @param packageName - App package name
233
+ * @returns Whether the app is installed
234
+ */
235
+ export async function isAppInstalledAndroid(deviceId: string | undefined, packageName: string): Promise<boolean> {
236
+ try {
237
+ let command = "adb";
238
+ if (deviceId) {
239
+ command += ` -s ${deviceId}`;
240
+ }
241
+ command += ` shell pm list packages ${packageName}`;
242
+
243
+ const { stdout } = await execAsync(command);
244
+ return stdout.includes(`package:${packageName}`);
245
+ } catch {
246
+ return false;
247
+ }
248
+ }
@@ -2,6 +2,28 @@
2
2
  * Utility functions for Teardown Launchpad
3
3
  */
4
4
 
5
+ export type { BundlerReadyResult, StartBundlerOptions } from "./bundler";
6
+ export {
7
+ attachBundlerToForeground,
8
+ DEFAULT_BUNDLER_PORT,
9
+ DEFAULT_BUNDLER_TIMEOUT,
10
+ getBundlerUrl,
11
+ isBundlerReady,
12
+ killBundler,
13
+ startBundlerBackground,
14
+ waitForBundlerReady,
15
+ } from "./bundler";
16
+ export type { DevDeepLinkParams } from "./deep-link";
17
+ export {
18
+ buildDevDeepLink,
19
+ getAppScheme,
20
+ isAppInstalledAndroid,
21
+ isAppInstalledIOS,
22
+ launchAppWithBundler,
23
+ openDeepLinkAndroid,
24
+ openDeepLinkIOS,
25
+ parseBundlerFromDeepLink,
26
+ } from "./deep-link";
5
27
  export type { VirtualFileSystem } from "./fs";
6
28
  export {
7
29
  copyDirectory,
@@ -19,3 +41,5 @@ export {
19
41
  formatKeyValue,
20
42
  formatList,
21
43
  } from "./logger";
44
+ export type { ReporterStep, StepStatus, TerminalReporterConfig } from "./terminal-reporter";
45
+ export { createReporter, TerminalReporter } from "./terminal-reporter";
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Terminal Reporter
3
+ *
4
+ * Unified step-by-step progress reporter for CLI commands.
5
+ * Provides a consistent UX for multi-step operations like build and run.
6
+ */
7
+
8
+ import chalk from "chalk";
9
+ import ora, { type Ora } from "ora";
10
+
11
+ /**
12
+ * Status of a reporter step
13
+ */
14
+ export type StepStatus = "pending" | "running" | "success" | "error" | "skipped";
15
+
16
+ /**
17
+ * A step in the reporter
18
+ */
19
+ export interface ReporterStep {
20
+ /** Unique step identifier */
21
+ id: string;
22
+ /** Display label for the step */
23
+ label: string;
24
+ /** Current status */
25
+ status: StepStatus;
26
+ /** Optional detail message */
27
+ detail?: string;
28
+ /** Error message if failed */
29
+ error?: string;
30
+ /** Start time (for duration tracking) */
31
+ startTime?: number;
32
+ /** End time */
33
+ endTime?: number;
34
+ }
35
+
36
+ /**
37
+ * Terminal reporter configuration
38
+ */
39
+ export interface TerminalReporterConfig {
40
+ /** Whether to show elapsed time for each step */
41
+ showDuration?: boolean;
42
+ /** Whether to use colors */
43
+ colors?: boolean;
44
+ /** Whether to show step numbers */
45
+ showNumbers?: boolean;
46
+ }
47
+
48
+ /**
49
+ * Status icons for different step states
50
+ */
51
+ const STATUS_ICONS: Record<StepStatus, string> = {
52
+ pending: chalk.gray("○"),
53
+ running: chalk.blue("◐"),
54
+ success: chalk.green("✓"),
55
+ error: chalk.red("✗"),
56
+ skipped: chalk.yellow("⊘"),
57
+ };
58
+
59
+ /**
60
+ * Format duration in human-readable format
61
+ */
62
+ function formatDuration(ms: number): string {
63
+ if (ms < 1000) {
64
+ return `${ms}ms`;
65
+ }
66
+ if (ms < 60000) {
67
+ return `${(ms / 1000).toFixed(1)}s`;
68
+ }
69
+ const minutes = Math.floor(ms / 60000);
70
+ const seconds = Math.floor((ms % 60000) / 1000);
71
+ return `${minutes}m ${seconds}s`;
72
+ }
73
+
74
+ /**
75
+ * Terminal Reporter class
76
+ *
77
+ * Manages multi-step operations with visual feedback.
78
+ *
79
+ * @example
80
+ * ```ts
81
+ * const reporter = new TerminalReporter();
82
+ *
83
+ * reporter.addStep('prebuild', 'Ensure native project');
84
+ * reporter.addStep('metro', 'Start Metro bundler');
85
+ * reporter.addStep('build', 'Build iOS app');
86
+ *
87
+ * reporter.startStep('prebuild');
88
+ * await ensureNativeProject('ios');
89
+ * reporter.completeStep('prebuild');
90
+ *
91
+ * reporter.startStep('metro');
92
+ * reporter.updateStepDetail('metro', 'Starting on port 8081...');
93
+ * await startMetro();
94
+ * reporter.completeStep('metro', 'http://localhost:8081');
95
+ * ```
96
+ */
97
+ export class TerminalReporter {
98
+ private steps: Map<string, ReporterStep> = new Map();
99
+ private stepOrder: string[] = [];
100
+ private spinner: Ora | null = null;
101
+ private config: Required<TerminalReporterConfig>;
102
+
103
+ constructor(config: TerminalReporterConfig = {}) {
104
+ this.config = {
105
+ showDuration: config.showDuration ?? true,
106
+ colors: config.colors ?? true,
107
+ showNumbers: config.showNumbers ?? true,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Add a new step to the reporter
113
+ */
114
+ addStep(id: string, label: string): void {
115
+ if (this.steps.has(id)) {
116
+ throw new Error(`Step with id "${id}" already exists`);
117
+ }
118
+
119
+ this.steps.set(id, {
120
+ id,
121
+ label,
122
+ status: "pending",
123
+ });
124
+ this.stepOrder.push(id);
125
+ }
126
+
127
+ /**
128
+ * Start a step (mark as running)
129
+ */
130
+ startStep(id: string): void {
131
+ const step = this.getStep(id);
132
+ step.status = "running";
133
+ step.startTime = Date.now();
134
+
135
+ // Stop any existing spinner
136
+ this.stopSpinner();
137
+
138
+ // Start new spinner
139
+ const stepNumber = this.config.showNumbers ? `[${this.getStepNumber(id)}/${this.stepOrder.length}] ` : "";
140
+ this.spinner = ora({
141
+ text: `${stepNumber}${step.label}`,
142
+ prefixText: "",
143
+ }).start();
144
+ }
145
+
146
+ /**
147
+ * Complete a step successfully
148
+ */
149
+ completeStep(id: string, detail?: string): void {
150
+ const step = this.getStep(id);
151
+ step.status = "success";
152
+ step.endTime = Date.now();
153
+ step.detail = detail;
154
+
155
+ this.stopSpinner();
156
+ this.printStepResult(step);
157
+ }
158
+
159
+ /**
160
+ * Mark a step as failed
161
+ */
162
+ failStep(id: string, error: string): void {
163
+ const step = this.getStep(id);
164
+ step.status = "error";
165
+ step.endTime = Date.now();
166
+ step.error = error;
167
+
168
+ this.stopSpinner();
169
+ this.printStepResult(step);
170
+ }
171
+
172
+ /**
173
+ * Skip a step
174
+ */
175
+ skipStep(id: string, reason?: string): void {
176
+ const step = this.getStep(id);
177
+ step.status = "skipped";
178
+ step.detail = reason;
179
+
180
+ this.stopSpinner();
181
+ this.printStepResult(step);
182
+ }
183
+
184
+ /**
185
+ * Update the detail message for a running step
186
+ */
187
+ updateStepDetail(id: string, detail: string): void {
188
+ const step = this.getStep(id);
189
+ step.detail = detail;
190
+
191
+ if (this.spinner && step.status === "running") {
192
+ const stepNumber = this.config.showNumbers ? `[${this.getStepNumber(id)}/${this.stepOrder.length}] ` : "";
193
+ this.spinner.text = `${stepNumber}${step.label} - ${chalk.gray(detail)}`;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Get current status of a step
199
+ */
200
+ getStepStatus(id: string): StepStatus {
201
+ return this.getStep(id).status;
202
+ }
203
+
204
+ /**
205
+ * Check if all steps completed successfully
206
+ */
207
+ allSucceeded(): boolean {
208
+ return Array.from(this.steps.values()).every((step) => step.status === "success" || step.status === "skipped");
209
+ }
210
+
211
+ /**
212
+ * Print a summary of all steps
213
+ */
214
+ printSummary(): void {
215
+ console.log();
216
+ console.log(chalk.bold("Summary:"));
217
+ console.log(chalk.gray("─".repeat(50)));
218
+
219
+ for (const id of this.stepOrder) {
220
+ const step = this.steps.get(id);
221
+ if (step) {
222
+ this.printStepResult(step, true);
223
+ }
224
+ }
225
+
226
+ console.log(chalk.gray("─".repeat(50)));
227
+
228
+ const succeeded = Array.from(this.steps.values()).filter((s) => s.status === "success").length;
229
+ const failed = Array.from(this.steps.values()).filter((s) => s.status === "error").length;
230
+ const skipped = Array.from(this.steps.values()).filter((s) => s.status === "skipped").length;
231
+
232
+ if (failed > 0) {
233
+ console.log(chalk.red(`✗ ${failed} failed`), chalk.gray(`| ${succeeded} succeeded | ${skipped} skipped`));
234
+ } else {
235
+ console.log(
236
+ chalk.green(`✓ All ${succeeded} steps completed`),
237
+ skipped > 0 ? chalk.gray(`(${skipped} skipped)`) : ""
238
+ );
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Clean up (stop spinner if running)
244
+ */
245
+ cleanup(): void {
246
+ this.stopSpinner();
247
+ }
248
+
249
+ private getStep(id: string): ReporterStep {
250
+ const step = this.steps.get(id);
251
+ if (!step) {
252
+ throw new Error(`Step with id "${id}" not found`);
253
+ }
254
+ return step;
255
+ }
256
+
257
+ private getStepNumber(id: string): number {
258
+ return this.stepOrder.indexOf(id) + 1;
259
+ }
260
+
261
+ private stopSpinner(): void {
262
+ if (this.spinner) {
263
+ this.spinner.stop();
264
+ this.spinner = null;
265
+ }
266
+ }
267
+
268
+ private printStepResult(step: ReporterStep, isSummary = false): void {
269
+ const icon = STATUS_ICONS[step.status];
270
+ const stepNumber = this.config.showNumbers ? `[${this.getStepNumber(step.id)}/${this.stepOrder.length}] ` : "";
271
+
272
+ let line = `${icon} ${stepNumber}${step.label}`;
273
+
274
+ // Add duration if available
275
+ if (this.config.showDuration && step.startTime && step.endTime) {
276
+ const duration = step.endTime - step.startTime;
277
+ line += chalk.gray(` (${formatDuration(duration)})`);
278
+ }
279
+
280
+ // Add detail if present
281
+ if (step.detail) {
282
+ line += chalk.gray(` - ${step.detail}`);
283
+ }
284
+
285
+ console.log(line);
286
+
287
+ // Print error on separate line if failed and not in summary
288
+ if (step.status === "error" && step.error && !isSummary) {
289
+ console.log(chalk.red(` └─ ${step.error}`));
290
+ }
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Create a simple reporter for quick use
296
+ */
297
+ export function createReporter(config?: TerminalReporterConfig): TerminalReporter {
298
+ return new TerminalReporter(config);
299
+ }