clean-my-mac-cli
Advanced tools
+925
-331
@@ -7,4 +7,3 @@ #!/usr/bin/env node | ||
| // src/commands/scan.ts | ||
| import chalk from "chalk"; | ||
| import ora from "ora"; | ||
| import chalk2 from "chalk"; | ||
@@ -111,2 +110,18 @@ // src/types.ts | ||
| safetyNote: "Review each file carefully before deleting" | ||
| }, | ||
| "node-modules": { | ||
| id: "node-modules", | ||
| name: "Node Modules", | ||
| group: "Development", | ||
| description: "Orphaned node_modules in old projects", | ||
| safetyLevel: "moderate", | ||
| safetyNote: "Projects will need npm install to restore" | ||
| }, | ||
| "duplicates": { | ||
| id: "duplicates", | ||
| name: "Duplicate Files", | ||
| group: "Storage", | ||
| description: "Files with identical content", | ||
| safetyLevel: "risky", | ||
| safetyNote: "Review carefully - keeps newest copy by default" | ||
| } | ||
@@ -325,2 +340,234 @@ }; | ||
| // src/utils/progress.ts | ||
| import chalk from "chalk"; | ||
| var ProgressBar = class { | ||
| current = 0; | ||
| total; | ||
| label; | ||
| showPercentage; | ||
| showCount; | ||
| barWidth; | ||
| startTime; | ||
| lastRender = ""; | ||
| constructor(options) { | ||
| this.total = options.total; | ||
| this.label = options.label ?? ""; | ||
| this.showPercentage = options.showPercentage ?? true; | ||
| this.showCount = options.showCount ?? true; | ||
| this.barWidth = options.barWidth ?? 30; | ||
| this.startTime = Date.now(); | ||
| } | ||
| update(current, label) { | ||
| this.current = current; | ||
| if (label) this.label = label; | ||
| this.render(); | ||
| } | ||
| increment(label) { | ||
| this.update(this.current + 1, label); | ||
| } | ||
| render() { | ||
| const percentage = Math.min(100, Math.round(this.current / this.total * 100)); | ||
| const filledWidth = Math.round(percentage / 100 * this.barWidth); | ||
| const emptyWidth = this.barWidth - filledWidth; | ||
| const filled = chalk.green("\u2588".repeat(filledWidth)); | ||
| const empty = chalk.dim("\u2591".repeat(emptyWidth)); | ||
| const bar = `${filled}${empty}`; | ||
| const parts = []; | ||
| if (this.label) { | ||
| parts.push(chalk.cyan(this.label.substring(0, 25).padEnd(25))); | ||
| } | ||
| parts.push(bar); | ||
| if (this.showPercentage) { | ||
| parts.push(chalk.yellow(`${percentage.toString().padStart(3)}%`)); | ||
| } | ||
| if (this.showCount) { | ||
| parts.push(chalk.dim(`(${this.current}/${this.total})`)); | ||
| } | ||
| const elapsed = Date.now() - this.startTime; | ||
| if (elapsed > 1e3 && this.current > 0) { | ||
| const eta = Math.round(elapsed / this.current * (this.total - this.current) / 1e3); | ||
| if (eta > 0) { | ||
| parts.push(chalk.dim(`~${eta}s`)); | ||
| } | ||
| } | ||
| const output = parts.join(" "); | ||
| if (output !== this.lastRender) { | ||
| process.stdout.write(`\r${output}`); | ||
| this.lastRender = output; | ||
| } | ||
| } | ||
| finish(message) { | ||
| process.stdout.write("\r" + " ".repeat(this.lastRender.length) + "\r"); | ||
| if (message) { | ||
| console.log(message); | ||
| } | ||
| } | ||
| }; | ||
| function createScanProgress(total) { | ||
| return new ProgressBar({ | ||
| total, | ||
| label: "Scanning...", | ||
| barWidth: 25 | ||
| }); | ||
| } | ||
| function createCleanProgress(total) { | ||
| return new ProgressBar({ | ||
| total, | ||
| label: "Cleaning...", | ||
| barWidth: 25 | ||
| }); | ||
| } | ||
| // src/utils/backup.ts | ||
| import { mkdir, rename, readdir as readdir2, stat as stat2, rm as rm2 } from "fs/promises"; | ||
| import { join as join3, dirname } from "path"; | ||
| import { homedir as homedir2 } from "os"; | ||
| var BACKUP_DIR = join3(homedir2(), ".clean-my-mac", "backup"); | ||
| var BACKUP_RETENTION_DAYS = 7; | ||
| async function cleanOldBackups() { | ||
| let cleaned = 0; | ||
| const now = Date.now(); | ||
| const maxAge = BACKUP_RETENTION_DAYS * 24 * 60 * 60 * 1e3; | ||
| try { | ||
| const entries = await readdir2(BACKUP_DIR); | ||
| for (const entry of entries) { | ||
| const entryPath = join3(BACKUP_DIR, entry); | ||
| try { | ||
| const stats = await stat2(entryPath); | ||
| if (stats.isDirectory() && now - stats.mtime.getTime() > maxAge) { | ||
| await rm2(entryPath, { recursive: true, force: true }); | ||
| cleaned++; | ||
| } | ||
| } catch { | ||
| continue; | ||
| } | ||
| } | ||
| } catch { | ||
| } | ||
| return cleaned; | ||
| } | ||
| async function listBackups() { | ||
| const backups = []; | ||
| try { | ||
| const entries = await readdir2(BACKUP_DIR); | ||
| for (const entry of entries) { | ||
| const entryPath = join3(BACKUP_DIR, entry); | ||
| try { | ||
| const stats = await stat2(entryPath); | ||
| if (stats.isDirectory()) { | ||
| const size = await getBackupSize(entryPath); | ||
| backups.push({ | ||
| path: entryPath, | ||
| date: stats.mtime, | ||
| size | ||
| }); | ||
| } | ||
| } catch { | ||
| continue; | ||
| } | ||
| } | ||
| } catch { | ||
| } | ||
| return backups.sort((a, b) => b.date.getTime() - a.date.getTime()); | ||
| } | ||
| async function getBackupSize(dir) { | ||
| let size = 0; | ||
| try { | ||
| const entries = await readdir2(dir, { withFileTypes: true }); | ||
| for (const entry of entries) { | ||
| const entryPath = join3(dir, entry.name); | ||
| try { | ||
| if (entry.isFile()) { | ||
| const stats = await stat2(entryPath); | ||
| size += stats.size; | ||
| } else if (entry.isDirectory()) { | ||
| size += await getBackupSize(entryPath); | ||
| } | ||
| } catch { | ||
| continue; | ||
| } | ||
| } | ||
| } catch { | ||
| } | ||
| return size; | ||
| } | ||
| // src/utils/config.ts | ||
| import { readFile, writeFile, access as access2 } from "fs/promises"; | ||
| import { join as join4 } from "path"; | ||
| import { homedir as homedir3 } from "os"; | ||
| var CONFIG_PATHS = [ | ||
| join4(homedir3(), ".cleanmymacrc"), | ||
| join4(homedir3(), ".config", "clean-my-mac", "config.json") | ||
| ]; | ||
| var DEFAULT_CONFIG = { | ||
| downloadsDaysOld: 30, | ||
| largeFilesMinSize: 500 * 1024 * 1024, | ||
| backupEnabled: false, | ||
| backupRetentionDays: 7, | ||
| parallelScans: true, | ||
| concurrency: 4 | ||
| }; | ||
| var cachedConfig = null; | ||
| async function loadConfig(configPath) { | ||
| if (cachedConfig && !configPath) { | ||
| return cachedConfig; | ||
| } | ||
| const paths = configPath ? [configPath] : CONFIG_PATHS; | ||
| for (const path of paths) { | ||
| try { | ||
| await access2(path); | ||
| const content = await readFile(path, "utf-8"); | ||
| const parsed = JSON.parse(content); | ||
| cachedConfig = { ...DEFAULT_CONFIG, ...parsed }; | ||
| return cachedConfig; | ||
| } catch { | ||
| continue; | ||
| } | ||
| } | ||
| cachedConfig = DEFAULT_CONFIG; | ||
| return cachedConfig; | ||
| } | ||
| async function configExists() { | ||
| for (const path of CONFIG_PATHS) { | ||
| try { | ||
| await access2(path); | ||
| return true; | ||
| } catch { | ||
| continue; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
| async function initConfig() { | ||
| const configPath = CONFIG_PATHS[0]; | ||
| const defaultConfig = { | ||
| downloadsDaysOld: 30, | ||
| largeFilesMinSize: 500 * 1024 * 1024, | ||
| backupEnabled: false, | ||
| backupRetentionDays: 7, | ||
| parallelScans: true, | ||
| concurrency: 4, | ||
| extraPaths: { | ||
| nodeModules: ["~/Projects", "~/Developer", "~/Code"], | ||
| projects: ["~/Projects", "~/Developer", "~/Code"] | ||
| } | ||
| }; | ||
| await writeFile(configPath, JSON.stringify(defaultConfig, null, 2)); | ||
| return configPath; | ||
| } | ||
| // src/utils/hash.ts | ||
| import { createHash } from "crypto"; | ||
| import { createReadStream } from "fs"; | ||
| async function getFileHash(filePath, algorithm = "md5") { | ||
| return new Promise((resolve, reject) => { | ||
| const hash = createHash(algorithm); | ||
| const stream = createReadStream(filePath); | ||
| stream.on("data", (data) => hash.update(data)); | ||
| stream.on("end", () => resolve(hash.digest("hex"))); | ||
| stream.on("error", reject); | ||
| }); | ||
| } | ||
| // src/scanners/system-cache.ts | ||
@@ -360,4 +607,4 @@ var SystemCacheScanner = class extends BaseScanner { | ||
| // src/scanners/temp-files.ts | ||
| import { readdir as readdir2 } from "fs/promises"; | ||
| import { join as join3 } from "path"; | ||
| import { readdir as readdir3 } from "fs/promises"; | ||
| import { join as join5 } from "path"; | ||
| var TempFilesScanner = class extends BaseScanner { | ||
@@ -383,9 +630,9 @@ category = CATEGORIES["temp-files"]; | ||
| try { | ||
| const level1 = await readdir2(PATHS.varFolders); | ||
| const level1 = await readdir3(PATHS.varFolders); | ||
| for (const dir1 of level1) { | ||
| const path1 = join3(PATHS.varFolders, dir1); | ||
| const path1 = join5(PATHS.varFolders, dir1); | ||
| try { | ||
| const level2 = await readdir2(path1); | ||
| const level2 = await readdir3(path1); | ||
| for (const dir2 of level2) { | ||
| const tempPath = join3(path1, dir2, "T"); | ||
| const tempPath = join5(path1, dir2, "T"); | ||
| if (await exists(tempPath)) { | ||
@@ -437,3 +684,3 @@ const tempItems = await getDirectoryItems(tempPath); | ||
| // src/scanners/browser-cache.ts | ||
| import { stat as stat2 } from "fs/promises"; | ||
| import { stat as stat3 } from "fs/promises"; | ||
| var BrowserCacheScanner = class extends BaseScanner { | ||
@@ -453,3 +700,3 @@ category = CATEGORIES["browser-cache"]; | ||
| const size = await getSize(browser.path); | ||
| const stats = await stat2(browser.path); | ||
| const stats = await stat3(browser.path); | ||
| items.push({ | ||
@@ -472,3 +719,3 @@ path: browser.path, | ||
| // src/scanners/dev-cache.ts | ||
| import { stat as stat3 } from "fs/promises"; | ||
| import { stat as stat4 } from "fs/promises"; | ||
| var DevCacheScanner = class extends BaseScanner { | ||
@@ -492,3 +739,3 @@ category = CATEGORIES["dev-cache"]; | ||
| if (size > 0) { | ||
| const stats = await stat3(dev.path); | ||
| const stats = await stat4(dev.path); | ||
| items.push({ | ||
@@ -520,3 +767,3 @@ path: dev.path, | ||
| if (size > 0) { | ||
| const stats = await stat3(PATHS.xcodeArchives); | ||
| const stats = await stat4(PATHS.xcodeArchives); | ||
| items.push({ | ||
@@ -540,3 +787,3 @@ path: PATHS.xcodeArchives, | ||
| import { promisify } from "util"; | ||
| import { stat as stat4 } from "fs/promises"; | ||
| import { stat as stat5 } from "fs/promises"; | ||
| var execAsync = promisify(exec); | ||
@@ -553,3 +800,3 @@ var HomebrewScanner = class extends BaseScanner { | ||
| if (size > 0) { | ||
| const stats = await stat4(brewCache); | ||
| const stats = await stat5(brewCache); | ||
| items.push({ | ||
@@ -695,4 +942,4 @@ path: brewCache, | ||
| // src/scanners/language-files.ts | ||
| import { readdir as readdir3, stat as stat5 } from "fs/promises"; | ||
| import { join as join4 } from "path"; | ||
| import { readdir as readdir4, stat as stat6 } from "fs/promises"; | ||
| import { join as join6 } from "path"; | ||
| var KEEP_LANGUAGES = ["en", "en_US", "en_GB", "pt", "pt_BR", "pt_PT", "Base"]; | ||
@@ -704,9 +951,9 @@ var LanguageFilesScanner = class extends BaseScanner { | ||
| if (await exists(PATHS.applications)) { | ||
| const apps = await readdir3(PATHS.applications); | ||
| const apps = await readdir4(PATHS.applications); | ||
| for (const app of apps) { | ||
| if (!app.endsWith(".app")) continue; | ||
| const resourcesPath = join4(PATHS.applications, app, "Contents", "Resources"); | ||
| const resourcesPath = join6(PATHS.applications, app, "Contents", "Resources"); | ||
| if (await exists(resourcesPath)) { | ||
| try { | ||
| const resources = await readdir3(resourcesPath); | ||
| const resources = await readdir4(resourcesPath); | ||
| const lprojDirs = resources.filter( | ||
@@ -716,6 +963,6 @@ (r) => r.endsWith(".lproj") && !KEEP_LANGUAGES.includes(r.replace(".lproj", "")) | ||
| for (const lproj of lprojDirs) { | ||
| const lprojPath = join4(resourcesPath, lproj); | ||
| const lprojPath = join6(resourcesPath, lproj); | ||
| try { | ||
| const size = await getSize(lprojPath); | ||
| const stats = await stat5(lprojPath); | ||
| const stats = await stat6(lprojPath); | ||
| items.push({ | ||
@@ -743,4 +990,4 @@ path: lprojPath, | ||
| // src/scanners/large-files.ts | ||
| import { readdir as readdir4, stat as stat6 } from "fs/promises"; | ||
| import { join as join5 } from "path"; | ||
| import { readdir as readdir5, stat as stat7 } from "fs/promises"; | ||
| import { join as join7 } from "path"; | ||
| var LargeFilesScanner = class extends BaseScanner { | ||
@@ -765,9 +1012,9 @@ category = CATEGORIES["large-files"]; | ||
| try { | ||
| const entries = await readdir4(dirPath, { withFileTypes: true }); | ||
| const entries = await readdir5(dirPath, { withFileTypes: true }); | ||
| for (const entry of entries) { | ||
| if (entry.name.startsWith(".")) continue; | ||
| const fullPath = join5(dirPath, entry.name); | ||
| const fullPath = join7(dirPath, entry.name); | ||
| try { | ||
| if (entry.isFile()) { | ||
| const stats = await stat6(fullPath); | ||
| const stats = await stat7(fullPath); | ||
| if (stats.size >= minSize) { | ||
@@ -796,2 +1043,185 @@ items.push({ | ||
| // src/scanners/node-modules.ts | ||
| import { readdir as readdir6, stat as stat8, access as access3 } from "fs/promises"; | ||
| import { join as join8 } from "path"; | ||
| import { homedir as homedir4 } from "os"; | ||
| var DEFAULT_SEARCH_PATHS = [ | ||
| join8(homedir4(), "Projects"), | ||
| join8(homedir4(), "Developer"), | ||
| join8(homedir4(), "Code"), | ||
| join8(homedir4(), "dev"), | ||
| join8(homedir4(), "workspace"), | ||
| join8(homedir4(), "repos") | ||
| ]; | ||
| var DEFAULT_DAYS_OLD = 30; | ||
| var NodeModulesScanner = class extends BaseScanner { | ||
| category = CATEGORIES["node-modules"]; | ||
| async scan(options) { | ||
| const items = []; | ||
| const daysOld = options?.daysOld ?? DEFAULT_DAYS_OLD; | ||
| for (const searchPath of DEFAULT_SEARCH_PATHS) { | ||
| if (await exists(searchPath)) { | ||
| const found = await this.findNodeModules(searchPath, daysOld, 4); | ||
| items.push(...found); | ||
| } | ||
| } | ||
| items.sort((a, b) => b.size - a.size); | ||
| return this.createResult(items); | ||
| } | ||
| async findNodeModules(dir, daysOld, maxDepth, currentDepth = 0) { | ||
| const items = []; | ||
| if (currentDepth > maxDepth) return items; | ||
| try { | ||
| const entries = await readdir6(dir, { withFileTypes: true }); | ||
| for (const entry of entries) { | ||
| if (!entry.isDirectory()) continue; | ||
| if (entry.name.startsWith(".")) continue; | ||
| const fullPath = join8(dir, entry.name); | ||
| if (entry.name === "node_modules") { | ||
| const parentDir = dir; | ||
| const packageJsonPath = join8(parentDir, "package.json"); | ||
| try { | ||
| await access3(packageJsonPath); | ||
| const parentStats = await stat8(parentDir); | ||
| const daysSinceModified = (Date.now() - parentStats.mtime.getTime()) / (1e3 * 60 * 60 * 24); | ||
| if (daysSinceModified >= daysOld) { | ||
| const size = await getSize(fullPath); | ||
| const stats = await stat8(fullPath); | ||
| items.push({ | ||
| path: fullPath, | ||
| size, | ||
| name: `${this.getProjectName(parentDir)} (${Math.floor(daysSinceModified)}d old)`, | ||
| isDirectory: true, | ||
| modifiedAt: stats.mtime | ||
| }); | ||
| } | ||
| } catch { | ||
| const size = await getSize(fullPath); | ||
| if (size > 0) { | ||
| const stats = await stat8(fullPath); | ||
| items.push({ | ||
| path: fullPath, | ||
| size, | ||
| name: `${this.getProjectName(parentDir)} (orphaned)`, | ||
| isDirectory: true, | ||
| modifiedAt: stats.mtime | ||
| }); | ||
| } | ||
| } | ||
| } else { | ||
| const subItems = await this.findNodeModules(fullPath, daysOld, maxDepth, currentDepth + 1); | ||
| items.push(...subItems); | ||
| } | ||
| } | ||
| } catch { | ||
| } | ||
| return items; | ||
| } | ||
| getProjectName(projectPath) { | ||
| const parts = projectPath.split("/"); | ||
| return parts[parts.length - 1] || projectPath; | ||
| } | ||
| }; | ||
| // src/scanners/duplicates.ts | ||
| import { readdir as readdir7, stat as stat9 } from "fs/promises"; | ||
| import { join as join9 } from "path"; | ||
| import { homedir as homedir5 } from "os"; | ||
| var DEFAULT_SEARCH_PATHS2 = [ | ||
| join9(homedir5(), "Downloads"), | ||
| join9(homedir5(), "Documents"), | ||
| join9(homedir5(), "Desktop") | ||
| ]; | ||
| var MIN_FILE_SIZE = 1024 * 1024; | ||
| var MAX_DEPTH = 5; | ||
| var DuplicatesScanner = class extends BaseScanner { | ||
| category = CATEGORIES["duplicates"]; | ||
| async scan(options) { | ||
| const minSize = options?.minSize ?? MIN_FILE_SIZE; | ||
| const filesBySize = /* @__PURE__ */ new Map(); | ||
| for (const searchPath of DEFAULT_SEARCH_PATHS2) { | ||
| if (await exists(searchPath)) { | ||
| await this.collectFiles(searchPath, filesBySize, minSize, MAX_DEPTH); | ||
| } | ||
| } | ||
| const duplicates = await this.findDuplicates(filesBySize); | ||
| const items = this.convertToCleanableItems(duplicates); | ||
| return this.createResult(items); | ||
| } | ||
| async collectFiles(dir, filesBySize, minSize, maxDepth, currentDepth = 0) { | ||
| if (currentDepth > maxDepth) return; | ||
| try { | ||
| const entries = await readdir7(dir, { withFileTypes: true }); | ||
| for (const entry of entries) { | ||
| if (entry.name.startsWith(".")) continue; | ||
| const fullPath = join9(dir, entry.name); | ||
| try { | ||
| if (entry.isFile()) { | ||
| const stats = await stat9(fullPath); | ||
| if (stats.size >= minSize) { | ||
| const files = filesBySize.get(stats.size) ?? []; | ||
| files.push({ | ||
| path: fullPath, | ||
| size: stats.size, | ||
| modifiedAt: stats.mtime | ||
| }); | ||
| filesBySize.set(stats.size, files); | ||
| } | ||
| } else if (entry.isDirectory()) { | ||
| await this.collectFiles(fullPath, filesBySize, minSize, maxDepth, currentDepth + 1); | ||
| } | ||
| } catch { | ||
| continue; | ||
| } | ||
| } | ||
| } catch { | ||
| } | ||
| } | ||
| async findDuplicates(filesBySize) { | ||
| const duplicates = /* @__PURE__ */ new Map(); | ||
| for (const [, files] of filesBySize) { | ||
| if (files.length < 2) continue; | ||
| const filesByHash = /* @__PURE__ */ new Map(); | ||
| for (const file of files) { | ||
| try { | ||
| const hash = await getFileHash(file.path); | ||
| const hashFiles = filesByHash.get(hash) ?? []; | ||
| hashFiles.push(file); | ||
| filesByHash.set(hash, hashFiles); | ||
| } catch { | ||
| continue; | ||
| } | ||
| } | ||
| for (const [hash, hashFiles] of filesByHash) { | ||
| if (hashFiles.length >= 2) { | ||
| duplicates.set(hash, hashFiles); | ||
| } | ||
| } | ||
| } | ||
| return duplicates; | ||
| } | ||
| convertToCleanableItems(duplicates) { | ||
| const items = []; | ||
| for (const [, files] of duplicates) { | ||
| files.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime()); | ||
| const [newest, ...older] = files; | ||
| for (const file of older) { | ||
| items.push({ | ||
| path: file.path, | ||
| size: file.size, | ||
| name: `${this.getFileName(file.path)} (dup of ${this.getFileName(newest.path)})`, | ||
| isDirectory: false, | ||
| modifiedAt: file.modifiedAt | ||
| }); | ||
| } | ||
| } | ||
| items.sort((a, b) => b.size - a.size); | ||
| return items; | ||
| } | ||
| getFileName(path) { | ||
| const parts = path.split("/"); | ||
| return parts[parts.length - 1] || path; | ||
| } | ||
| }; | ||
| // src/scanners/index.ts | ||
@@ -811,3 +1241,5 @@ var ALL_SCANNERS = { | ||
| "language-files": new LanguageFilesScanner(), | ||
| "large-files": new LargeFilesScanner() | ||
| "large-files": new LargeFilesScanner(), | ||
| "node-modules": new NodeModulesScanner(), | ||
| "duplicates": new DuplicatesScanner() | ||
| }; | ||
@@ -820,33 +1252,53 @@ function getScanner(categoryId) { | ||
| } | ||
| async function runAllScans(options, onProgress) { | ||
| async function runWithConcurrency(tasks, concurrency) { | ||
| const results = []; | ||
| const scanners = getAllScanners(); | ||
| for (const scanner of scanners) { | ||
| const result = await scanner.scan(options); | ||
| results.push(result); | ||
| onProgress?.(scanner, result); | ||
| const executing = []; | ||
| for (const task of tasks) { | ||
| const p = task().then((result) => { | ||
| results.push(result); | ||
| }); | ||
| executing.push(p); | ||
| if (executing.length >= concurrency) { | ||
| await Promise.race(executing); | ||
| executing.splice( | ||
| executing.findIndex((e) => e === p), | ||
| 1 | ||
| ); | ||
| } | ||
| } | ||
| const totalSize = results.reduce((sum, r) => sum + r.totalSize, 0); | ||
| const totalItems = results.reduce((sum, r) => sum + r.items.length, 0); | ||
| return { | ||
| results, | ||
| totalSize, | ||
| totalItems | ||
| }; | ||
| await Promise.all(executing); | ||
| return results; | ||
| } | ||
| async function runScans(categoryIds, options, onProgress) { | ||
| const results = []; | ||
| for (const categoryId of categoryIds) { | ||
| const scanner = getScanner(categoryId); | ||
| const result = await scanner.scan(options); | ||
| results.push(result); | ||
| onProgress?.(scanner, result); | ||
| async function runAllScans(options, onProgress) { | ||
| const scanners = getAllScanners(); | ||
| const parallel = options?.parallel ?? true; | ||
| const concurrency = options?.concurrency ?? 4; | ||
| let completed = 0; | ||
| const total = scanners.length; | ||
| if (parallel) { | ||
| const tasks = scanners.map((scanner) => async () => { | ||
| const result = await scanner.scan(options); | ||
| completed++; | ||
| options?.onProgress?.(completed, total, scanner, result); | ||
| onProgress?.(scanner, result); | ||
| return { scanner, result }; | ||
| }); | ||
| const scanResults = await runWithConcurrency(tasks, concurrency); | ||
| const results = scanResults.map((r) => r.result); | ||
| const totalSize = results.reduce((sum, r) => sum + r.totalSize, 0); | ||
| const totalItems = results.reduce((sum, r) => sum + r.items.length, 0); | ||
| return { results, totalSize, totalItems }; | ||
| } else { | ||
| const results = []; | ||
| for (const scanner of scanners) { | ||
| const result = await scanner.scan(options); | ||
| results.push(result); | ||
| completed++; | ||
| options?.onProgress?.(completed, total, scanner, result); | ||
| onProgress?.(scanner, result); | ||
| } | ||
| const totalSize = results.reduce((sum, r) => sum + r.totalSize, 0); | ||
| const totalItems = results.reduce((sum, r) => sum + r.items.length, 0); | ||
| return { results, totalSize, totalItems }; | ||
| } | ||
| const totalSize = results.reduce((sum, r) => sum + r.totalSize, 0); | ||
| const totalItems = results.reduce((sum, r) => sum + r.items.length, 0); | ||
| return { | ||
| results, | ||
| totalSize, | ||
| totalItems | ||
| }; | ||
| } | ||
@@ -856,74 +1308,10 @@ | ||
| var SAFETY_ICONS = { | ||
| safe: chalk.green("\u25CF"), | ||
| moderate: chalk.yellow("\u25CF"), | ||
| risky: chalk.red("\u25CF") | ||
| safe: chalk2.green("\u25CF"), | ||
| moderate: chalk2.yellow("\u25CF"), | ||
| risky: chalk2.red("\u25CF") | ||
| }; | ||
| async function scanCommand(options) { | ||
| const spinner = ora("Scanning your Mac...").start(); | ||
| let summary; | ||
| if (options.category) { | ||
| summary = await runScans([options.category], { verbose: options.verbose }); | ||
| } else { | ||
| summary = await runAllScans({ verbose: options.verbose }); | ||
| } | ||
| spinner.stop(); | ||
| printScanResults(summary, options.verbose); | ||
| return summary; | ||
| } | ||
| function printScanResults(summary, verbose = false) { | ||
| console.log(); | ||
| console.log(chalk.bold("Scan Results")); | ||
| console.log(chalk.dim("\u2500".repeat(60))); | ||
| const groupedResults = groupResultsByCategory(summary.results); | ||
| for (const [group, results] of Object.entries(groupedResults)) { | ||
| const groupTotal = results.reduce((sum, r) => sum + r.totalSize, 0); | ||
| if (groupTotal === 0) continue; | ||
| console.log(); | ||
| console.log(chalk.bold.cyan(group)); | ||
| for (const result of results) { | ||
| if (result.totalSize === 0) continue; | ||
| const sizeStr = formatSize(result.totalSize); | ||
| const itemCount = result.items.length; | ||
| const safetyIcon = SAFETY_ICONS[result.category.safetyLevel]; | ||
| console.log( | ||
| ` ${safetyIcon} ${result.category.name.padEnd(28)} ${chalk.yellow(sizeStr.padStart(10))} ${chalk.dim(`(${itemCount} items)`)}` | ||
| ); | ||
| if (verbose && result.items.length > 0) { | ||
| const topItems = result.items.sort((a, b) => b.size - a.size).slice(0, 5); | ||
| for (const item of topItems) { | ||
| console.log(chalk.dim(` \u2514\u2500 ${item.name.padEnd(24)} ${formatSize(item.size).padStart(10)}`)); | ||
| } | ||
| if (result.items.length > 5) { | ||
| console.log(chalk.dim(` \u2514\u2500 ... and ${result.items.length - 5} more`)); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| console.log(); | ||
| console.log(chalk.dim("\u2500".repeat(60))); | ||
| console.log( | ||
| chalk.bold(`Total: ${chalk.green(formatSize(summary.totalSize))} can be cleaned (${summary.totalItems} items)`) | ||
| ); | ||
| console.log(); | ||
| console.log(chalk.dim("Safety: ") + `${SAFETY_ICONS.safe} safe ${SAFETY_ICONS.moderate} moderate ${SAFETY_ICONS.risky} risky (use --unsafe)`); | ||
| console.log(); | ||
| } | ||
| function groupResultsByCategory(results) { | ||
| const groups = { | ||
| "System Junk": [], | ||
| "Development": [], | ||
| "Storage": [], | ||
| "Browsers": [], | ||
| "Large Files": [] | ||
| }; | ||
| for (const result of results) { | ||
| const group = result.category.group; | ||
| groups[group].push(result); | ||
| } | ||
| return groups; | ||
| } | ||
| function listCategories() { | ||
| console.log(); | ||
| console.log(chalk.bold("Available Categories")); | ||
| console.log(chalk.dim("\u2500".repeat(70))); | ||
| console.log(chalk2.bold("Available Categories")); | ||
| console.log(chalk2.dim("\u2500".repeat(70))); | ||
| const groupedCategories = { | ||
@@ -941,8 +1329,8 @@ "System Junk": [], | ||
| console.log(); | ||
| console.log(chalk.bold.cyan(group)); | ||
| console.log(chalk2.bold.cyan(group)); | ||
| for (const category of categories) { | ||
| const safetyIcon = SAFETY_ICONS[category.safetyLevel]; | ||
| console.log(` ${safetyIcon} ${chalk.yellow(category.id.padEnd(18))} ${chalk.dim(category.description)}`); | ||
| console.log(` ${safetyIcon} ${chalk2.yellow(category.id.padEnd(18))} ${chalk2.dim(category.description)}`); | ||
| if (category.safetyNote) { | ||
| console.log(` ${chalk.dim.italic(`\u26A0 ${category.safetyNote}`)}`); | ||
| console.log(` ${chalk2.dim.italic(`\u26A0 ${category.safetyNote}`)}`); | ||
| } | ||
@@ -952,3 +1340,3 @@ } | ||
| console.log(); | ||
| console.log(chalk.dim("Safety: ") + `${SAFETY_ICONS.safe} safe ${SAFETY_ICONS.moderate} moderate ${SAFETY_ICONS.risky} risky (requires --unsafe)`); | ||
| console.log(chalk2.dim("Safety: ") + `${SAFETY_ICONS.safe} safe ${SAFETY_ICONS.moderate} moderate ${SAFETY_ICONS.risky} risky (requires --unsafe)`); | ||
| console.log(); | ||
@@ -958,16 +1346,301 @@ } | ||
| // src/commands/clean.ts | ||
| import chalk2 from "chalk"; | ||
| import ora2 from "ora"; | ||
| import inquirer from "inquirer"; | ||
| import chalk3 from "chalk"; | ||
| import { confirm, checkbox } from "@inquirer/prompts"; | ||
| var SAFETY_ICONS2 = { | ||
| safe: chalk2.green("\u25CF"), | ||
| moderate: chalk2.yellow("\u25CF"), | ||
| risky: chalk2.red("\u25CF") | ||
| safe: chalk3.green("\u25CF"), | ||
| moderate: chalk3.yellow("\u25CF"), | ||
| risky: chalk3.red("\u25CF") | ||
| }; | ||
| async function cleanCommand(options) { | ||
| const spinner = ora2("Scanning your Mac...").start(); | ||
| const summary = await runAllScans(); | ||
| spinner.stop(); | ||
| // src/commands/maintenance.ts | ||
| import chalk4 from "chalk"; | ||
| import ora from "ora"; | ||
| // src/maintenance/dns-cache.ts | ||
| import { exec as exec3 } from "child_process"; | ||
| import { promisify as promisify3 } from "util"; | ||
| var execAsync3 = promisify3(exec3); | ||
| async function flushDnsCache() { | ||
| try { | ||
| await execAsync3("sudo dscacheutil -flushcache"); | ||
| await execAsync3("sudo killall -HUP mDNSResponder"); | ||
| return { | ||
| success: true, | ||
| message: "DNS cache flushed successfully" | ||
| }; | ||
| } catch (error) { | ||
| return { | ||
| success: false, | ||
| message: "Failed to flush DNS cache", | ||
| error: error instanceof Error ? error.message : String(error) | ||
| }; | ||
| } | ||
| } | ||
| // src/maintenance/purgeable.ts | ||
| import { exec as exec4 } from "child_process"; | ||
| import { promisify as promisify4 } from "util"; | ||
| var execAsync4 = promisify4(exec4); | ||
| async function freePurgeableSpace() { | ||
| try { | ||
| await execAsync4("purge"); | ||
| return { | ||
| success: true, | ||
| message: "Purgeable space freed successfully" | ||
| }; | ||
| } catch (error) { | ||
| return { | ||
| success: false, | ||
| message: "Failed to free purgeable space", | ||
| error: error instanceof Error ? error.message : String(error) | ||
| }; | ||
| } | ||
| } | ||
| // src/commands/maintenance.ts | ||
| async function maintenanceCommand(options) { | ||
| const tasks = []; | ||
| if (options.dns) { | ||
| tasks.push({ name: "Flush DNS Cache", fn: flushDnsCache }); | ||
| } | ||
| if (options.purgeable) { | ||
| tasks.push({ name: "Free Purgeable Space", fn: freePurgeableSpace }); | ||
| } | ||
| if (tasks.length === 0) { | ||
| console.log(chalk4.yellow("\nNo maintenance tasks specified.")); | ||
| console.log(chalk4.dim("Use --dns to flush DNS cache or --purgeable to free purgeable space.\n")); | ||
| return; | ||
| } | ||
| console.log(); | ||
| console.log(chalk4.bold("Running Maintenance Tasks")); | ||
| console.log(chalk4.dim("\u2500".repeat(50))); | ||
| for (const task of tasks) { | ||
| const spinner = ora(task.name).start(); | ||
| const result = await task.fn(); | ||
| if (result.success) { | ||
| spinner.succeed(chalk4.green(result.message)); | ||
| } else { | ||
| spinner.fail(chalk4.red(result.message)); | ||
| if (result.error) { | ||
| console.log(chalk4.dim(` ${result.error}`)); | ||
| } | ||
| } | ||
| } | ||
| console.log(); | ||
| } | ||
| // src/commands/uninstall.ts | ||
| import chalk5 from "chalk"; | ||
| import { confirm as confirm2, checkbox as checkbox2 } from "@inquirer/prompts"; | ||
| import { readdir as readdir8, stat as stat10, rm as rm3 } from "fs/promises"; | ||
| import { join as join10, basename } from "path"; | ||
| import { homedir as homedir6 } from "os"; | ||
| var APPLICATIONS_DIR = "/Applications"; | ||
| var USER_APPLICATIONS_DIR = join10(homedir6(), "Applications"); | ||
| var RELATED_PATHS_TEMPLATES = [ | ||
| join10(homedir6(), "Library", "Application Support", "{APP_NAME}"), | ||
| join10(homedir6(), "Library", "Preferences", "{BUNDLE_ID}.plist"), | ||
| join10(homedir6(), "Library", "Preferences", "{APP_NAME}.plist"), | ||
| join10(homedir6(), "Library", "Caches", "{APP_NAME}"), | ||
| join10(homedir6(), "Library", "Caches", "{BUNDLE_ID}"), | ||
| join10(homedir6(), "Library", "Logs", "{APP_NAME}"), | ||
| join10(homedir6(), "Library", "Saved Application State", "{BUNDLE_ID}.savedState"), | ||
| join10(homedir6(), "Library", "WebKit", "{APP_NAME}"), | ||
| join10(homedir6(), "Library", "HTTPStorages", "{BUNDLE_ID}"), | ||
| join10(homedir6(), "Library", "Containers", "{BUNDLE_ID}"), | ||
| join10(homedir6(), "Library", "Group Containers", "*.{APP_NAME}") | ||
| ]; | ||
| async function uninstallCommand(options) { | ||
| console.log(chalk5.cyan("\nScanning installed applications...\n")); | ||
| const apps = await getInstalledApps(); | ||
| if (apps.length === 0) { | ||
| console.log(chalk5.yellow("No applications found.\n")); | ||
| return; | ||
| } | ||
| const choices = apps.map((app) => ({ | ||
| name: `${app.name.padEnd(35)} ${chalk5.yellow(formatSize(app.totalSize).padStart(10))} ${chalk5.dim(`(+${app.relatedPaths.length} related)`)}`, | ||
| value: app.name, | ||
| checked: false | ||
| })); | ||
| const selectedApps = await checkbox2({ | ||
| message: "Select applications to uninstall:", | ||
| choices, | ||
| pageSize: 15 | ||
| }); | ||
| if (selectedApps.length === 0) { | ||
| console.log(chalk5.yellow("\nNo applications selected.\n")); | ||
| return; | ||
| } | ||
| const appsToRemove = apps.filter((a) => selectedApps.includes(a.name)); | ||
| const totalSize = appsToRemove.reduce((sum, a) => sum + a.totalSize, 0); | ||
| const totalPaths = appsToRemove.reduce((sum, a) => sum + 1 + a.relatedPaths.length, 0); | ||
| console.log(); | ||
| console.log(chalk5.bold("Applications to uninstall:")); | ||
| for (const app of appsToRemove) { | ||
| console.log(` ${chalk5.red("\u2717")} ${app.name} (${formatSize(app.totalSize)})`); | ||
| for (const related of app.relatedPaths) { | ||
| console.log(chalk5.dim(` \u2514\u2500 ${related.replace(homedir6(), "~")}`)); | ||
| } | ||
| } | ||
| console.log(); | ||
| console.log(chalk5.bold(`Total: ${formatSize(totalSize)} will be freed (${totalPaths} items)`)); | ||
| console.log(); | ||
| if (options.dryRun) { | ||
| console.log(chalk5.cyan("[DRY RUN] Would uninstall the above applications.\n")); | ||
| return; | ||
| } | ||
| if (!options.yes) { | ||
| const proceed = await confirm2({ | ||
| message: "Proceed with uninstallation?", | ||
| default: false | ||
| }); | ||
| if (!proceed) { | ||
| console.log(chalk5.yellow("\nUninstallation cancelled.\n")); | ||
| return; | ||
| } | ||
| } | ||
| const showProgress = !options.noProgress && process.stdout.isTTY; | ||
| const progress = showProgress ? createCleanProgress(appsToRemove.length) : null; | ||
| let uninstalledCount = 0; | ||
| let freedSpace = 0; | ||
| const errors = []; | ||
| for (let i = 0; i < appsToRemove.length; i++) { | ||
| const app = appsToRemove[i]; | ||
| progress?.update(i, `Uninstalling ${app.name}...`); | ||
| try { | ||
| await rm3(app.path, { recursive: true, force: true }); | ||
| freedSpace += app.size; | ||
| for (const relatedPath of app.relatedPaths) { | ||
| try { | ||
| await rm3(relatedPath, { recursive: true, force: true }); | ||
| const size = await getSize(relatedPath).catch(() => 0); | ||
| freedSpace += size; | ||
| } catch { | ||
| } | ||
| } | ||
| uninstalledCount++; | ||
| } catch (error) { | ||
| errors.push(`${app.name}: ${error instanceof Error ? error.message : "Unknown error"}`); | ||
| } | ||
| } | ||
| progress?.finish(); | ||
| console.log(); | ||
| console.log(chalk5.bold.green("\u2713 Uninstallation Complete")); | ||
| console.log(chalk5.dim("\u2500".repeat(50))); | ||
| console.log(` Apps uninstalled: ${uninstalledCount}`); | ||
| console.log(` Space freed: ${chalk5.green(formatSize(freedSpace))}`); | ||
| if (errors.length > 0) { | ||
| console.log(); | ||
| console.log(chalk5.red("Errors:")); | ||
| for (const error of errors) { | ||
| console.log(chalk5.red(` \u2717 ${error}`)); | ||
| } | ||
| } | ||
| console.log(); | ||
| } | ||
| async function getInstalledApps() { | ||
| const apps = []; | ||
| for (const appDir of [APPLICATIONS_DIR, USER_APPLICATIONS_DIR]) { | ||
| if (!await exists(appDir)) continue; | ||
| try { | ||
| const entries = await readdir8(appDir); | ||
| for (const entry of entries) { | ||
| if (!entry.endsWith(".app")) continue; | ||
| const appPath = join10(appDir, entry); | ||
| const appName = basename(entry, ".app"); | ||
| try { | ||
| const stats = await stat10(appPath); | ||
| if (!stats.isDirectory()) continue; | ||
| const appSize = await getSize(appPath); | ||
| const relatedPaths = await findRelatedPaths(appName); | ||
| let totalRelatedSize = 0; | ||
| for (const path of relatedPaths) { | ||
| totalRelatedSize += await getSize(path).catch(() => 0); | ||
| } | ||
| apps.push({ | ||
| name: appName, | ||
| path: appPath, | ||
| size: appSize, | ||
| relatedPaths, | ||
| totalSize: appSize + totalRelatedSize | ||
| }); | ||
| } catch { | ||
| continue; | ||
| } | ||
| } | ||
| } catch { | ||
| continue; | ||
| } | ||
| } | ||
| return apps.sort((a, b) => b.totalSize - a.totalSize); | ||
| } | ||
| async function findRelatedPaths(appName) { | ||
| const paths = []; | ||
| const bundleId = appName.toLowerCase().replace(/\s+/g, "."); | ||
| for (const template of RELATED_PATHS_TEMPLATES) { | ||
| const variations = [ | ||
| template.replace("{APP_NAME}", appName).replace("{BUNDLE_ID}", bundleId), | ||
| template.replace("{APP_NAME}", appName.toLowerCase()).replace("{BUNDLE_ID}", bundleId), | ||
| template.replace("{APP_NAME}", appName.replace(/\s+/g, "")).replace("{BUNDLE_ID}", bundleId) | ||
| ]; | ||
| for (const path of variations) { | ||
| if (path.includes("*")) { | ||
| const dir = path.substring(0, path.lastIndexOf("/")); | ||
| const pattern = path.substring(path.lastIndexOf("/") + 1); | ||
| if (await exists(dir)) { | ||
| try { | ||
| const entries = await readdir8(dir); | ||
| for (const entry of entries) { | ||
| if (matchPattern(entry, pattern)) { | ||
| const fullPath = join10(dir, entry); | ||
| if (!paths.includes(fullPath)) { | ||
| paths.push(fullPath); | ||
| } | ||
| } | ||
| } | ||
| } catch { | ||
| continue; | ||
| } | ||
| } | ||
| } else if (await exists(path)) { | ||
| if (!paths.includes(path)) { | ||
| paths.push(path); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return paths; | ||
| } | ||
| function matchPattern(str, pattern) { | ||
| const regexPattern = pattern.replace(/\*/g, ".*").replace(/\./g, "\\."); | ||
| return new RegExp(`^${regexPattern}$`, "i").test(str); | ||
| } | ||
| // src/commands/interactive.ts | ||
| import chalk6 from "chalk"; | ||
| import { confirm as confirm3, checkbox as checkbox3 } from "@inquirer/prompts"; | ||
| var SAFETY_ICONS3 = { | ||
| safe: chalk6.green("\u25CF"), | ||
| moderate: chalk6.yellow("\u25CF"), | ||
| risky: chalk6.red("\u25CF") | ||
| }; | ||
| async function interactiveCommand(options = {}) { | ||
| console.log(); | ||
| console.log(chalk6.bold.cyan("\u{1F9F9} Clean My Mac")); | ||
| console.log(chalk6.dim("\u2500".repeat(50))); | ||
| console.log(); | ||
| const showProgress = !options.noProgress && process.stdout.isTTY; | ||
| const scanners = getAllScanners(); | ||
| const scanProgress = showProgress ? createScanProgress(scanners.length) : null; | ||
| console.log(chalk6.cyan("Scanning your Mac for cleanable files...\n")); | ||
| const summary = await runAllScans({ | ||
| parallel: true, | ||
| concurrency: 4, | ||
| onProgress: (completed, _total, scanner) => { | ||
| scanProgress?.update(completed, `Scanning ${scanner.category.name}...`); | ||
| } | ||
| }); | ||
| scanProgress?.finish(); | ||
| if (summary.totalSize === 0) { | ||
| console.log(chalk2.green("\n\u2713 Your Mac is already clean!\n")); | ||
| console.log(chalk6.green("\u2713 Your Mac is already clean! Nothing to remove.\n")); | ||
| return null; | ||
@@ -978,30 +1651,23 @@ } | ||
| const safeResults = resultsWithItems.filter((r) => r.category.safetyLevel !== "risky"); | ||
| if (!options.unsafe && riskyResults.length > 0) { | ||
| if (!options.includeRisky && riskyResults.length > 0) { | ||
| const riskySize = riskyResults.reduce((sum, r) => sum + r.totalSize, 0); | ||
| console.log(); | ||
| console.log(chalk2.yellow("\u26A0 Skipping risky categories (use --unsafe to include):")); | ||
| console.log(chalk6.yellow("\u26A0 Hiding risky categories:")); | ||
| for (const result of riskyResults) { | ||
| console.log(chalk2.dim(` ${SAFETY_ICONS2.risky} ${result.category.name}: ${formatSize(result.totalSize)}`)); | ||
| if (result.category.safetyNote) { | ||
| console.log(chalk2.dim.italic(` ${result.category.safetyNote}`)); | ||
| } | ||
| console.log(chalk6.dim(` ${SAFETY_ICONS3.risky} ${result.category.name}: ${formatSize(result.totalSize)}`)); | ||
| } | ||
| console.log(chalk2.dim(` Total skipped: ${formatSize(riskySize)}`)); | ||
| console.log(chalk6.dim(` Total hidden: ${formatSize(riskySize)}`)); | ||
| console.log(chalk6.dim(" Run with --risky to include these categories")); | ||
| resultsWithItems = safeResults; | ||
| } | ||
| if (resultsWithItems.length === 0) { | ||
| console.log(chalk2.green("\n\u2713 Nothing safe to clean!\n")); | ||
| console.log(chalk6.green("\n\u2713 Nothing safe to clean!\n")); | ||
| return null; | ||
| } | ||
| let selectedItems = []; | ||
| if (options.all) { | ||
| selectedItems = resultsWithItems.map((r) => ({ | ||
| categoryId: r.category.id, | ||
| items: r.items | ||
| })); | ||
| } else { | ||
| selectedItems = await selectItemsInteractively(resultsWithItems, options.unsafe); | ||
| } | ||
| console.log(); | ||
| console.log(chalk6.bold(`Found ${chalk6.green(formatSize(summary.totalSize))} that can be cleaned:`)); | ||
| console.log(); | ||
| const selectedItems = await selectItemsInteractively(resultsWithItems, options.includeRisky); | ||
| if (selectedItems.length === 0) { | ||
| console.log(chalk2.yellow("\nNo items selected for cleaning.\n")); | ||
| console.log(chalk6.yellow("\nNo items selected. Nothing to clean.\n")); | ||
| return null; | ||
@@ -1011,29 +1677,16 @@ } | ||
| const totalItems = selectedItems.reduce((sum, s) => sum + s.items.length, 0); | ||
| if (!options.yes && !options.dryRun) { | ||
| const confirm = await inquirer.prompt([ | ||
| { | ||
| type: "confirm", | ||
| name: "proceed", | ||
| message: `Delete ${totalItems} items (${formatSize(totalToClean)})?`, | ||
| default: false | ||
| } | ||
| ]); | ||
| if (!confirm.proceed) { | ||
| console.log(chalk2.yellow("\nCleaning cancelled.\n")); | ||
| return null; | ||
| } | ||
| } | ||
| if (options.dryRun) { | ||
| console.log(chalk2.cyan("\n[DRY RUN] Would clean the following:")); | ||
| for (const { categoryId, items } of selectedItems) { | ||
| const scanner = getScanner(categoryId); | ||
| const size = items.reduce((sum, i) => sum + i.size, 0); | ||
| console.log(` ${scanner.category.name}: ${items.length} items (${formatSize(size)})`); | ||
| } | ||
| console.log(chalk2.cyan(` | ||
| [DRY RUN] Would free ${formatSize(totalToClean)} | ||
| `)); | ||
| console.log(); | ||
| console.log(chalk6.bold("Summary:")); | ||
| console.log(` Items to delete: ${chalk6.yellow(totalItems.toString())}`); | ||
| console.log(` Space to free: ${chalk6.green(formatSize(totalToClean))}`); | ||
| console.log(); | ||
| const proceed = await confirm3({ | ||
| message: `Proceed with cleaning?`, | ||
| default: true | ||
| }); | ||
| if (!proceed) { | ||
| console.log(chalk6.yellow("\nCleaning cancelled.\n")); | ||
| return null; | ||
| } | ||
| const cleanSpinner = ora2("Cleaning...").start(); | ||
| const cleanProgress = showProgress ? createCleanProgress(selectedItems.length) : null; | ||
| const cleanResults = { | ||
@@ -1045,6 +1698,7 @@ results: [], | ||
| }; | ||
| let cleanedCount = 0; | ||
| for (const { categoryId, items } of selectedItems) { | ||
| const scanner = getScanner(categoryId); | ||
| cleanSpinner.text = `Cleaning ${scanner.category.name}...`; | ||
| const result = await scanner.clean(items, options.dryRun); | ||
| cleanProgress?.update(cleanedCount, `Cleaning ${scanner.category.name}...`); | ||
| const result = await scanner.clean(items); | ||
| cleanResults.results.push(result); | ||
@@ -1054,36 +1708,27 @@ cleanResults.totalFreedSpace += result.freedSpace; | ||
| cleanResults.totalErrors += result.errors.length; | ||
| cleanedCount++; | ||
| } | ||
| cleanSpinner.stop(); | ||
| cleanProgress?.finish(); | ||
| printCleanResults(cleanResults); | ||
| return cleanResults; | ||
| } | ||
| async function selectItemsInteractively(results, _unsafe = false) { | ||
| console.log(); | ||
| console.log(chalk2.bold("Select categories to clean:")); | ||
| console.log(); | ||
| async function selectItemsInteractively(results, _includeRisky = false) { | ||
| const choices = results.map((r) => { | ||
| const safetyIcon = SAFETY_ICONS2[r.category.safetyLevel]; | ||
| const safetyIcon = SAFETY_ICONS3[r.category.safetyLevel]; | ||
| const isRisky = r.category.safetyLevel === "risky"; | ||
| return { | ||
| name: `${safetyIcon} ${r.category.name.padEnd(28)} ${chalk2.yellow(formatSize(r.totalSize).padStart(10))} ${chalk2.dim(`(${r.items.length} items)`)}`, | ||
| name: `${safetyIcon} ${r.category.name.padEnd(28)} ${chalk6.yellow(formatSize(r.totalSize).padStart(10))} ${chalk6.dim(`(${r.items.length} items)`)}`, | ||
| value: r.category.id, | ||
| checked: !isRisky && r.category.safetyLevel !== "risky", | ||
| size: r.totalSize, | ||
| items: r.items | ||
| checked: !isRisky | ||
| }; | ||
| }); | ||
| const { categories } = await inquirer.prompt([ | ||
| { | ||
| type: "checkbox", | ||
| name: "categories", | ||
| message: "Categories", | ||
| choices: choices.map((c) => ({ | ||
| name: c.name, | ||
| value: c.value, | ||
| checked: c.checked | ||
| })), | ||
| pageSize: 15 | ||
| } | ||
| ]); | ||
| const selectedCategories = categories; | ||
| const selectedCategories = await checkbox3({ | ||
| message: "Select categories to clean (space to toggle, enter to confirm):", | ||
| choices: choices.map((c) => ({ | ||
| name: c.name, | ||
| value: c.value, | ||
| checked: c.checked | ||
| })), | ||
| pageSize: 15 | ||
| }); | ||
| const selectedResults = results.filter((r) => selectedCategories.includes(r.category.id)); | ||
@@ -1093,22 +1738,18 @@ const selectedItems = []; | ||
| const isRisky = result.category.safetyLevel === "risky"; | ||
| if (isRisky || result.category.id === "large-files" || result.category.id === "ios-backups") { | ||
| const needsItemSelection = isRisky || result.category.id === "large-files" || result.category.id === "ios-backups"; | ||
| if (needsItemSelection) { | ||
| if (isRisky && result.category.safetyNote) { | ||
| console.log(); | ||
| console.log(chalk2.red(`\u26A0 WARNING: ${result.category.safetyNote}`)); | ||
| console.log(chalk6.red(`\u26A0 WARNING: ${result.category.safetyNote}`)); | ||
| } | ||
| const itemChoices = result.items.map((item) => ({ | ||
| name: `${item.name.substring(0, 40).padEnd(40)} ${chalk2.yellow(formatSize(item.size).padStart(10))}`, | ||
| name: `${item.name.substring(0, 40).padEnd(40)} ${chalk6.yellow(formatSize(item.size).padStart(10))}`, | ||
| value: item.path, | ||
| checked: false | ||
| })); | ||
| const { items } = await inquirer.prompt([ | ||
| { | ||
| type: "checkbox", | ||
| name: "items", | ||
| message: `Select items from ${result.category.name}:`, | ||
| choices: itemChoices, | ||
| pageSize: 10 | ||
| } | ||
| ]); | ||
| const selectedPaths = items; | ||
| const selectedPaths = await checkbox3({ | ||
| message: `Select items from ${result.category.name}:`, | ||
| choices: itemChoices, | ||
| pageSize: 10 | ||
| }); | ||
| const selectedItemsList = result.items.filter((i) => selectedPaths.includes(i.path)); | ||
@@ -1132,20 +1773,20 @@ if (selectedItemsList.length > 0) { | ||
| console.log(); | ||
| console.log(chalk2.bold.green("\u2713 Cleaning Complete")); | ||
| console.log(chalk2.dim("\u2500".repeat(50))); | ||
| console.log(chalk6.bold.green("\u2713 Cleaning Complete!")); | ||
| console.log(chalk6.dim("\u2500".repeat(50))); | ||
| for (const result of summary.results) { | ||
| if (result.cleanedItems > 0) { | ||
| console.log( | ||
| ` ${result.category.name.padEnd(30)} ${chalk2.green("\u2713")} ${formatSize(result.freedSpace)} freed` | ||
| ` ${result.category.name.padEnd(30)} ${chalk6.green("\u2713")} ${formatSize(result.freedSpace)} freed` | ||
| ); | ||
| } | ||
| for (const error of result.errors) { | ||
| console.log(` ${result.category.name.padEnd(30)} ${chalk2.red("\u2717")} ${error}`); | ||
| console.log(` ${result.category.name.padEnd(30)} ${chalk6.red("\u2717")} ${error}`); | ||
| } | ||
| } | ||
| console.log(); | ||
| console.log(chalk2.dim("\u2500".repeat(50))); | ||
| console.log(chalk2.bold(`Freed: ${chalk2.green(formatSize(summary.totalFreedSpace))}`)); | ||
| console.log(chalk2.dim(`Cleaned ${summary.totalCleanedItems} items`)); | ||
| console.log(chalk6.dim("\u2500".repeat(50))); | ||
| console.log(chalk6.bold(`\u{1F389} Freed ${chalk6.green(formatSize(summary.totalFreedSpace))} of disk space!`)); | ||
| console.log(chalk6.dim(` Cleaned ${summary.totalCleanedItems} items`)); | ||
| if (summary.totalErrors > 0) { | ||
| console.log(chalk2.red(`Errors: ${summary.totalErrors}`)); | ||
| console.log(chalk6.red(` Errors: ${summary.totalErrors}`)); | ||
| } | ||
@@ -1155,112 +1796,18 @@ console.log(); | ||
| // src/commands/maintenance.ts | ||
| import chalk3 from "chalk"; | ||
| import ora3 from "ora"; | ||
| // src/maintenance/dns-cache.ts | ||
| import { exec as exec3 } from "child_process"; | ||
| import { promisify as promisify3 } from "util"; | ||
| var execAsync3 = promisify3(exec3); | ||
| async function flushDnsCache() { | ||
| try { | ||
| await execAsync3("sudo dscacheutil -flushcache"); | ||
| await execAsync3("sudo killall -HUP mDNSResponder"); | ||
| return { | ||
| success: true, | ||
| message: "DNS cache flushed successfully" | ||
| }; | ||
| } catch (error) { | ||
| return { | ||
| success: false, | ||
| message: "Failed to flush DNS cache", | ||
| error: error instanceof Error ? error.message : String(error) | ||
| }; | ||
| } | ||
| } | ||
| // src/maintenance/purgeable.ts | ||
| import { exec as exec4 } from "child_process"; | ||
| import { promisify as promisify4 } from "util"; | ||
| var execAsync4 = promisify4(exec4); | ||
| async function freePurgeableSpace() { | ||
| try { | ||
| await execAsync4("purge"); | ||
| return { | ||
| success: true, | ||
| message: "Purgeable space freed successfully" | ||
| }; | ||
| } catch (error) { | ||
| return { | ||
| success: false, | ||
| message: "Failed to free purgeable space", | ||
| error: error instanceof Error ? error.message : String(error) | ||
| }; | ||
| } | ||
| } | ||
| // src/commands/maintenance.ts | ||
| async function maintenanceCommand(options) { | ||
| const tasks = []; | ||
| if (options.dns) { | ||
| tasks.push({ name: "Flush DNS Cache", fn: flushDnsCache }); | ||
| } | ||
| if (options.purgeable) { | ||
| tasks.push({ name: "Free Purgeable Space", fn: freePurgeableSpace }); | ||
| } | ||
| if (tasks.length === 0) { | ||
| console.log(chalk3.yellow("\nNo maintenance tasks specified.")); | ||
| console.log(chalk3.dim("Use --dns to flush DNS cache or --purgeable to free purgeable space.\n")); | ||
| return; | ||
| } | ||
| console.log(); | ||
| console.log(chalk3.bold("Running Maintenance Tasks")); | ||
| console.log(chalk3.dim("\u2500".repeat(50))); | ||
| for (const task of tasks) { | ||
| const spinner = ora3(task.name).start(); | ||
| const result = await task.fn(); | ||
| if (result.success) { | ||
| spinner.succeed(chalk3.green(result.message)); | ||
| } else { | ||
| spinner.fail(chalk3.red(result.message)); | ||
| if (result.error) { | ||
| console.log(chalk3.dim(` ${result.error}`)); | ||
| } | ||
| } | ||
| } | ||
| console.log(); | ||
| } | ||
| // src/index.ts | ||
| var program = new Command(); | ||
| program.name("clean-my-mac").description("Open source CLI tool to clean your Mac").version("1.0.0"); | ||
| program.command("scan").description("Scan your Mac for cleanable files").option("-c, --category <category>", "Scan specific category").option("-v, --verbose", "Show detailed output").option("-l, --list", "List available categories").action(async (options) => { | ||
| if (options.list) { | ||
| listCategories(); | ||
| return; | ||
| } | ||
| if (options.category && !CATEGORIES[options.category]) { | ||
| console.error(`Invalid category: ${options.category}`); | ||
| console.error("Use --list to see available categories"); | ||
| process.exit(1); | ||
| } | ||
| await scanCommand({ | ||
| category: options.category, | ||
| verbose: options.verbose | ||
| program.name("clean-my-mac").description("Open source CLI tool to clean your Mac").version("1.1.0").option("-r, --risky", "Include risky categories (downloads, iOS backups, etc)").option("--no-progress", "Disable progress bar").action(async (options) => { | ||
| await interactiveCommand({ | ||
| includeRisky: options.risky, | ||
| noProgress: !options.progress | ||
| }); | ||
| }); | ||
| program.command("clean").description("Clean selected files from your Mac").option("-a, --all", "Clean all categories (safe and moderate only by default)").option("-y, --yes", "Skip confirmation prompts").option("-d, --dry-run", "Show what would be cleaned without actually cleaning").option("-c, --category <category>", "Clean specific category").option("-u, --unsafe", "Include risky categories (downloads, iOS backups, etc)").action(async (options) => { | ||
| if (options.category && !CATEGORIES[options.category]) { | ||
| console.error(`Invalid category: ${options.category}`); | ||
| console.error('Use "clean-my-mac scan --list" to see available categories'); | ||
| process.exit(1); | ||
| } | ||
| await cleanCommand({ | ||
| all: options.all, | ||
| program.command("uninstall").description("Uninstall applications and their related files").option("-y, --yes", "Skip confirmation prompts").option("-d, --dry-run", "Show what would be uninstalled without actually uninstalling").option("--no-progress", "Disable progress bar").action(async (options) => { | ||
| await uninstallCommand({ | ||
| yes: options.yes, | ||
| dryRun: options.dryRun, | ||
| category: options.category, | ||
| unsafe: options.unsafe | ||
| noProgress: !options.progress | ||
| }); | ||
| }); | ||
| program.command("maintenance").description("Run maintenance tasks").option("--dns", "Flush DNS cache").option("--purgeable", "Free purgeable space").action(async (options) => { | ||
| program.command("maintenance").description("Run maintenance tasks (DNS flush, free purgeable space)").option("--dns", "Flush DNS cache").option("--purgeable", "Free purgeable space").action(async (options) => { | ||
| await maintenanceCommand({ | ||
@@ -1271,2 +1818,49 @@ dns: options.dns, | ||
| }); | ||
| program.command("categories").description("List all available categories").action(() => { | ||
| listCategories(); | ||
| }); | ||
| program.command("config").description("Manage configuration").option("--init", "Create default configuration file").option("--show", "Show current configuration").action(async (options) => { | ||
| if (options.init) { | ||
| const exists2 = await configExists(); | ||
| if (exists2) { | ||
| console.log("Configuration file already exists."); | ||
| return; | ||
| } | ||
| const path = await initConfig(); | ||
| console.log(`Created configuration file at: ${path}`); | ||
| return; | ||
| } | ||
| if (options.show) { | ||
| const exists2 = await configExists(); | ||
| if (!exists2) { | ||
| console.log('No configuration file found. Run "clean-my-mac config --init" to create one.'); | ||
| return; | ||
| } | ||
| const config = await loadConfig(); | ||
| console.log(JSON.stringify(config, null, 2)); | ||
| return; | ||
| } | ||
| console.log("Use --init to create config or --show to display current config."); | ||
| }); | ||
| program.command("backup").description("Manage backups").option("--list", "List all backups").option("--clean", "Clean old backups (older than 7 days)").action(async (options) => { | ||
| if (options.list) { | ||
| const backups = await listBackups(); | ||
| if (backups.length === 0) { | ||
| console.log("No backups found."); | ||
| return; | ||
| } | ||
| console.log("\nBackups:"); | ||
| for (const backup of backups) { | ||
| console.log(` ${backup.date.toLocaleDateString()} - ${formatSize(backup.size)}`); | ||
| console.log(` ${backup.path}`); | ||
| } | ||
| return; | ||
| } | ||
| if (options.clean) { | ||
| const cleaned = await cleanOldBackups(); | ||
| console.log(`Cleaned ${cleaned} old backups.`); | ||
| return; | ||
| } | ||
| console.log("Use --list to show backups or --clean to remove old ones."); | ||
| }); | ||
| program.parse(); |
+11
-12
| { | ||
| "name": "clean-my-mac-cli", | ||
| "version": "1.0.0", | ||
| "version": "1.1.0", | ||
| "description": "Open source CLI tool to clean your Mac - similar to CleanMyMac", | ||
@@ -19,3 +19,3 @@ "type": "module", | ||
| "build": "tsup src/index.ts --format esm --dts --clean", | ||
| "lint": "eslint src --ext .ts", | ||
| "lint": "eslint src", | ||
| "typecheck": "tsc --noEmit", | ||
@@ -50,21 +50,20 @@ "test": "vitest run", | ||
| "devDependencies": { | ||
| "@types/inquirer": "^9.0.9", | ||
| "@eslint/js": "^9.28.0", | ||
| "@types/node": "^20.10.0", | ||
| "@typescript-eslint/eslint-plugin": "^6.13.0", | ||
| "@typescript-eslint/parser": "^6.13.0", | ||
| "@vitest/coverage-v8": "^1.0.4", | ||
| "eslint": "^8.55.0", | ||
| "@vitest/coverage-v8": "^4.0.15", | ||
| "eslint": "^9.28.0", | ||
| "tsup": "^8.0.1", | ||
| "tsx": "^4.6.2", | ||
| "typescript": "^5.3.2", | ||
| "vitest": "^1.0.4" | ||
| "typescript-eslint": "^8.48.1", | ||
| "vitest": "^4.0.15" | ||
| }, | ||
| "dependencies": { | ||
| "@inquirer/prompts": "^7.5.1", | ||
| "chalk": "^5.3.0", | ||
| "commander": "^11.1.0", | ||
| "inquirer": "^9.2.12", | ||
| "ora": "^8.0.1" | ||
| "commander": "^14.0.2", | ||
| "ora": "^9.0.0" | ||
| }, | ||
| "engines": { | ||
| "node": ">=18" | ||
| "node": ">=20" | ||
| }, | ||
@@ -71,0 +70,0 @@ "os": [ |
+83
-59
@@ -5,69 +5,81 @@ # Clean My Mac CLI | ||
| ## Installation | ||
| [](https://www.npmjs.com/package/clean-my-mac-cli) | ||
| ```bash | ||
| npm install -g clean-my-mac-cli | ||
| ``` | ||
| ## Quick Start | ||
| Or run directly with npx: | ||
| Just run one command - no installation required: | ||
| ```bash | ||
| npx clean-my-mac-cli scan | ||
| npx clean-my-mac-cli | ||
| ``` | ||
| That's it! The CLI will: | ||
| 1. ๐ Scan your Mac for cleanable files | ||
| 2. ๐ Show you what was found | ||
| 3. โ Let you select what to clean | ||
| 4. ๐๏ธ Clean the selected items | ||
| ## Features | ||
| - **Smart Scanning**: Automatically detects cleanable files across multiple categories | ||
| - **Safety Levels**: Items are classified as safe, moderate, or risky to prevent accidental data loss | ||
| - **Interactive Selection**: Choose exactly what to clean with an interactive checkbox interface | ||
| - **Dry Run Mode**: Preview what would be cleaned without actually deleting anything | ||
| - **Multiple Categories**: Clean system caches, logs, browser data, development files, and more | ||
| - **One Command**: Just run `npx clean-my-mac-cli` - no complex flags to remember | ||
| - **Interactive**: Select exactly what you want to clean with checkboxes | ||
| - **Safe by Default**: Risky items are hidden unless you explicitly include them | ||
| - **Smart Scanning**: Finds caches, logs, development files, browser data, and more | ||
| - **App Uninstaller**: Remove apps completely with all their associated files | ||
| - **Maintenance Tasks**: Flush DNS cache, free purgeable space | ||
| ## Usage | ||
| ### Scan for Cleanable Files | ||
| ### Basic Usage (Recommended) | ||
| ```bash | ||
| # Full scan | ||
| clean-my-mac scan | ||
| # Interactive mode - scan, select, and clean | ||
| npx clean-my-mac-cli | ||
| # Detailed scan with file breakdown | ||
| clean-my-mac scan --verbose | ||
| # Include risky categories (downloads, iOS backups, large files) | ||
| npx clean-my-mac-cli --risky | ||
| ``` | ||
| # Scan specific category | ||
| clean-my-mac scan --category dev-cache | ||
| ### Uninstall Apps | ||
| # List all available categories | ||
| clean-my-mac scan --list | ||
| Remove applications completely, including their preferences, caches, and support files: | ||
| ```bash | ||
| npx clean-my-mac-cli uninstall | ||
| ``` | ||
| ### Clean Files | ||
| ### Maintenance Tasks | ||
| ```bash | ||
| # Interactive cleaning (recommended) | ||
| clean-my-mac clean | ||
| # Flush DNS cache (may require sudo) | ||
| npx clean-my-mac-cli maintenance --dns | ||
| # Preview what would be cleaned | ||
| clean-my-mac clean --dry-run | ||
| # Clean all safe and moderate categories | ||
| clean-my-mac clean --all --yes | ||
| # Include risky categories (downloads, iOS backups, etc) | ||
| clean-my-mac clean --all --yes --unsafe | ||
| # Free purgeable space | ||
| npx clean-my-mac-cli maintenance --purgeable | ||
| ``` | ||
| ### Maintenance Tasks | ||
| ### Other Commands | ||
| ```bash | ||
| # Flush DNS cache | ||
| clean-my-mac maintenance --dns | ||
| # List all available categories | ||
| npx clean-my-mac-cli categories | ||
| # Free purgeable space | ||
| clean-my-mac maintenance --purgeable | ||
| # Manage configuration | ||
| npx clean-my-mac-cli config --init | ||
| npx clean-my-mac-cli config --show | ||
| # Run both | ||
| clean-my-mac maintenance --dns --purgeable | ||
| # Manage backups | ||
| npx clean-my-mac-cli backup --list | ||
| npx clean-my-mac-cli backup --clean | ||
| ``` | ||
| ## Global Installation (Optional) | ||
| If you use this tool frequently, install it globally: | ||
| ```bash | ||
| npm install -g clean-my-mac-cli | ||
| clean-my-mac | ||
| ``` | ||
| ## Categories | ||
@@ -91,2 +103,3 @@ | ||
| | `docker` | ๐ข Safe | Unused Docker images, containers, volumes | | ||
| | `node-modules` | ๐ก Moderate | Orphaned node_modules in old projects | | ||
@@ -101,2 +114,3 @@ ### Storage | ||
| | `mail-attachments` | ๐ด Risky | Downloaded email attachments | | ||
| | `duplicates` | ๐ด Risky | Duplicate files (keeps newest) | | ||
@@ -119,30 +133,40 @@ ### Browsers | ||
| - ๐ก **Moderate**: Generally safe, but may cause minor inconvenience (e.g., apps rebuilding cache). | ||
| - ๐ด **Risky**: May contain important data. Requires `--unsafe` flag and individual file selection. | ||
| - ๐ด **Risky**: May contain important data. Hidden by default, use `--risky` to include. | ||
| ## Example Output | ||
| ## Example | ||
| ``` | ||
| Scanning your Mac... | ||
| $ npx clean-my-mac-cli | ||
| Scan Results | ||
| โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| ๐งน Clean My Mac | ||
| โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| System Junk | ||
| ๐ก User Cache Files 15.5 GB (118 items) | ||
| ๐ก System Log Files 102.4 MB (80 items) | ||
| ๐ข Temporary Files 549.2 MB (622 items) | ||
| ๐ด Language Files 68.9 MB (535 items) | ||
| Scanning your Mac for cleanable files... | ||
| Development | ||
| ๐ก Development Cache 21.9 GB (14 items) | ||
| ๐ข Homebrew Cache 225.6 MB (1 items) | ||
| ๐ข Docker 4.9 GB (3 items) | ||
| Found 44.8 GB that can be cleaned: | ||
| Browsers | ||
| ๐ข Browser Cache 1.5 GB (3 items) | ||
| ? Select categories to clean (space to toggle, enter to confirm): | ||
| โ ๐ข Trash 2.1 GB (45 items) | ||
| โ ๐ข Browser Cache 1.5 GB (3 items) | ||
| โ ๐ข Temporary Files 549.2 MB (622 items) | ||
| โ ๐ก User Cache Files 15.5 GB (118 items) | ||
| โ ๐ก Development Cache 21.9 GB (14 items) | ||
| โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| Total: 44.8 GB can be cleaned (1377 items) | ||
| Summary: | ||
| Items to delete: 802 | ||
| Space to free: 41.5 GB | ||
| Safety: ๐ข safe ๐ก moderate ๐ด risky (use --unsafe) | ||
| ? Proceed with cleaning? (Y/n) | ||
| โ Cleaning Complete! | ||
| โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| Trash โ 2.1 GB freed | ||
| Browser Cache โ 1.5 GB freed | ||
| Temporary Files โ 549.2 MB freed | ||
| User Cache Files โ 15.5 GB freed | ||
| Development Cache โ 21.9 GB freed | ||
| โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| ๐ Freed 41.5 GB of disk space! | ||
| Cleaned 802 items | ||
| ``` | ||
@@ -161,3 +185,3 @@ | ||
| # Run in development mode | ||
| npm run dev -- scan | ||
| npm run dev | ||
@@ -193,2 +217,2 @@ # Run tests | ||
| This tool deletes files from your system. While we've implemented safety measures, always use `--dry-run` first to preview changes, and ensure you have backups of important data. Use at your own risk. | ||
| This tool deletes files from your system. While we've implemented safety measures, always ensure you have backups of important data. Use at your own risk. |
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 2 instances in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
67376
43.52%9
-10%1800
48.27%213
12.7%13
85.71%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
Updated
Updated