@ceraph/react-native-mcp 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +196 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +16 -0
- package/dist/error-parser.d.ts +71 -0
- package/dist/error-parser.js +345 -0
- package/dist/expo-manager.d.ts +134 -0
- package/dist/expo-manager.js +561 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +442 -0
- package/dist/init.d.ts +8 -0
- package/dist/init.js +235 -0
- package/dist/prebuild-detector.d.ts +49 -0
- package/dist/prebuild-detector.js +215 -0
- package/dist/screen.d.ts +95 -0
- package/dist/screen.js +357 -0
- package/package.json +42 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manages React Native / Expo child processes (build and Metro dev server).
|
|
3
|
+
* Auto-detects whether the project uses Expo or bare React Native
|
|
4
|
+
* and runs the appropriate commands.
|
|
5
|
+
* Captures stdout/stderr into rolling buffers and parses errors.
|
|
6
|
+
*/
|
|
7
|
+
import { type Warning, type AllErrors } from "./error-parser.js";
|
|
8
|
+
export interface BuildResult {
|
|
9
|
+
success: boolean;
|
|
10
|
+
errors: Array<{
|
|
11
|
+
file: string;
|
|
12
|
+
line: number;
|
|
13
|
+
message: string;
|
|
14
|
+
type: "build" | "link" | "pod";
|
|
15
|
+
}>;
|
|
16
|
+
warnings: Warning[];
|
|
17
|
+
output: string;
|
|
18
|
+
}
|
|
19
|
+
interface RunBuildOptions {
|
|
20
|
+
clean?: boolean;
|
|
21
|
+
device?: string;
|
|
22
|
+
}
|
|
23
|
+
interface StartMetroOptions {
|
|
24
|
+
port?: number;
|
|
25
|
+
clear?: boolean;
|
|
26
|
+
}
|
|
27
|
+
interface ConsoleOptions {
|
|
28
|
+
lines?: number;
|
|
29
|
+
level?: "all" | "error" | "warn" | "log";
|
|
30
|
+
}
|
|
31
|
+
export declare class RNManager {
|
|
32
|
+
private buildProcess;
|
|
33
|
+
private metroProcess;
|
|
34
|
+
private buildKilled;
|
|
35
|
+
private metroKilled;
|
|
36
|
+
private buildOutput;
|
|
37
|
+
private metroOutput;
|
|
38
|
+
private buildErrors;
|
|
39
|
+
private runtimeErrors;
|
|
40
|
+
private buildWarnings;
|
|
41
|
+
private metroWarnings;
|
|
42
|
+
private metroParser;
|
|
43
|
+
private readonly MAX_BUILD_LINES;
|
|
44
|
+
private readonly MAX_METRO_LINES;
|
|
45
|
+
private readonly MAX_ERRORS;
|
|
46
|
+
/** The working directory for commands. */
|
|
47
|
+
private cwd;
|
|
48
|
+
/** Path to the error file that triggers the Claude Code hook. */
|
|
49
|
+
private errorFilePath;
|
|
50
|
+
constructor(cwd: string);
|
|
51
|
+
/**
|
|
52
|
+
* Detect whether this is an Expo or bare React Native project.
|
|
53
|
+
* Checks for app.json with expo config or expo package in dependencies.
|
|
54
|
+
*/
|
|
55
|
+
detectProjectType(): Promise<boolean>;
|
|
56
|
+
/**
|
|
57
|
+
* Write errors to .rn-errors.json so the Claude Code hook can detect them.
|
|
58
|
+
* Overwrites the file each time — the hook fires on any write.
|
|
59
|
+
*/
|
|
60
|
+
private writeErrorFile;
|
|
61
|
+
/**
|
|
62
|
+
* Clear the error file (e.g., on successful build or process start).
|
|
63
|
+
*/
|
|
64
|
+
private clearErrorFile;
|
|
65
|
+
/**
|
|
66
|
+
* Append a line to a rolling buffer, evicting oldest entries when full.
|
|
67
|
+
*/
|
|
68
|
+
private pushLine;
|
|
69
|
+
/**
|
|
70
|
+
* Cap an error array at MAX_ERRORS, dropping oldest entries.
|
|
71
|
+
*/
|
|
72
|
+
private capErrors;
|
|
73
|
+
/**
|
|
74
|
+
* Kill a child process gracefully (SIGTERM, then SIGKILL after timeout).
|
|
75
|
+
*
|
|
76
|
+
* The promise resolves only when the process actually exits, not when
|
|
77
|
+
* SIGKILL is sent. Resolving on the SIGKILL timer was a race: callers
|
|
78
|
+
* (e.g. `runBuild`) would spawn a new build while the old Xcode process
|
|
79
|
+
* was still releasing DerivedData / build locks, causing cryptic
|
|
80
|
+
* SIGABRT or "file locked" errors on the new build.
|
|
81
|
+
*
|
|
82
|
+
* A hard upper-bound timer (2s after SIGKILL) guards against the
|
|
83
|
+
* pathological case where a process refuses to die at all, so callers
|
|
84
|
+
* never hang forever.
|
|
85
|
+
*/
|
|
86
|
+
private killProcess;
|
|
87
|
+
/**
|
|
88
|
+
* Run `npx expo prebuild --clean` synchronously (waits for exit).
|
|
89
|
+
*/
|
|
90
|
+
private runPrebuildClean;
|
|
91
|
+
/**
|
|
92
|
+
* Build and run the app on an iOS device or simulator.
|
|
93
|
+
* Auto-detects Expo vs bare React Native and uses the appropriate command:
|
|
94
|
+
* - Expo: `npx expo run:ios`
|
|
95
|
+
* - Bare RN: `npx react-native run-ios`
|
|
96
|
+
*
|
|
97
|
+
* Optionally runs `npx expo prebuild --clean` first (Expo only).
|
|
98
|
+
*/
|
|
99
|
+
runBuild(options?: RunBuildOptions): Promise<BuildResult>;
|
|
100
|
+
/**
|
|
101
|
+
* Start the Metro dev server.
|
|
102
|
+
* Auto-detects Expo vs bare React Native:
|
|
103
|
+
* - Expo: `npx expo start --dev-client`
|
|
104
|
+
* - Bare RN: `npx react-native start`
|
|
105
|
+
*
|
|
106
|
+
* Spawns Metro in the background and continuously captures output.
|
|
107
|
+
* Returns quickly once Metro shows signs of being ready (or after a timeout).
|
|
108
|
+
*/
|
|
109
|
+
startMetro(options?: StartMetroOptions): Promise<{
|
|
110
|
+
success: boolean;
|
|
111
|
+
message: string;
|
|
112
|
+
}>;
|
|
113
|
+
/**
|
|
114
|
+
* Return all captured errors from both build and runtime contexts.
|
|
115
|
+
*/
|
|
116
|
+
getErrors(): AllErrors;
|
|
117
|
+
/**
|
|
118
|
+
* Return recent console output from Metro, optionally filtered by level.
|
|
119
|
+
*/
|
|
120
|
+
getConsole(options?: ConsoleOptions): string[];
|
|
121
|
+
/**
|
|
122
|
+
* Stop all managed Expo processes.
|
|
123
|
+
*/
|
|
124
|
+
stopAll(): Promise<string[]>;
|
|
125
|
+
/**
|
|
126
|
+
* Get the raw build output buffer (for diagnostics).
|
|
127
|
+
*/
|
|
128
|
+
getBuildOutput(): string[];
|
|
129
|
+
/**
|
|
130
|
+
* Get the raw metro output buffer (for diagnostics).
|
|
131
|
+
*/
|
|
132
|
+
getMetroOutput(): string[];
|
|
133
|
+
}
|
|
134
|
+
export {};
|
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manages React Native / Expo child processes (build and Metro dev server).
|
|
3
|
+
* Auto-detects whether the project uses Expo or bare React Native
|
|
4
|
+
* and runs the appropriate commands.
|
|
5
|
+
* Captures stdout/stderr into rolling buffers and parses errors.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { writeFile, access } from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { parseBuildOutput, parseMetroOutput, MetroErrorParser, classifyLogLevel, } from "./error-parser.js";
|
|
11
|
+
export class RNManager {
|
|
12
|
+
buildProcess = null;
|
|
13
|
+
metroProcess = null;
|
|
14
|
+
buildKilled = false;
|
|
15
|
+
metroKilled = false;
|
|
16
|
+
buildOutput = [];
|
|
17
|
+
metroOutput = [];
|
|
18
|
+
buildErrors = [];
|
|
19
|
+
runtimeErrors = [];
|
|
20
|
+
buildWarnings = [];
|
|
21
|
+
metroWarnings = [];
|
|
22
|
+
// Parser state must persist across `onData` chunks so that an error
|
|
23
|
+
// whose message and stack trace arrive in separate chunks is still
|
|
24
|
+
// assembled into a single RuntimeError. Resetting on every chunk
|
|
25
|
+
// (the previous bug) caused the .rn-errors.json hook to fire with
|
|
26
|
+
// the message but no stack, or to drop multi-line errors entirely.
|
|
27
|
+
metroParser = new MetroErrorParser();
|
|
28
|
+
MAX_BUILD_LINES = 1000;
|
|
29
|
+
MAX_METRO_LINES = 500;
|
|
30
|
+
MAX_ERRORS = 100;
|
|
31
|
+
/** The working directory for commands. */
|
|
32
|
+
cwd;
|
|
33
|
+
/** Path to the error file that triggers the Claude Code hook. */
|
|
34
|
+
errorFilePath;
|
|
35
|
+
constructor(cwd) {
|
|
36
|
+
this.cwd = cwd;
|
|
37
|
+
this.errorFilePath = join(cwd, ".rn-errors.json");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Detect whether this is an Expo or bare React Native project.
|
|
41
|
+
* Checks for app.json with expo config or expo package in dependencies.
|
|
42
|
+
*/
|
|
43
|
+
async detectProjectType() {
|
|
44
|
+
try {
|
|
45
|
+
// Check for app.json with expo key
|
|
46
|
+
const appJsonPath = join(this.cwd, "app.json");
|
|
47
|
+
await access(appJsonPath);
|
|
48
|
+
const appJson = JSON.parse(await (await import("node:fs/promises")).readFile(appJsonPath, "utf-8"));
|
|
49
|
+
if (appJson.expo) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// No app.json or no expo key
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
// Check for expo in package.json dependencies
|
|
58
|
+
const pkgPath = join(this.cwd, "package.json");
|
|
59
|
+
const pkg = JSON.parse(await (await import("node:fs/promises")).readFile(pkgPath, "utf-8"));
|
|
60
|
+
if (pkg.dependencies?.expo) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// No package.json
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Write errors to .rn-errors.json so the Claude Code hook can detect them.
|
|
71
|
+
* Overwrites the file each time — the hook fires on any write.
|
|
72
|
+
*/
|
|
73
|
+
async writeErrorFile(errors) {
|
|
74
|
+
try {
|
|
75
|
+
await writeFile(this.errorFilePath, JSON.stringify({
|
|
76
|
+
timestamp: new Date().toISOString(),
|
|
77
|
+
errors: errors.map((e) => ({
|
|
78
|
+
message: e.message,
|
|
79
|
+
stack: e.stack,
|
|
80
|
+
timestamp: e.timestamp,
|
|
81
|
+
})),
|
|
82
|
+
}, null, 2), "utf-8");
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Non-critical — don't break the process over a file write
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Clear the error file (e.g., on successful build or process start).
|
|
90
|
+
*/
|
|
91
|
+
async clearErrorFile() {
|
|
92
|
+
try {
|
|
93
|
+
await writeFile(this.errorFilePath, JSON.stringify({ timestamp: new Date().toISOString(), errors: [] }), "utf-8");
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Non-critical
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Append a line to a rolling buffer, evicting oldest entries when full.
|
|
101
|
+
*/
|
|
102
|
+
pushLine(buffer, line, max) {
|
|
103
|
+
buffer.push(line);
|
|
104
|
+
if (buffer.length > max) {
|
|
105
|
+
buffer.splice(0, buffer.length - max);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Cap an error array at MAX_ERRORS, dropping oldest entries.
|
|
110
|
+
*/
|
|
111
|
+
capErrors(arr) {
|
|
112
|
+
if (arr.length > this.MAX_ERRORS) {
|
|
113
|
+
arr.splice(0, arr.length - this.MAX_ERRORS);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Kill a child process gracefully (SIGTERM, then SIGKILL after timeout).
|
|
118
|
+
*
|
|
119
|
+
* The promise resolves only when the process actually exits, not when
|
|
120
|
+
* SIGKILL is sent. Resolving on the SIGKILL timer was a race: callers
|
|
121
|
+
* (e.g. `runBuild`) would spawn a new build while the old Xcode process
|
|
122
|
+
* was still releasing DerivedData / build locks, causing cryptic
|
|
123
|
+
* SIGABRT or "file locked" errors on the new build.
|
|
124
|
+
*
|
|
125
|
+
* A hard upper-bound timer (2s after SIGKILL) guards against the
|
|
126
|
+
* pathological case where a process refuses to die at all, so callers
|
|
127
|
+
* never hang forever.
|
|
128
|
+
*/
|
|
129
|
+
async killProcess(proc) {
|
|
130
|
+
return new Promise((resolve) => {
|
|
131
|
+
if (!proc.pid || proc.exitCode !== null) {
|
|
132
|
+
resolve();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
let hardTimer = null;
|
|
136
|
+
const softTimer = setTimeout(() => {
|
|
137
|
+
try {
|
|
138
|
+
proc.kill("SIGKILL");
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Process may have already exited between checks.
|
|
142
|
+
}
|
|
143
|
+
// Wait for `exit` to fire after SIGKILL, but bound the wait so
|
|
144
|
+
// callers don't hang on a process that refuses to die.
|
|
145
|
+
hardTimer = setTimeout(() => {
|
|
146
|
+
proc.off("exit", onExit);
|
|
147
|
+
resolve();
|
|
148
|
+
}, 2000);
|
|
149
|
+
}, 5000);
|
|
150
|
+
const onExit = () => {
|
|
151
|
+
clearTimeout(softTimer);
|
|
152
|
+
if (hardTimer)
|
|
153
|
+
clearTimeout(hardTimer);
|
|
154
|
+
resolve();
|
|
155
|
+
};
|
|
156
|
+
proc.once("exit", onExit);
|
|
157
|
+
try {
|
|
158
|
+
proc.kill("SIGTERM");
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// Process already exited or PID is gone — treat as done. Detach
|
|
162
|
+
// the exit listener so it doesn't fire stale on a stop/start cycle.
|
|
163
|
+
proc.off("exit", onExit);
|
|
164
|
+
clearTimeout(softTimer);
|
|
165
|
+
resolve();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// TOCTOU recovery: if the process exited between the entry guard
|
|
169
|
+
// and the `once("exit")` registration above, `kill()` returns false
|
|
170
|
+
// (no throw) and our listener never fires for the already-emitted
|
|
171
|
+
// exit. Re-check `exitCode` here so we don't wait the full 7s on
|
|
172
|
+
// the hard timer.
|
|
173
|
+
if (proc.exitCode !== null) {
|
|
174
|
+
proc.off("exit", onExit);
|
|
175
|
+
clearTimeout(softTimer);
|
|
176
|
+
resolve();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Run `npx expo prebuild --clean` synchronously (waits for exit).
|
|
182
|
+
*/
|
|
183
|
+
runPrebuildClean() {
|
|
184
|
+
return new Promise((resolve) => {
|
|
185
|
+
const lines = [];
|
|
186
|
+
const proc = spawn("npx", ["expo", "prebuild", "--clean"], {
|
|
187
|
+
cwd: this.cwd,
|
|
188
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
189
|
+
});
|
|
190
|
+
const onData = (data) => {
|
|
191
|
+
const text = data.toString();
|
|
192
|
+
for (const line of text.split("\n")) {
|
|
193
|
+
if (line.trim())
|
|
194
|
+
lines.push(line);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
proc.stdout?.on("data", onData);
|
|
198
|
+
proc.stderr?.on("data", onData);
|
|
199
|
+
// Node fires both `error` and `exit` on a failed spawn. Guard so
|
|
200
|
+
// the second handler doesn't run a stale resolve / output dump.
|
|
201
|
+
let resolved = false;
|
|
202
|
+
proc.on("error", (err) => {
|
|
203
|
+
if (resolved)
|
|
204
|
+
return;
|
|
205
|
+
resolved = true;
|
|
206
|
+
resolve({
|
|
207
|
+
success: false,
|
|
208
|
+
output: `Failed to spawn prebuild: ${err.message}`,
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
proc.on("exit", (code) => {
|
|
212
|
+
if (resolved)
|
|
213
|
+
return;
|
|
214
|
+
resolved = true;
|
|
215
|
+
resolve({
|
|
216
|
+
success: code === 0,
|
|
217
|
+
output: lines.join("\n"),
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Build and run the app on an iOS device or simulator.
|
|
224
|
+
* Auto-detects Expo vs bare React Native and uses the appropriate command:
|
|
225
|
+
* - Expo: `npx expo run:ios`
|
|
226
|
+
* - Bare RN: `npx react-native run-ios`
|
|
227
|
+
*
|
|
228
|
+
* Optionally runs `npx expo prebuild --clean` first (Expo only).
|
|
229
|
+
*/
|
|
230
|
+
async runBuild(options = {}) {
|
|
231
|
+
const isExpo = await this.detectProjectType();
|
|
232
|
+
// Kill any existing build process
|
|
233
|
+
if (this.buildProcess) {
|
|
234
|
+
this.buildKilled = true;
|
|
235
|
+
await this.killProcess(this.buildProcess);
|
|
236
|
+
this.buildProcess = null;
|
|
237
|
+
}
|
|
238
|
+
// Reset state
|
|
239
|
+
this.buildKilled = false;
|
|
240
|
+
this.buildOutput = [];
|
|
241
|
+
this.buildErrors = [];
|
|
242
|
+
this.buildWarnings = [];
|
|
243
|
+
// Run prebuild --clean if requested (Expo only)
|
|
244
|
+
if (options.clean) {
|
|
245
|
+
if (!isExpo) {
|
|
246
|
+
return {
|
|
247
|
+
success: false,
|
|
248
|
+
errors: [
|
|
249
|
+
{
|
|
250
|
+
file: "",
|
|
251
|
+
line: 0,
|
|
252
|
+
message: "prebuild --clean is only available for Expo projects.",
|
|
253
|
+
type: "build",
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
warnings: [],
|
|
257
|
+
output: "",
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
const prebuildResult = await this.runPrebuildClean();
|
|
261
|
+
if (!prebuildResult.success) {
|
|
262
|
+
return {
|
|
263
|
+
success: false,
|
|
264
|
+
errors: [
|
|
265
|
+
{
|
|
266
|
+
file: "",
|
|
267
|
+
line: 0,
|
|
268
|
+
message: `prebuild --clean failed:\n${prebuildResult.output}`,
|
|
269
|
+
type: "build",
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
warnings: [],
|
|
273
|
+
output: prebuildResult.output,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
// Add prebuild output to build output
|
|
277
|
+
for (const line of prebuildResult.output.split("\n")) {
|
|
278
|
+
this.pushLine(this.buildOutput, line, this.MAX_BUILD_LINES);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Construct the build command based on project type
|
|
282
|
+
const args = isExpo ? ["expo", "run:ios"] : ["react-native", "run-ios"];
|
|
283
|
+
if (options.device) {
|
|
284
|
+
args.push(isExpo ? "--device" : "--udid", options.device);
|
|
285
|
+
}
|
|
286
|
+
return new Promise((resolve) => {
|
|
287
|
+
const proc = spawn("npx", args, {
|
|
288
|
+
cwd: this.cwd,
|
|
289
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
290
|
+
});
|
|
291
|
+
this.buildProcess = proc;
|
|
292
|
+
const onData = (data) => {
|
|
293
|
+
const text = data.toString();
|
|
294
|
+
for (const line of text.split("\n")) {
|
|
295
|
+
if (line.trim()) {
|
|
296
|
+
this.pushLine(this.buildOutput, line, this.MAX_BUILD_LINES);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
proc.stdout?.on("data", onData);
|
|
301
|
+
proc.stderr?.on("data", onData);
|
|
302
|
+
// Node fires both `error` and `exit` on a spawn failure. Guard so
|
|
303
|
+
// the `exit` handler doesn't re-parse the (likely empty) output
|
|
304
|
+
// and overwrite `this.buildErrors` / `this.buildWarnings` after
|
|
305
|
+
// the caller has already received the failure result.
|
|
306
|
+
let resolved = false;
|
|
307
|
+
proc.on("error", (err) => {
|
|
308
|
+
if (resolved)
|
|
309
|
+
return;
|
|
310
|
+
resolved = true;
|
|
311
|
+
this.buildProcess = null;
|
|
312
|
+
resolve({
|
|
313
|
+
success: false,
|
|
314
|
+
errors: [
|
|
315
|
+
{
|
|
316
|
+
file: "",
|
|
317
|
+
line: 0,
|
|
318
|
+
message: `Failed to spawn ${isExpo ? "expo run:ios" : "react-native run-ios"}: ${err.message}`,
|
|
319
|
+
type: "build",
|
|
320
|
+
},
|
|
321
|
+
],
|
|
322
|
+
warnings: [],
|
|
323
|
+
output: this.buildOutput.join("\n"),
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
proc.on("exit", (code) => {
|
|
327
|
+
if (resolved)
|
|
328
|
+
return;
|
|
329
|
+
resolved = true;
|
|
330
|
+
this.buildProcess = null;
|
|
331
|
+
if (this.buildKilled) {
|
|
332
|
+
resolve({
|
|
333
|
+
success: false,
|
|
334
|
+
errors: [],
|
|
335
|
+
warnings: [],
|
|
336
|
+
output: "Build was cancelled.",
|
|
337
|
+
});
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
// Parse all captured output
|
|
341
|
+
const parsed = parseBuildOutput(this.buildOutput);
|
|
342
|
+
this.buildErrors = parsed.errors;
|
|
343
|
+
this.buildWarnings = parsed.warnings;
|
|
344
|
+
this.capErrors(this.buildErrors);
|
|
345
|
+
const success = code === 0 && !parsed.buildFailed;
|
|
346
|
+
resolve({
|
|
347
|
+
success,
|
|
348
|
+
errors: parsed.errors.map((e) => ({
|
|
349
|
+
file: e.file,
|
|
350
|
+
line: e.line,
|
|
351
|
+
message: e.message,
|
|
352
|
+
type: e.type,
|
|
353
|
+
})),
|
|
354
|
+
warnings: parsed.warnings,
|
|
355
|
+
output: this.buildOutput.slice(-100).join("\n"), // Last 100 lines
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Start the Metro dev server.
|
|
362
|
+
* Auto-detects Expo vs bare React Native:
|
|
363
|
+
* - Expo: `npx expo start --dev-client`
|
|
364
|
+
* - Bare RN: `npx react-native start`
|
|
365
|
+
*
|
|
366
|
+
* Spawns Metro in the background and continuously captures output.
|
|
367
|
+
* Returns quickly once Metro shows signs of being ready (or after a timeout).
|
|
368
|
+
*/
|
|
369
|
+
async startMetro(options = {}) {
|
|
370
|
+
const isExpo = await this.detectProjectType();
|
|
371
|
+
// Kill existing Metro process
|
|
372
|
+
if (this.metroProcess) {
|
|
373
|
+
this.metroKilled = true;
|
|
374
|
+
await this.killProcess(this.metroProcess);
|
|
375
|
+
this.metroProcess = null;
|
|
376
|
+
}
|
|
377
|
+
// Reset state
|
|
378
|
+
this.metroKilled = false;
|
|
379
|
+
this.metroOutput = [];
|
|
380
|
+
this.runtimeErrors = [];
|
|
381
|
+
this.metroWarnings = [];
|
|
382
|
+
this.metroParser = new MetroErrorParser();
|
|
383
|
+
const args = isExpo
|
|
384
|
+
? ["expo", "start", "--dev-client"]
|
|
385
|
+
: ["react-native", "start"];
|
|
386
|
+
if (options.port) {
|
|
387
|
+
args.push("--port", String(options.port));
|
|
388
|
+
}
|
|
389
|
+
if (options.clear) {
|
|
390
|
+
args.push(isExpo ? "--clear" : "--reset-cache");
|
|
391
|
+
}
|
|
392
|
+
await this.clearErrorFile();
|
|
393
|
+
return new Promise((resolve) => {
|
|
394
|
+
const proc = spawn("npx", args, {
|
|
395
|
+
cwd: this.cwd,
|
|
396
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
397
|
+
});
|
|
398
|
+
this.metroProcess = proc;
|
|
399
|
+
let resolved = false;
|
|
400
|
+
const onData = (data) => {
|
|
401
|
+
const text = data.toString();
|
|
402
|
+
// Collect lines from this chunk and parse them as a batch.
|
|
403
|
+
// The parser is stateful, so an error whose message and stack
|
|
404
|
+
// straddle chunk boundaries is still assembled correctly.
|
|
405
|
+
const chunkLines = [];
|
|
406
|
+
for (const line of text.split("\n")) {
|
|
407
|
+
if (line.trim()) {
|
|
408
|
+
this.pushLine(this.metroOutput, line, this.MAX_METRO_LINES);
|
|
409
|
+
chunkLines.push(line);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (chunkLines.length > 0) {
|
|
413
|
+
const parsed = this.metroParser.parse(chunkLines);
|
|
414
|
+
if (parsed.runtimeErrors.length > 0) {
|
|
415
|
+
this.runtimeErrors.push(...parsed.runtimeErrors);
|
|
416
|
+
this.capErrors(this.runtimeErrors);
|
|
417
|
+
this.writeErrorFile(this.runtimeErrors);
|
|
418
|
+
}
|
|
419
|
+
if (parsed.warnings.length > 0) {
|
|
420
|
+
this.metroWarnings.push(...parsed.warnings);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// Detect Metro ready
|
|
424
|
+
if (!resolved && text.includes("Metro waiting on")) {
|
|
425
|
+
resolved = true;
|
|
426
|
+
resolve({
|
|
427
|
+
success: true,
|
|
428
|
+
message: "Metro dev server started successfully.",
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
proc.stdout?.on("data", onData);
|
|
433
|
+
proc.stderr?.on("data", onData);
|
|
434
|
+
proc.on("error", (err) => {
|
|
435
|
+
this.metroProcess = null;
|
|
436
|
+
if (!resolved) {
|
|
437
|
+
resolved = true;
|
|
438
|
+
resolve({
|
|
439
|
+
success: false,
|
|
440
|
+
message: `Failed to start Metro: ${err.message}`,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
proc.on("exit", (code) => {
|
|
445
|
+
this.metroProcess = null;
|
|
446
|
+
// Flush any error still being assembled in the streaming parser
|
|
447
|
+
// so it surfaces in `.rn-errors.json` for the Claude Code hook.
|
|
448
|
+
const flushed = this.metroParser.flush();
|
|
449
|
+
if (flushed.runtimeErrors.length > 0) {
|
|
450
|
+
this.runtimeErrors.push(...flushed.runtimeErrors);
|
|
451
|
+
this.capErrors(this.runtimeErrors);
|
|
452
|
+
this.writeErrorFile(this.runtimeErrors);
|
|
453
|
+
}
|
|
454
|
+
if (this.metroKilled) {
|
|
455
|
+
if (!resolved) {
|
|
456
|
+
resolved = true;
|
|
457
|
+
resolve({
|
|
458
|
+
success: false,
|
|
459
|
+
message: "Metro was cancelled.",
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (!resolved) {
|
|
465
|
+
resolved = true;
|
|
466
|
+
resolve({
|
|
467
|
+
success: false,
|
|
468
|
+
message: `Metro exited unexpectedly with code ${code}.`,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
// If Metro doesn't show "ready" within 30s, resolve anyway
|
|
473
|
+
// -- it may still be starting up (pod install, etc.)
|
|
474
|
+
setTimeout(() => {
|
|
475
|
+
if (!resolved) {
|
|
476
|
+
resolved = true;
|
|
477
|
+
resolve({
|
|
478
|
+
success: true,
|
|
479
|
+
message: "Metro process started but has not confirmed readiness yet. " +
|
|
480
|
+
"It may still be initializing. Use rn_get_console to check.",
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}, 30_000);
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Return all captured errors from both build and runtime contexts.
|
|
488
|
+
*/
|
|
489
|
+
getErrors() {
|
|
490
|
+
// Only re-parse the buffer when Metro is NOT running. While the
|
|
491
|
+
// streaming parser holds in-progress state (header received, stack
|
|
492
|
+
// not yet), an independent re-parse would `flush()` a stackless
|
|
493
|
+
// copy of that same error and surface it as a duplicate-without-stack.
|
|
494
|
+
// Once Metro exits, the streaming parser has already been flushed
|
|
495
|
+
// in the `proc.on("exit")` handler, and re-parsing the buffer is a
|
|
496
|
+
// safe fallback for anything still missing.
|
|
497
|
+
const metroParsed = this.metroProcess === null
|
|
498
|
+
? parseMetroOutput(this.metroOutput)
|
|
499
|
+
: { runtimeErrors: [], warnings: [] };
|
|
500
|
+
return {
|
|
501
|
+
buildErrors: this.buildErrors.map((e) => ({
|
|
502
|
+
file: e.file,
|
|
503
|
+
line: e.line,
|
|
504
|
+
column: e.column,
|
|
505
|
+
message: e.message,
|
|
506
|
+
severity: e.severity,
|
|
507
|
+
type: e.type,
|
|
508
|
+
})),
|
|
509
|
+
runtimeErrors: this.runtimeErrors.length > 0
|
|
510
|
+
? this.runtimeErrors
|
|
511
|
+
: metroParsed.runtimeErrors,
|
|
512
|
+
warnings: [...this.buildWarnings, ...this.metroWarnings],
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Return recent console output from Metro, optionally filtered by level.
|
|
517
|
+
*/
|
|
518
|
+
getConsole(options = {}) {
|
|
519
|
+
const { lines = 50, level = "all" } = options;
|
|
520
|
+
let output = this.metroOutput;
|
|
521
|
+
if (level !== "all") {
|
|
522
|
+
output = output.filter((line) => {
|
|
523
|
+
const classified = classifyLogLevel(line);
|
|
524
|
+
return classified === level;
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
// Return the last N lines
|
|
528
|
+
return output.slice(-lines);
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Stop all managed Expo processes.
|
|
532
|
+
*/
|
|
533
|
+
async stopAll() {
|
|
534
|
+
const stopped = [];
|
|
535
|
+
if (this.buildProcess) {
|
|
536
|
+
this.buildKilled = true;
|
|
537
|
+
await this.killProcess(this.buildProcess);
|
|
538
|
+
this.buildProcess = null;
|
|
539
|
+
stopped.push("build");
|
|
540
|
+
}
|
|
541
|
+
if (this.metroProcess) {
|
|
542
|
+
this.metroKilled = true;
|
|
543
|
+
await this.killProcess(this.metroProcess);
|
|
544
|
+
this.metroProcess = null;
|
|
545
|
+
stopped.push("metro");
|
|
546
|
+
}
|
|
547
|
+
return stopped;
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Get the raw build output buffer (for diagnostics).
|
|
551
|
+
*/
|
|
552
|
+
getBuildOutput() {
|
|
553
|
+
return [...this.buildOutput];
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Get the raw metro output buffer (for diagnostics).
|
|
557
|
+
*/
|
|
558
|
+
getMetroOutput() {
|
|
559
|
+
return [...this.metroOutput];
|
|
560
|
+
}
|
|
561
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @ceraph/react-native-mcp — MCP server for React Native / Expo development workflow.
|
|
4
|
+
*
|
|
5
|
+
* Auto-detects Expo vs bare React Native projects and uses the appropriate
|
|
6
|
+
* commands. Provides tools for building, running, error capture, screen
|
|
7
|
+
* interaction, and prebuild detection.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|