wappler-cleanup-tool
Version:
Interactive CLI tool to identify and cleanup unused resources in Wappler projects
351 lines (301 loc) • 11.3 kB
JavaScript
const fs = require('fs-extra');
const path = require('path');
const glob = require('fast-glob');
const chalk = require('chalk');
const ora = require('ora');
const EmptyFolderDetector = require('./empty-folders');
class Scanner {
constructor(projectRoot) {
this.projectRoot = projectRoot;
this.serverActions = new Map();
this.references = new Map();
}
async scan() {
const spinner = ora('Discovering server actions...').start();
try {
// Step 1: Find all server action files
await this.discoverServerActions();
spinner.text = `Found ${this.serverActions.size} server actions. Scanning for references...`;
// Step 2: Find all references to these actions
await this.findReferences();
spinner.succeed(`Scan complete! Found ${this.serverActions.size} actions with ${this.references.size} reference patterns.`);
// Step 3: Find pre-existing empty folders
spinner.text = 'Scanning for pre-existing empty folders...';
const emptyFolderDetector = new EmptyFolderDetector(this.projectRoot);
const emptyFolders = await emptyFolderDetector.findEmptyFolders();
// Step 4: Analyze and categorize
const results = this.analyzeResults();
results.emptyFolders = emptyFolders;
return results;
} catch (error) {
spinner.fail('Scan failed');
throw error;
}
}
async discoverServerActions() {
const apiPattern = path.join(this.projectRoot, 'app/api/**/*.json');
const libPattern = path.join(this.projectRoot, 'app/lib/**/*.json');
const files = await glob([apiPattern, libPattern]);
for (const file of files) {
const relativePath = path.relative(this.projectRoot, file);
const urlPath = this.filePathToUrl(relativePath);
// Skip if it's clearly not a server action (no exec/steps)
try {
const content = await fs.readJson(file);
if (content.exec || content.steps) {
this.serverActions.set(urlPath, {
filePath: file,
relativePath,
urlPath,
content,
references: []
});
}
} catch (error) {
// Skip malformed JSON files
console.warn(chalk.yellow(`Warning: Could not parse ${relativePath}`));
}
}
}
filePathToUrl(filePath) {
// Convert app/api/v1/courses/create.json -> /api/v1/courses/create
if (filePath.startsWith('app/api/')) {
return '/' + filePath.replace('app/', '').replace('.json', '');
}
// Convert app/lib/security/check.json -> lib/security/check
if (filePath.startsWith('app/lib/')) {
return filePath.replace('app/', '').replace('.json', '');
}
return filePath.replace('.json', '');
}
async findReferences() {
// Scan different file types for references
await this.scanHtmlFiles();
await this.scanJsonFiles();
await this.scanJavaScriptFiles();
}
async scanHtmlFiles() {
const pattern = path.join(this.projectRoot, '{views,public}/**/*.{html,ejs}');
const files = await glob(pattern);
for (const file of files) {
const content = await fs.readFile(file, 'utf-8');
// Look for url="/api/..." patterns
const urlMatches = content.match(/url=["']([^"']+)["']/g);
if (urlMatches) {
for (const match of urlMatches) {
const url = match.match(/url=["']([^"']+)["']/)[1];
this.addReference(url, file, 'html-url');
}
}
// Look for action="/api/..." patterns
const actionMatches = content.match(/action=["']([^"']+)["']/g);
if (actionMatches) {
for (const match of actionMatches) {
const url = match.match(/action=["']([^"']+)["']/)[1];
this.addReference(url, file, 'html-action');
}
}
// Look for API URLs in href attributes and other URL patterns
const hrefMatches = content.match(/href=["']([^"']+)["']/g);
if (hrefMatches) {
for (const match of hrefMatches) {
const url = match.match(/href=["']([^"']+)["']/)[1];
if (url.includes('/api/')) {
this.scanForApiUrlPatterns(url, file);
}
}
}
// Scan entire content for API URL patterns (for magic links in emails, etc.)
this.scanForApiUrlPatterns(content, file);
}
}
async scanJsonFiles() {
// Scan all JSON files for exec/include references and api_file patterns
const pattern = path.join(this.projectRoot, 'app/**/*.json');
const files = await glob(pattern);
for (const file of files) {
try {
const content = await fs.readJson(file);
// Look for Bull queue api_file references
this.findInObject(content, 'api_file', (value) => {
this.addReference(value, file, 'queue-api-file');
});
// Look for exec references
this.findInObject(content, 'exec', (value) => {
if (typeof value === 'string') {
this.addReference(value, file, 'exec-string');
}
});
// Look for module references in steps
this.findInObject(content, 'module', (value) => {
if (typeof value === 'string') {
this.addReference(value, file, 'exec-module');
}
});
// Look for URL patterns in value strings (magic links, direct API calls)
this.findInObject(content, 'value', (value) => {
if (typeof value === 'string') {
this.scanForApiUrlPatterns(value, file);
}
});
// Also check other string fields that might contain URLs
this.findInObject(content, 'url', (value) => {
if (typeof value === 'string') {
this.scanForApiUrlPatterns(value, file);
}
});
this.findInObject(content, 'link', (value) => {
if (typeof value === 'string') {
this.scanForApiUrlPatterns(value, file);
}
});
} catch (error) {
// Skip malformed JSON
}
}
}
scanForApiUrlPatterns(str, sourceFile) {
// Look for patterns like '/api/v1/security/magic-login' in URL strings
// This catches magic links and other direct API endpoint references
// Pattern 1: Direct API paths in strings
const apiMatches = str.match(/\/api\/[^'"?\s&]+/g);
if (apiMatches) {
for (const match of apiMatches) {
// Clean up any query parameters or fragments
const cleanPath = match.split('?')[0].split('#')[0];
this.addReference(cleanPath, sourceFile, 'url-string');
}
}
// Pattern 2: Template string patterns with variables
// Example: '/api/v1/security/magic-login?token='+variable
const templateApiMatches = str.match(/['"`][^'"]*\/api\/[^'"?\s&]+[^'"]*['"`]/g);
if (templateApiMatches) {
for (const match of templateApiMatches) {
// Extract just the API path part
const apiMatch = match.match(/\/api\/[^'"?\s&]+/);
if (apiMatch) {
const cleanPath = apiMatch[0].split('?')[0].split('#')[0];
this.addReference(cleanPath, sourceFile, 'template-url');
}
}
}
}
async scanJavaScriptFiles() {
const pattern = path.join(this.projectRoot, '{public,views,extensions}/**/*.js');
const files = await glob(pattern);
for (const file of files) {
const content = await fs.readFile(file, 'utf-8');
// Look for fetch('/api/...') patterns
const fetchMatches = content.match(/fetch\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g);
if (fetchMatches) {
for (const match of fetchMatches) {
const url = match.match(/fetch\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/)[1];
if (url.startsWith('/api/')) {
this.addReference(url, file, 'js-fetch');
}
}
}
// Look for other AJAX patterns
const ajaxMatches = content.match(/url\s*:\s*['"`]([^'"`]+)['"`]/g);
if (ajaxMatches) {
for (const match of ajaxMatches) {
const url = match.match(/url\s*:\s*['"`]([^'"`]+)['"`]/)[1];
if (url.startsWith('/api/')) {
this.addReference(url, file, 'js-ajax');
}
}
}
}
}
findInObject(obj, key, callback) {
if (typeof obj !== 'object' || obj === null) return;
if (Array.isArray(obj)) {
for (const item of obj) {
this.findInObject(item, key, callback);
}
} else {
for (const [k, v] of Object.entries(obj)) {
if (k === key) {
callback(v);
} else {
this.findInObject(v, key, callback);
}
}
}
}
addReference(referencedPath, sourceFile, type) {
// Normalize the referenced path
let normalizedPath = referencedPath;
// Convert different path formats to match our server action keys
if (referencedPath.startsWith('/api/')) {
normalizedPath = referencedPath;
} else if (referencedPath.startsWith('/app/api/')) {
// Bull queue pattern: /app/api/v1/queues/... -> /api/v1/queues/...
normalizedPath = referencedPath.replace('/app/api/', '/api/');
} else if (!referencedPath.startsWith('/') && !referencedPath.startsWith('lib/')) {
// Handle relative lib references
normalizedPath = 'lib/' + referencedPath;
}
// Remove .json extension to match server action keys
if (normalizedPath.endsWith('.json')) {
normalizedPath = normalizedPath.replace('.json', '');
}
if (this.serverActions.has(normalizedPath)) {
this.serverActions.get(normalizedPath).references.push({
sourceFile: path.relative(this.projectRoot, sourceFile),
type,
originalReference: referencedPath
});
}
// Also store in references map for lookup
if (!this.references.has(normalizedPath)) {
this.references.set(normalizedPath, []);
}
this.references.get(normalizedPath).push({
sourceFile: path.relative(this.projectRoot, sourceFile),
type,
originalReference: referencedPath
});
}
analyzeResults() {
const results = {
summary: {
totalActions: this.serverActions.size,
used: 0,
possiblyUnused: 0,
likelyUnused: 0
},
actions: []
};
for (const [urlPath, action] of this.serverActions) {
const referenceCount = action.references.length;
let confidence, status;
// Simplified confidence system: only "safe to delete" vs "review needed"
if (referenceCount === 0) {
confidence = 'safe-to-delete';
status = 'unused';
results.summary.likelyUnused++;
} else {
confidence = 'review-needed';
status = 'used';
results.summary.used++;
}
results.actions.push({
urlPath,
filePath: action.relativePath,
status,
confidence,
referenceCount,
references: action.references,
content: action.content
});
}
// Sort by confidence (safe to delete first)
results.actions.sort((a, b) => {
const confidenceOrder = { 'safe-to-delete': 0, 'review-needed': 1 };
return confidenceOrder[a.confidence] - confidenceOrder[b.confidence];
});
return results;
}
}
module.exports = Scanner;