@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 +2 -2
- package/src/cli/commands/run.ts +139 -51
- package/src/utils/bundler.ts +263 -0
- package/src/utils/deep-link.ts +248 -0
- package/src/utils/index.ts +24 -0
- package/src/utils/terminal-reporter.ts +299 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@teardown/cli",
|
|
3
|
-
"version": "2.0.
|
|
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.
|
|
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"
|
package/src/cli/commands/run.ts
CHANGED
|
@@ -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
|
-
//
|
|
348
|
-
if (
|
|
349
|
-
|
|
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
|
-
//
|
|
410
|
-
console.log(chalk.blue("\
|
|
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(
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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(
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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("\
|
|
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
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -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
|
+
}
|