Skip to content

Commit a98d117

Browse files
feat: add React Scan migration CLI and enhance detection/removal logic
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ba3162d commit a98d117

File tree

26 files changed

+4614
-375
lines changed

26 files changed

+4614
-375
lines changed

packages/cli/src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Command } from "commander";
22
import { add } from "./commands/add.js";
33
import { configure } from "./commands/configure.js";
44
import { init } from "./commands/init.js";
5+
import { migrate } from "./commands/migrate.js";
56
import { remove } from "./commands/remove.js";
67

78
const VERSION = process.env.VERSION ?? "0.0.1";
@@ -23,6 +24,7 @@ program.addCommand(init);
2324
program.addCommand(add);
2425
program.addCommand(remove);
2526
program.addCommand(configure);
27+
program.addCommand(migrate);
2628

2729
const main = async () => {
2830
await program.parseAsync();
Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
import { Command } from "commander";
2+
import pc from "picocolors";
3+
import prompts from "prompts";
4+
import {
5+
applyTransformWithFeedback,
6+
installPackagesWithFeedback,
7+
uninstallPackagesWithFeedback,
8+
} from "../utils/cli-helpers.js";
9+
import { detectProject, detectReactScan } from "../utils/detect.js";
10+
import { printDiff } from "../utils/diff.js";
11+
import { handleError } from "../utils/handle-error.js";
12+
import { highlighter } from "../utils/highlighter.js";
13+
import { getPackagesToInstall } from "../utils/install.js";
14+
import { logger } from "../utils/logger.js";
15+
import { spinner } from "../utils/spinner.js";
16+
import {
17+
previewReactScanRemoval,
18+
previewTransform,
19+
type TransformResult,
20+
} from "../utils/transform.js";
21+
22+
const VERSION = process.env.VERSION ?? "0.0.1";
23+
const DOCS_URL = "https://github.com/aidenybai/react-grab";
24+
25+
const exitWithMessage = (message?: string, code = 0): never => {
26+
if (message) logger.log(message);
27+
logger.break();
28+
process.exit(code);
29+
};
30+
31+
const confirmOrExit = async (
32+
message: string,
33+
isNonInteractive: boolean,
34+
): Promise<void> => {
35+
if (isNonInteractive) return;
36+
const { proceed } = await prompts({
37+
type: "confirm",
38+
name: "proceed",
39+
message,
40+
initial: true,
41+
});
42+
if (!proceed) exitWithMessage("Migration cancelled.");
43+
};
44+
45+
const hasTransformChanges = (
46+
result: TransformResult,
47+
): result is TransformResult & {
48+
originalContent: string;
49+
newContent: string;
50+
} =>
51+
result.success &&
52+
!result.noChanges &&
53+
Boolean(result.originalContent) &&
54+
Boolean(result.newContent);
55+
56+
const FRAMEWORK_DISPLAY_NAMES: Record<string, string> = {
57+
next: "Next.js",
58+
vite: "Vite",
59+
tanstack: "TanStack Start",
60+
webpack: "Webpack",
61+
};
62+
63+
export const migrate = new Command()
64+
.name("migrate")
65+
.description("migrate to React Grab from another tool")
66+
.option("-y, --yes", "skip confirmation prompts", false)
67+
.option("-f, --from <source>", "migration source (react-scan)")
68+
.option(
69+
"-c, --cwd <cwd>",
70+
"working directory (defaults to current directory)",
71+
process.cwd(),
72+
)
73+
.action(async (opts) => {
74+
console.log(
75+
`${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION)}`,
76+
);
77+
console.log();
78+
79+
try {
80+
const { cwd, yes: isNonInteractive, from: migrationSource } = opts;
81+
82+
if (migrationSource && migrationSource !== "react-scan") {
83+
logger.error(`Unknown migration source: ${migrationSource}`);
84+
logger.log(`Available sources: ${highlighter.info("react-scan")}`);
85+
logger.break();
86+
process.exit(1);
87+
}
88+
89+
logger.break();
90+
logger.log(
91+
`Migrating from ${highlighter.info("React Scan")} to ${highlighter.info("React Grab")}...`,
92+
);
93+
logger.break();
94+
95+
const preflightSpinner = spinner("Preflight checks.").start();
96+
const projectInfo = await detectProject(cwd);
97+
preflightSpinner.succeed();
98+
99+
if (projectInfo.framework === "unknown") {
100+
logger.break();
101+
logger.error("Could not detect a supported framework.");
102+
logger.log(
103+
"React Grab supports Next.js, Vite, TanStack Start, and Webpack projects.",
104+
);
105+
logger.log(`Visit ${highlighter.info(DOCS_URL)} for manual setup.`);
106+
logger.break();
107+
process.exit(1);
108+
}
109+
110+
const frameworkName = FRAMEWORK_DISPLAY_NAMES[projectInfo.framework];
111+
const frameworkSpinner = spinner("Verifying framework.").start();
112+
frameworkSpinner.succeed(
113+
`Verifying framework. Found ${highlighter.info(frameworkName)}.`,
114+
);
115+
116+
if (projectInfo.framework === "next") {
117+
const routerSpinner = spinner("Detecting router type.").start();
118+
const routerName =
119+
projectInfo.nextRouterType === "app" ? "App Router" : "Pages Router";
120+
routerSpinner.succeed(
121+
`Detecting router type. Found ${highlighter.info(routerName)}.`,
122+
);
123+
}
124+
125+
const sourceSpinner = spinner("Checking for React Scan.").start();
126+
const reactScanInfo = detectReactScan(cwd);
127+
128+
if (!reactScanInfo.hasReactScan) {
129+
sourceSpinner.fail("React Scan is not installed in this project.");
130+
exitWithMessage(
131+
`Use ${highlighter.info("npx grab init")} to install React Grab directly.`,
132+
);
133+
}
134+
135+
const detectionType = reactScanInfo.isPackageInstalled
136+
? "npm package"
137+
: "script reference";
138+
sourceSpinner.succeed(
139+
`Checking for React Scan. Found ${highlighter.info(detectionType)}.`,
140+
);
141+
142+
if (reactScanInfo.hasReactScanMonitoring) {
143+
logger.break();
144+
logger.warn("React Scan Monitoring (@react-scan/monitoring) detected.");
145+
logger.warn(
146+
"Monitoring features are not available in React Grab. You may need to remove it manually.",
147+
);
148+
}
149+
150+
const removalResult = previewReactScanRemoval(
151+
projectInfo.projectRoot,
152+
projectInfo.framework,
153+
projectInfo.nextRouterType,
154+
);
155+
156+
const getOtherDetectedFiles = () =>
157+
reactScanInfo.detectedFiles.filter(
158+
(file) => file !== removalResult.filePath,
159+
);
160+
161+
const shouldUninstallPackage =
162+
reactScanInfo.isPackageInstalled &&
163+
getOtherDetectedFiles().length === 0;
164+
165+
if (projectInfo.hasReactGrab) {
166+
logger.break();
167+
logger.success("React Grab is already installed.");
168+
logger.log(
169+
"This migration will only remove React Scan from your project.",
170+
);
171+
logger.break();
172+
173+
if (removalResult.noChanges) {
174+
logger.log("No React Scan code found in configuration files.");
175+
logger.break();
176+
177+
if (reactScanInfo.detectedFiles.length > 0) {
178+
logger.warn(
179+
"React Scan was detected in files that cannot be automatically cleaned:",
180+
);
181+
for (const file of reactScanInfo.detectedFiles) {
182+
logger.log(` - ${file}`);
183+
}
184+
logger.warn(
185+
"Please remove React Scan references manually before uninstalling the package.",
186+
);
187+
logger.break();
188+
process.exit(1);
189+
}
190+
191+
if (reactScanInfo.isPackageInstalled) {
192+
await confirmOrExit(
193+
"Uninstall react-scan package?",
194+
isNonInteractive,
195+
);
196+
uninstallPackagesWithFeedback(
197+
["react-scan"],
198+
projectInfo.packageManager,
199+
projectInfo.projectRoot,
200+
);
201+
logger.break();
202+
logger.success("React Scan has been removed.");
203+
}
204+
205+
exitWithMessage();
206+
}
207+
208+
if (hasTransformChanges(removalResult)) {
209+
logger.break();
210+
printDiff(
211+
removalResult.filePath,
212+
removalResult.originalContent,
213+
removalResult.newContent,
214+
);
215+
logger.break();
216+
await confirmOrExit("Apply these changes?", isNonInteractive);
217+
218+
applyTransformWithFeedback(
219+
removalResult,
220+
`Removing React Scan from ${removalResult.filePath}.`,
221+
);
222+
223+
if (shouldUninstallPackage) {
224+
uninstallPackagesWithFeedback(
225+
["react-scan"],
226+
projectInfo.packageManager,
227+
projectInfo.projectRoot,
228+
);
229+
}
230+
231+
logger.break();
232+
logger.success("Migration complete! React Scan has been removed.");
233+
exitWithMessage();
234+
}
235+
236+
if (!removalResult.success) {
237+
logger.break();
238+
logger.error("Failed to remove React Scan.");
239+
logger.log(removalResult.message);
240+
logger.break();
241+
process.exit(1);
242+
}
243+
244+
exitWithMessage();
245+
}
246+
247+
const addResult = previewTransform(
248+
projectInfo.projectRoot,
249+
projectInfo.framework,
250+
projectInfo.nextRouterType,
251+
"none",
252+
false,
253+
);
254+
255+
const hasRemovalChanges = hasTransformChanges(removalResult);
256+
const hasAddChanges = hasTransformChanges(addResult);
257+
const shouldShowUninstallStep =
258+
shouldUninstallPackage &&
259+
(hasRemovalChanges ||
260+
getOtherDetectedFiles().length ===
261+
reactScanInfo.detectedFiles.length);
262+
263+
if (!hasRemovalChanges && !hasAddChanges) {
264+
exitWithMessage("No changes needed.");
265+
}
266+
267+
logger.break();
268+
logger.log("Migration will perform the following changes:");
269+
logger.break();
270+
271+
if (hasRemovalChanges) {
272+
logger.log(
273+
` ${pc.red("−")} Remove React Scan from ${removalResult.filePath}`,
274+
);
275+
}
276+
if (shouldShowUninstallStep) {
277+
logger.log(` ${pc.red("−")} Uninstall react-scan package`);
278+
}
279+
logger.log(` ${pc.green("+")} Install react-grab package`);
280+
if (hasAddChanges) {
281+
logger.log(
282+
` ${pc.green("+")} Add React Grab to ${addResult.filePath}`,
283+
);
284+
}
285+
286+
const isSameFile =
287+
hasRemovalChanges &&
288+
hasAddChanges &&
289+
removalResult.filePath === addResult.filePath;
290+
291+
if (isSameFile) {
292+
logger.break();
293+
printDiff(
294+
removalResult.filePath,
295+
removalResult.originalContent,
296+
addResult.newContent,
297+
);
298+
} else {
299+
if (hasRemovalChanges) {
300+
logger.break();
301+
printDiff(
302+
removalResult.filePath,
303+
removalResult.originalContent,
304+
removalResult.newContent,
305+
);
306+
}
307+
if (
308+
hasAddChanges &&
309+
addResult.originalContent !== undefined &&
310+
addResult.newContent !== undefined
311+
) {
312+
logger.break();
313+
printDiff(
314+
addResult.filePath,
315+
addResult.originalContent,
316+
addResult.newContent,
317+
);
318+
}
319+
}
320+
321+
logger.break();
322+
logger.warn("Auto-detection may not be 100% accurate.");
323+
logger.warn("Please verify the changes before committing.");
324+
logger.break();
325+
await confirmOrExit("Apply these changes?", isNonInteractive);
326+
327+
if (hasRemovalChanges) {
328+
applyTransformWithFeedback(
329+
removalResult,
330+
`Removing React Scan from ${removalResult.filePath}.`,
331+
);
332+
}
333+
if (hasAddChanges) {
334+
applyTransformWithFeedback(
335+
addResult,
336+
`Adding React Grab to ${addResult.filePath}.`,
337+
);
338+
}
339+
if (shouldShowUninstallStep) {
340+
uninstallPackagesWithFeedback(
341+
["react-scan"],
342+
projectInfo.packageManager,
343+
projectInfo.projectRoot,
344+
);
345+
}
346+
347+
installPackagesWithFeedback(
348+
getPackagesToInstall("none", true),
349+
projectInfo.packageManager,
350+
projectInfo.projectRoot,
351+
);
352+
353+
logger.break();
354+
logger.log(`${highlighter.success("Success!")} Migration complete.`);
355+
logger.log("You may now start your development server.");
356+
logger.break();
357+
} catch (error) {
358+
handleError(error);
359+
}
360+
});

0 commit comments

Comments
 (0)