shadcn-remover
Version:
A CLI tool to remove Shadcn UI components from your project
259 lines (258 loc) • 11.5 kB
JavaScript
// a CLI tool to remove Shadcn ui components from a project
import { Command } from "commander";
import inquirer from "inquirer";
import chalk from "chalk";
import fs from "fs-extra";
import path from "path";
import ora from "ora";
import { fileURLToPath } from "url"; // Import necessary function for __dirname workaround
// --- Start: __dirname workaround for ES Modules ---
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const program = new Command();
// Define the expected components directory relative to the current working directory
const COMPONENTS_DIR = path.join(process.cwd(), "src", "components", "ui");
// Helper function to check if the components directory exists
async function checkComponentsDir() {
const exists = await fs.pathExists(COMPONENTS_DIR);
if (!exists) {
console.error(chalk.red(`❌ Error: Directory not found: ${COMPONENTS_DIR}`));
console.log(chalk.yellow("Please ensure you are running this command from the root of your project and the path is correct."));
process.exit(1);
}
return exists;
}
// Get a list of all component names (files/dirs) in the components/ui directory
async function getAllComponents() {
await checkComponentsDir();
try {
const entries = await fs.readdir(COMPONENTS_DIR);
return entries
.map((entry) => entry.replace(".tsx", ""))
.filter((entry) => !entry.startsWith(".") && entry !== "index");
}
catch (error) {
console.error(chalk.red(`❌ Error reading components directory: ${COMPONENTS_DIR}`));
if (error instanceof Error) {
console.error(error.message);
}
else {
console.error(error);
}
process.exit(1);
}
}
// Get All Files rcrsv
async function getAllFiles() {
const filesToCheck = [];
const srcDir = path.join(process.cwd(), "src");
// spot `.tsx` files
const walkDir = async (dir) => {
const entries = await fs.readdir(dir);
for (const entry of entries) {
const fullPath = path.join(dir, entry);
const stat = await fs.stat(fullPath);
if (stat.isDirectory()) {
await walkDir(fullPath);
}
else if (fullPath.endsWith(".tsx") || fullPath.endsWith(".ts")) {
filesToCheck.push(fullPath);
}
}
};
await walkDir(srcDir);
return filesToCheck;
}
// Remove a specific component (handles both single .tsx file and directory)
async function removeComponent(componentName, dryRun = false) {
const componentFilePath = path.join(COMPONENTS_DIR, `${componentName}.tsx`);
const componentDirPath = path.join(COMPONENTS_DIR, componentName);
let removedPath = null;
let removedType = "";
if (await fs.pathExists(componentDirPath)) {
removedPath = componentDirPath;
removedType = "directory";
}
else if (await fs.pathExists(componentFilePath)) {
removedPath = componentFilePath;
removedType = "file";
}
if (removedPath) {
const relativePath = path.relative(process.cwd(), removedPath);
if (dryRun) {
console.log(chalk.yellow(`[Dry Run] Would remove ${removedType}: ${relativePath}`));
}
else {
try {
await fs.remove(removedPath);
console.log(chalk.green(`Removed ${removedType}: ${relativePath}`));
}
catch (error) {
console.error(chalk.red(`❌ Failed to remove ${removedType}: ${componentName}`));
if (error instanceof Error) {
console.error(error.message);
}
else {
console.error(error);
}
throw error;
}
}
}
else {
console.log(chalk.yellow(`❓ Component "${componentName}" not found (checked for ${componentName}.tsx and directory ${componentName})`));
// throwing an error if not found should be treated as failure
throw new Error(`Component not found: ${componentName}`);
}
}
// Simple confirmation prompt
async function confirm(message) {
const { confirmed } = await inquirer.prompt([
{
type: "confirm",
name: "confirmed",
message: message,
default: false,
},
]);
return confirmed;
}
// Helper function to read package.json version
async function getPackageVersion() {
// Construct the path from the compiled JS file in 'dist' up to the root 'package.json'
const packageJsonPath = path.join(__dirname, "..", "package.json");
try {
// Use fs-extra's readJson which parses the JSON automatically
const pkg = await fs.readJson(packageJsonPath);
return pkg.version || "unknown"; // Return version or fallback
}
catch (error) {
console.warn(chalk.yellow(`⚠️ Could not read version from ${packageJsonPath}.`));
// Optionally log the error for debugging:
console.error(error);
return "unknown"; // Fallback version
}
}
async function removeImportsFromFile(filePath, componentNames, dryRun) {
let content = await fs.readFile(filePath, "utf-8");
let newContent = content;
for (const componentName of componentNames) {
const importRegex = new RegExp(`import\\s+{[^}]*}\\s+from\\s+['"][^'"]*\\/components\\/ui\\/${componentName}['"];?`, "g");
newContent = newContent.replace(importRegex, "");
}
if (newContent !== content) {
const relativePath = path.relative(process.cwd(), filePath);
if (dryRun) {
console.log(chalk.yellow(`[Dry Run] Would remove imports from: ${relativePath}`));
}
else {
await fs.writeFile(filePath, newContent, "utf-8");
console.log(chalk.green(`Removed imports from: ${relativePath}`));
}
}
}
// --- Main Program Setup and Execution ---
(async () => {
try {
const version = await getPackageVersion(); // Fetch version dynamically
program
.name("shadcn-remover")
.description("Remove Shadcn UI components from your project")
.version(version) // Set version dynamically
.option("-d, --dry-run", "Show what would be removed without actually removing files", false)
.option("-a, --all", "Attempt to remove all detected Shadcn UI components", false)
.argument("[components...]", "Specific component names to remove (e.g., button card dialog)")
.action(async (components, options) => {
const { dryRun, all: removeAll } = options;
let componentsToDelete = components;
// Determine which components to target
if (removeAll) {
if (components.length > 0) {
console.log(chalk.yellow("Warning: Specific components provided along with --all flag. Ignoring specific components and removing all."));
}
componentsToDelete = await getAllComponents(); // Handles internal exit on error
if (componentsToDelete.length === 0) {
console.log(chalk.yellow("🟡 No components found in components/ui directory."));
process.exit(0);
}
console.log(chalk.blue(`ℹ️ Attempting to remove all ${componentsToDelete.length} detected components.`));
}
else if (componentsToDelete.length === 0) {
const allComponents = await getAllComponents(); // Handles internal exit on error
if (allComponents.length === 0) {
console.log(chalk.yellow("🟡 No components found to select from in components/ui directory."));
process.exit(0);
}
const answers = await inquirer.prompt([
{
type: "checkbox",
name: "selectedComponents",
message: "Select components to remove:",
choices: allComponents,
pageSize: 10,
},
]);
componentsToDelete = answers.selectedComponents;
}
// Exit if nothing is selected
if (componentsToDelete.length === 0) {
console.log(chalk.yellow("🟡 No components selected or specified for removal. Exiting."));
process.exit(0);
}
// Confirmation step
console.log(chalk.cyan(`Selected components: ${componentsToDelete.join(", ")}`));
const proceed = await confirm(dryRun
? `Dry run: Show removal actions for ${componentsToDelete.length} component(s)?`
: `⚠️ Are you sure you want to permanently remove ${componentsToDelete.length} component(s)?`);
if (!proceed) {
console.log(chalk.gray("Operation cancelled by user."));
process.exit(0);
}
// Execution step
const spinner = ora(dryRun ? "Simulating component removal..." : "Removing components...").start();
let successCount = 0;
let failCount = 0;
const results = await Promise.allSettled(componentsToDelete.map((component) => removeComponent(component, dryRun)));
results.forEach((result) => {
if (result.status === "fulfilled") {
// Check if the component was actually found and removed/simulated
// This requires removeComponent to potentially return a status or not log "not found" as success
// For now, we count any non-error as success.
successCount++;
}
else {
// Error was already logged inside removeComponent's catch block
failCount++;
// Optionally log more context
console.error(chalk.red(`Failed processing component: ${result.reason?.message || result.reason}`));
}
});
// rm imports
const filesToCheck = await getAllFiles();
for (const file of filesToCheck) {
await removeImportsFromFile(file, componentsToDelete, dryRun);
}
// Update spinner based on results
if (failCount > 0) {
spinner.warn(chalk.yellow(`Completed with ${failCount} error(s). ${successCount} component(s) processed.`));
}
else if (dryRun) {
spinner.succeed(chalk.green(`Dry run complete. ${successCount} component(s) simulated for removal.`));
}
else {
spinner.succeed(chalk.green(`Successfully removed ${successCount} component(s).`));
}
if (failCount > 0) {
process.exit(1); // Exit with error code if any component removal failed
}
}); // End of .action()
// Parse arguments ONLY inside the async IIFE after setup
await program.parseAsync(process.argv); // Use parseAsync for async actions
}
catch (error) {
// Catch any unexpected errors during setup/parsing
console.error(chalk.red("An unexpected error occurred:"), error);
process.exit(1);
}
})(); // End of async IIFE