Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Command } from "commander";
import { add } from "./commands/add.js";
import { configure } from "./commands/configure.js";
import { init } from "./commands/init.js";
import { migrate } from "./commands/migrate.js";
import { remove } from "./commands/remove.js";

const VERSION = process.env.VERSION ?? "0.0.1";
Expand All @@ -23,6 +24,7 @@ program.addCommand(init);
program.addCommand(add);
program.addCommand(remove);
program.addCommand(configure);
program.addCommand(migrate);

const main = async () => {
await program.parseAsync();
Expand Down
373 changes: 373 additions & 0 deletions packages/cli/src/commands/migrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,373 @@
import { Command } from "commander";
import pc from "picocolors";
import prompts from "prompts";
import {
applyTransformWithFeedback,
installPackagesWithFeedback,
uninstallPackagesWithFeedback,
} from "../utils/cli-helpers.js";
import { detectProject, detectReactScan } from "../utils/detect.js";
import { printDiff } from "../utils/diff.js";
import { handleError } from "../utils/handle-error.js";
import { highlighter } from "../utils/highlighter.js";
import { getPackagesToInstall } from "../utils/install.js";
import { logger } from "../utils/logger.js";
import { spinner } from "../utils/spinner.js";
import {
previewReactScanRemoval,
previewTransform,
type TransformResult,
} from "../utils/transform.js";

const VERSION = process.env.VERSION ?? "0.0.1";
const DOCS_URL = "https://github.com/aidenybai/react-grab";

const exitWithMessage = (message?: string, code = 0): never => {
if (message) logger.log(message);
logger.break();
process.exit(code);
};

const confirmOrExit = async (
message: string,
isNonInteractive: boolean,
): Promise<void> => {
if (isNonInteractive) return;
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message,
initial: true,
});
if (!proceed) exitWithMessage("Migration cancelled.");
};

const hasTransformChanges = (
result: TransformResult,
): result is TransformResult & {
originalContent: string;
newContent: string;
} =>
result.success &&
!result.noChanges &&
Boolean(result.originalContent) &&
Boolean(result.newContent);

const FRAMEWORK_DISPLAY_NAMES: Record<string, string> = {
next: "Next.js",
vite: "Vite",
tanstack: "TanStack Start",
webpack: "Webpack",
};

export const migrate = new Command()
.name("migrate")
.description("migrate to React Grab from another tool")
.option("-y, --yes", "skip confirmation prompts", false)
.option("-f, --from <source>", "migration source (react-scan)")
.option(
"-c, --cwd <cwd>",
"working directory (defaults to current directory)",
process.cwd(),
)
.action(async (opts) => {
console.log(
`${pc.magenta("✿")} ${pc.bold("React Grab")} ${pc.gray(VERSION)}`,
);
console.log();

try {
const { cwd, yes: isNonInteractive, from: migrationSource } = opts;

if (migrationSource && migrationSource !== "react-scan") {
logger.error(`Unknown migration source: ${migrationSource}`);
logger.log(`Available sources: ${highlighter.info("react-scan")}`);
logger.break();
process.exit(1);
}

logger.break();
logger.log(
`Migrating from ${highlighter.info("React Scan")} to ${highlighter.info("React Grab")}...`,
);
logger.break();

const preflightSpinner = spinner("Preflight checks.").start();
const projectInfo = await detectProject(cwd);
preflightSpinner.succeed();

if (projectInfo.framework === "unknown") {
logger.break();
logger.error("Could not detect a supported framework.");
logger.log(
"React Grab supports Next.js, Vite, TanStack Start, and Webpack projects.",
);
logger.log(`Visit ${highlighter.info(DOCS_URL)} for manual setup.`);
logger.break();
process.exit(1);
}

const frameworkName = FRAMEWORK_DISPLAY_NAMES[projectInfo.framework];
const frameworkSpinner = spinner("Verifying framework.").start();
frameworkSpinner.succeed(
`Verifying framework. Found ${highlighter.info(frameworkName)}.`,
);

if (projectInfo.framework === "next") {
const routerSpinner = spinner("Detecting router type.").start();
const routerName =
projectInfo.nextRouterType === "app" ? "App Router" : "Pages Router";
routerSpinner.succeed(
`Detecting router type. Found ${highlighter.info(routerName)}.`,
);
}

const sourceSpinner = spinner("Checking for React Scan.").start();
const reactScanInfo = detectReactScan(cwd);

if (!reactScanInfo.hasReactScan) {
sourceSpinner.fail("React Scan is not installed in this project.");
exitWithMessage(
`Use ${highlighter.info("npx grab init")} to install React Grab directly.`,
);
}

const detectionType = reactScanInfo.isPackageInstalled
? "npm package"
: "script reference";
sourceSpinner.succeed(
`Checking for React Scan. Found ${highlighter.info(detectionType)}.`,
);

if (reactScanInfo.hasReactScanMonitoring) {
logger.break();
logger.warn("React Scan Monitoring (@react-scan/monitoring) detected.");
logger.warn(
"Monitoring features are not available in React Grab. You may need to remove it manually.",
);
}

const removalResult = previewReactScanRemoval(
projectInfo.projectRoot,
projectInfo.framework,
projectInfo.nextRouterType,
);

const getOtherDetectedFiles = () =>
reactScanInfo.detectedFiles.filter(
(file) => file !== removalResult.filePath,
);

const shouldUninstallPackage =
reactScanInfo.isPackageInstalled &&
getOtherDetectedFiles().length === 0;

if (projectInfo.hasReactGrab) {
logger.break();
logger.success("React Grab is already installed.");
logger.log(
"This migration will only remove React Scan from your project.",
);
logger.break();

if (removalResult.noChanges) {
logger.log("No React Scan code found in configuration files.");
logger.break();

if (reactScanInfo.detectedFiles.length > 0) {
logger.warn(
"React Scan was detected in files that cannot be automatically cleaned:",
);
for (const file of reactScanInfo.detectedFiles) {
logger.log(` - ${file}`);
}
logger.warn(
"Please remove React Scan references manually before uninstalling the package.",
);
logger.break();
process.exit(1);
}

if (reactScanInfo.isPackageInstalled) {
await confirmOrExit(
"Uninstall react-scan package?",
isNonInteractive,
);
uninstallPackagesWithFeedback(
["react-scan"],
projectInfo.packageManager,
projectInfo.projectRoot,
);
logger.break();
logger.success("React Scan has been removed.");
}

exitWithMessage();
}

if (hasTransformChanges(removalResult)) {
logger.break();
printDiff(
removalResult.filePath,
removalResult.originalContent,
removalResult.newContent,
);
logger.break();
await confirmOrExit("Apply these changes?", isNonInteractive);

applyTransformWithFeedback(
removalResult,
`Removing React Scan from ${removalResult.filePath}.`,
);

if (shouldUninstallPackage) {
uninstallPackagesWithFeedback(
["react-scan"],
projectInfo.packageManager,
projectInfo.projectRoot,
);
}

logger.break();
logger.success("Migration complete! React Scan has been removed.");
exitWithMessage();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing warning about uncleaned files in hasReactGrab branch

Medium Severity

When React Grab is already installed and the removal transform succeeds, the code logs "Migration complete! React Scan has been removed." without checking for additional detected files that weren't automatically cleaned. The main migration flow (lines 353-363) includes a warning when getOtherDetectedFiles().length > 0, but this warning is missing in the hasReactGrab branch. A user could have React Scan in multiple files; only one gets cleaned, but the success message suggests the migration is fully complete.

Fix in Cursor Fix in Web

}

if (!removalResult.success) {
logger.break();
logger.error("Failed to remove React Scan.");
logger.log(removalResult.message);
logger.break();
process.exit(1);
}

exitWithMessage();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failed removal silently exits without user feedback

Medium Severity

When projectInfo.hasReactGrab is true and previewReactScanRemoval returns success: false (e.g., when a file containing react-scan is found but the removal pattern doesn't match the specific code format), neither the noChanges branch nor the hasTransformChanges branch is entered. The code silently falls through to exitWithMessage() with no argument, producing no error or success message. The user sees "This migration will only remove React Scan" and then a blank exit, with no indication that the removal actually failed. The result.message explaining the failure is never displayed.

Fix in Cursor Fix in Web

}

const addResult = previewTransform(
projectInfo.projectRoot,
projectInfo.framework,
projectInfo.nextRouterType,
"none",
false,
);

const hasRemovalChanges = hasTransformChanges(removalResult);
const hasAddChanges = hasTransformChanges(addResult);
const shouldShowUninstallStep =
shouldUninstallPackage &&
(hasRemovalChanges ||
getOtherDetectedFiles().length ===
reactScanInfo.detectedFiles.length);

if (!hasRemovalChanges && !hasAddChanges) {
exitWithMessage("No changes needed.");
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Migration early exit skips package uninstall when no file changes

Medium Severity

The early exit condition !hasRemovalChanges && !hasAddChanges causes the migration to exit with "No changes needed" even when react-scan package is installed in package.json but has no file references. The later package uninstall logic at lines 389-397 correctly checks reactScanInfo.isPackageInstalled && (hasRemovalChanges || reactScanInfo.detectedFiles.length === 0), which would uninstall the package when there are no file references, but this code is never reached due to the early exit.

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Migration succeeds silently when detected files cannot be cleaned

Medium Severity

When React Scan is detected in files (e.g., _app.tsx) that the removal logic doesn't target (which only handles _document.tsx for Pages Router), the migration proceeds without warning. The hasReactGrab branch has validation at lines 209-222 that warns about detectedFiles that couldn't be cleaned, but the main migration branch lacks this check. Users see "Success! Migration complete." while React Scan code remains in their project.

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Migration reports success when React Grab code cannot be added

Medium Severity

When previewTransform returns success: false (e.g., layout file not found), the migration doesn't check for this failure. If removal succeeds but addition fails, the migration removes React Scan, installs the react-grab package, but does NOT add React Grab code to the project. The migration still reports "Success!" without showing the error message from addResult.message. This leaves users with a broken setup where neither tool works.

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Migration exits silently ignoring detected React Scan files

Medium Severity

When React Scan is detected (in files or package.json) but the target transformation file doesn't exist, the migration exits with "No changes needed." without warning the user about detected React Scan files that couldn't be cleaned, and without offering to uninstall the react-scan package. This happens because previewReactScanRemoval returns noChanges: true and previewTransform returns success: false when target files are missing, causing both hasRemovalChanges and hasAddChanges to be false, which triggers the early exit before the warning logic at lines 353-363 is reached.

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent failure when detected react-scan pattern cannot be removed

Medium Severity

Detection patterns in REACT_SCAN_DETECTION_PATTERNS include /<script[^>]*react-scan/i for CDN script tags, but removal patterns (removeReactScanFromVite) only handle dynamic imports like import("react-scan"). When a Vite project has a CDN script like <script src="unpkg.com/react-scan">, detection finds it, but removal fails silently with noChanges: true. The warning logic at lines 276-306 only fires when BOTH removal and addition have no changes. The warning at lines 394-404 uses getOtherDetectedFiles() which excludes the removal target file. So when removal fails but addition proceeds, the user sees "Migration complete!" with no indication that react-scan is still present.

Additional Locations (1)

Fix in Cursor Fix in Web


logger.break();
logger.log("Migration will perform the following changes:");
logger.break();

if (hasRemovalChanges) {
logger.log(
` ${pc.red("−")} Remove React Scan from ${removalResult.filePath}`,
);
}
if (shouldShowUninstallStep) {
logger.log(` ${pc.red("−")} Uninstall react-scan package`);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preview shows package uninstall that won't happen

Medium Severity

The preview at line 330 shows "Uninstall react-scan package" whenever reactScanInfo.isPackageInstalled is true. However, the actual uninstall at lines 393-401 only happens when isPackageInstalled && (hasRemovalChanges || detectedFiles.length === 0). When React Scan is detected in files but can't be auto-removed (detectedFiles.length > 0 and hasRemovalChanges = false), the preview misleadingly shows the uninstall action, but it won't actually be performed.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Package uninstall preview inconsistent with actual action

Medium Severity

The migration preview at line 330 shows "Uninstall react-scan package" when reactScanInfo.isPackageInstalled is true. However, the actual uninstall at lines 393-396 only executes when reactScanInfo.isPackageInstalled && (hasRemovalChanges || reactScanInfo.detectedFiles.length === 0). When React Scan is detected in files but the removal patterns don't match, the preview incorrectly promises package uninstallation that won't occur.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preview shows package uninstall but execution skips it

Medium Severity

The preview unconditionally shows "Uninstall react-scan package" when reactScanInfo.isPackageInstalled is true, but the actual uninstall at line 360 has an additional condition: hasRemovalChanges || reactScanInfo.detectedFiles.length === 0. When React Scan code exists in a file not targeted by removal (e.g., _app.tsx when removal only checks _document.tsx), the preview promises an uninstall that never happens. Users see a successful migration but the package remains installed.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preview shows package uninstall that won't actually happen

Medium Severity

The preview message at lines 315-317 shows "Uninstall react-scan package" whenever reactScanInfo.isPackageInstalled is true. However, the actual uninstall at lines 370-376 has an additional condition: otherDetectedFiles.length === 0. If react-scan code exists in files that weren't cleaned by the removal transform (e.g., detected in src/main.tsx but removal targeted index.html), the preview will claim the package will be uninstalled, but it won't be. The preview condition needs to match the execution condition.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preview shows uninstall but execution skips it

Medium Severity

The preview message at line 262 uses shouldUninstallPackage alone to decide whether to show "Uninstall react-scan package", but the actual uninstall at lines 321-325 has an additional condition requiring either hasRemovalChanges or that the removal target file wasn't in detectedFiles. When react-scan is detected in the framework file but the removal patterns don't match the code format, the preview indicates the package will be uninstalled but the execution skips it. This misleads users about what the migration will actually do.

Additional Locations (1)

Fix in Cursor Fix in Web

logger.log(` ${pc.green("+")} Install react-grab package`);
if (hasAddChanges) {
logger.log(
` ${pc.green("+")} Add React Grab to ${addResult.filePath}`,
);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing warning for uncleaned react-scan files during migration

Medium Severity

When React Grab is not yet installed, the migration proceeds without warning users about detected react-scan files that weren't automatically cleaned. The warning logic at lines 177-188 only triggers when projectInfo.hasReactGrab is true. In the normal migration path, getOtherDetectedFiles() may contain files with react-scan that the removal didn't target (e.g., react-scan in pages/_app.tsx when removal only targets pages/_document.tsx), but no warning is displayed.

Fix in Cursor Fix in Web


const isSameFile =
hasRemovalChanges &&
hasAddChanges &&
removalResult.filePath === addResult.filePath;

if (isSameFile) {
logger.break();
printDiff(
removalResult.filePath,
removalResult.originalContent,
addResult.newContent,
);
} else {
if (hasRemovalChanges) {
logger.break();
printDiff(
removalResult.filePath,
removalResult.originalContent,
removalResult.newContent,
);
}
if (
hasAddChanges &&
addResult.originalContent !== undefined &&
addResult.newContent !== undefined
) {
logger.break();
printDiff(
addResult.filePath,
addResult.originalContent,
addResult.newContent,
);
}
}

logger.break();
logger.warn("Auto-detection may not be 100% accurate.");
logger.warn("Please verify the changes before committing.");
logger.break();
await confirmOrExit("Apply these changes?", isNonInteractive);

if (hasRemovalChanges) {
applyTransformWithFeedback(
removalResult,
`Removing React Scan from ${removalResult.filePath}.`,
);
}
if (hasAddChanges) {
applyTransformWithFeedback(
addResult,
`Adding React Grab to ${addResult.filePath}.`,
);
}
if (shouldShowUninstallStep) {
uninstallPackagesWithFeedback(
["react-scan"],
projectInfo.packageManager,
projectInfo.projectRoot,
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Package uninstalled while react-scan imports remain in uncleaned files

Medium Severity

The detection in detectReactScan checks multiple files (e.g., both _document.tsx and _app.tsx for Pages Router), but findReactScanFile only returns one file per framework. When react-scan exists in multiple files, the removal only cleans one, yet the uninstall condition at line 361 (hasRemovalChanges || reactScanInfo.detectedFiles.length === 0) evaluates to true if ANY removal succeeded. This causes the package to be uninstalled while other files still have react-scan imports, breaking those files at runtime.

Additional Locations (1)

Fix in Cursor Fix in Web

}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Package uninstall ignores detection-removal file mismatch

High Severity

Detection checks files like _app.tsx for Next.js Pages Router and src/main.tsx for Vite, but removal only targets _document.tsx or index.html respectively. When React Scan is in a detected-but-not-removed file, the package is still uninstalled unconditionally, leaving broken imports. The detectedFiles array that tracks where React Scan was found is populated but never used to prevent this inconsistency.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Package uninstalled when code removal fails

Medium Severity

The shouldUninstallPackage logic filters out removalResult.filePath from detected files, assuming it will be cleaned. However, at lines 321-327, the package uninstall executes regardless of whether hasRemovalChanges is true. If detection patterns match a file (broad patterns like /react-scan/) but removal patterns fail to match the actual code format (e.g., non-self-closing <Script> tags), the package gets uninstalled while the code still references it, causing runtime import errors.

Additional Locations (1)

Fix in Cursor Fix in Web


installPackagesWithFeedback(
getPackagesToInstall("none", true),
projectInfo.packageManager,
projectInfo.projectRoot,
);

if (getOtherDetectedFiles().length > 0) {
logger.break();
logger.warn(
"React Scan was detected in additional files that were not automatically cleaned:",
);
for (const file of getOtherDetectedFiles()) {
logger.log(` - ${file}`);
}
logger.warn(
"Please remove React Scan references from these files manually.",
);
}

logger.break();
logger.log(`${highlighter.success("Success!")} Migration complete.`);
logger.log("You may now start your development server.");
logger.break();
} catch (error) {
handleError(error);
}
});
Loading
Loading