@kud/soap-cli 1.0.0-4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # Soap 🧼 - An app cleaner cli for macOS
2
+
3
+ > I was so lazy to use any GUI to clean my macOS. - @kud
4
+
5
+ A simple command in your shell to remove the application you want to delete and the related files as well (like preferences, logs, `.dmg`, cask formula).
6
+
7
+ ## Motivation
8
+
9
+ I used to use AppCleaner and [App Eraser](https://github.com/davunt/app-eraser) but the first one is not open source (and could have trackers) and the second one is not a CLI but a GUI. Here comes _soap_.
10
+
11
+ ## Install
12
+
13
+ ```shell
14
+ npm install -g @kud/soap
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ You've got two ways to uninstall an application, via its path or via its cask name.
20
+
21
+ #### Application path
22
+
23
+ ```shell
24
+ soap <app-path>
25
+ # ex: soap '/Applications/Android Studio.app'
26
+ ```
27
+
28
+ _warning: it won't remove the cask formula even if you've installed the app via homebrew._
29
+
30
+ #### Cask name
31
+
32
+ ```shell
33
+ soap <cask-name>
34
+ # ex: soap android-studio
35
+ ```
36
+
37
+ It will delete for instance this kind of files:
38
+
39
+ ```
40
+ /Applications/Android Studio.app
41
+ /Users/kud/Downloads/android-studio-ide-182.5314842-mac.dmg
42
+ /Users/kud/Library/Preferences/com.google.android.studio.plist
43
+ /Users/kud/Library/Saved Application State/com.google.android.studio.savedState
44
+ /Users/kud/Library/Application Support/google/AndroidStudio4.2
45
+ /Users/kud/Library/Caches/google/AndroidStudio4.2
46
+ /Users/kud/Library/Logs/google/AndroidStudio4.2
47
+ ```
48
+
49
+ No worries, soap will move them into your Trash, so if something wrong has happened, you can check there your files.
50
+
51
+ ## Credits
52
+
53
+ Inspired from [App Eraser](https://github.com/davunt/app-eraser)
package/index.js ADDED
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ import inquirer from "inquirer"
3
+ import signale from "signale"
4
+ import trash from "trash"
5
+ import chalk from "chalk"
6
+ import { $ } from "zx/core"
7
+
8
+ import {
9
+ appNameFromPath,
10
+ appNameFromCaskName,
11
+ getBundleIdentifier,
12
+ findAppFiles,
13
+ } from "./lib/index.js"
14
+
15
+ $.verbose = false // true for debugging
16
+
17
+ const [param] = process.argv.slice(2)
18
+
19
+ if (!param) {
20
+ console.error(`No parameter specified.`)
21
+
22
+ process.exit(0)
23
+ }
24
+
25
+ const isCask = !param.includes(".app")
26
+
27
+ console.log(
28
+ `Welcome to ${chalk.bold("soap")} 🧼, ${chalk.italic("the app cleaner")}.\n`,
29
+ )
30
+
31
+ try {
32
+ const appName = isCask
33
+ ? await appNameFromCaskName(param)
34
+ : appNameFromPath(param)
35
+ const bundleId = await getBundleIdentifier(appName)
36
+ const _appFiles = await findAppFiles(appName, bundleId)
37
+ const appFiles = isCask ? _appFiles.slice(1) : _appFiles
38
+ const isAppFilesEmpty = appFiles.length === 0
39
+
40
+ signale.info(
41
+ `You want me to clean this application: ${chalk.bold(appName)} 📦.`,
42
+ )
43
+
44
+ signale.info(
45
+ isCask
46
+ ? `I also assume ${chalk.bold("you gave me a cask name")}.`
47
+ : `I also assume you gave me an application path. ${chalk.bold(
48
+ "No homebrew cask will be deleted then",
49
+ )}.`,
50
+ )
51
+
52
+ console.log("")
53
+
54
+ const { deletedFilesWish } = await inquirer.prompt([
55
+ {
56
+ type: "checkbox",
57
+ name: "deletedFilesWish",
58
+ when: !isAppFilesEmpty,
59
+ message:
60
+ "This is what I've found about it. Please select the files you want to delete.",
61
+ choices: appFiles.map((appFile) => ({
62
+ name: appFile,
63
+ value: appFile,
64
+ checked: true,
65
+ })),
66
+ },
67
+ {
68
+ type: "confirm",
69
+ when: isCask,
70
+ name: "deletedCaskWish",
71
+ message: `Do you want to uninstall "${param}" via homebrew?`,
72
+ },
73
+ ])
74
+
75
+ if (!isAppFilesEmpty) {
76
+ await trash(deletedFilesWish, { force: true })
77
+
78
+ console.log("")
79
+
80
+ console.log("Files deleted:")
81
+ deletedFilesWish.forEach((deletedPath) => console.log(`· ${deletedPath}`))
82
+ }
83
+
84
+ if (isCask) {
85
+ console.log("")
86
+
87
+ signale.pending(`Starting cask uninstallation.`)
88
+
89
+ await $`brew uninstall ${param}`
90
+ }
91
+
92
+ console.log("")
93
+
94
+ signale.success("Operation successful.")
95
+ } catch (error) {
96
+ signale.error(`Something wrong appeared. Check the log below.\n`)
97
+ console.error(error)
98
+ console.error("")
99
+
100
+ if (isCask) {
101
+ inquirer
102
+ .prompt([
103
+ {
104
+ type: "confirm",
105
+ name: "forceUninstall",
106
+ message:
107
+ "An error occurred. It could happen when the app file no longer exists, but the cask is still present in Homebrew.\nWould you like to forcefully uninstall the cask by running `brew uninstall`?",
108
+ },
109
+ ])
110
+ .then(async (answers) => {
111
+ if (answers.forceUninstall) {
112
+ console.error("")
113
+ signale.pending(`Forcefully uninstalling cask.`)
114
+ await $`brew uninstall --force ${param}`
115
+ signale.success("Force uninstallation successful.")
116
+ }
117
+ })
118
+ }
119
+ }
@@ -0,0 +1,22 @@
1
+ const uuidReg =
2
+ /[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}/g
3
+
4
+ const dateReg = /[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}/g
5
+
6
+ const diagReg = /.[a-zA-Z]+_resource.diag/
7
+
8
+ const commonExtensions = [
9
+ ".dmg",
10
+ ".app",
11
+ ".bom",
12
+ ".plist",
13
+ ".XPCHelper",
14
+ ".beta",
15
+ ".extensions",
16
+ ".savedState",
17
+ ".driver",
18
+ ".wakeups_resource",
19
+ ".diag",
20
+ ]
21
+
22
+ export const fileRegex = [uuidReg, dateReg, diagReg, ...commonExtensions]
package/lib/index.js ADDED
@@ -0,0 +1,186 @@
1
+ import { $ } from "zx"
2
+ $.verbose = false
3
+
4
+ import { execSync } from "child_process"
5
+ import fs from "fs/promises"
6
+ import { pathLocations, commonSuffix } from "./path-locations.js"
7
+ import { fileRegex } from "./file-regex.js"
8
+
9
+ const scoreThreshold = 0.4
10
+
11
+ let compNameGlob
12
+
13
+ function stripString(file) {
14
+ let transformedString = file
15
+ fileRegex.forEach((regex1) => {
16
+ transformedString = transformedString.replace(regex1, "")
17
+ })
18
+
19
+ const normCompName = normalizeString(compNameGlob, "-")
20
+ .replace(/\u2019/g, "")
21
+ .replace(/\(/g, "")
22
+ .replace(/\)/g, "")
23
+
24
+ transformedString = transformedString.replace(normCompName, "")
25
+
26
+ return transformedString
27
+ }
28
+
29
+ const normalizeString = (str, spacer = "") =>
30
+ str.toLowerCase().replace(/ /g, spacer)
31
+
32
+ async function getFilePatternArray(appName, bundleId) {
33
+ const nameVariations = createNameVariations(appName, bundleId)
34
+ const appNameNorm = normalizeString(appName)
35
+ const bundleIdNorm = normalizeString(bundleId)
36
+
37
+ let patternArray = [...nameVariations]
38
+
39
+ const appNameComponents = appNameNorm.split(".")
40
+ if (appNameComponents) patternArray.push(appNameComponents[0])
41
+
42
+ const bundleIdComponents = bundleIdNorm.split(".")
43
+ if (
44
+ bundleIdComponents.length > 2 &&
45
+ bundleIdComponents[bundleIdComponents.length - 1].toLowerCase() === "app"
46
+ ) {
47
+ patternArray.push(
48
+ `${bundleIdComponents.slice(0, bundleIdComponents.length - 1).join(".")}`,
49
+ )
50
+ }
51
+
52
+ const appWithSuffix = new Set([])
53
+ commonSuffix.forEach((suffix) =>
54
+ nameVariations.forEach((nameVariation) =>
55
+ appWithSuffix.add(`${nameVariation}${suffix}`),
56
+ ),
57
+ )
58
+
59
+ patternArray = [...patternArray, [...appWithSuffix]]
60
+
61
+ return patternArray
62
+ }
63
+
64
+ function isPatternInFile(patterns, fileToCheck) {
65
+ return patterns.find((filePatten) => {
66
+ if (fileToCheck.includes(filePatten)) {
67
+ const strippedFile = stripString(fileToCheck)
68
+
69
+ let score = 0
70
+ const indexOfString = strippedFile.indexOf(filePatten)
71
+ for (let i = 0; i < strippedFile.length; i += 1) {
72
+ if (i === indexOfString) {
73
+ i += indexOfString + filePatten.length
74
+ score += filePatten.length
75
+ }
76
+ if (strippedFile[parseInt(i, 10)] === ".") score += 0.5
77
+ if (strippedFile[parseInt(i, 10)] === "_") score += 0.5
78
+ }
79
+ if (score / strippedFile.length > scoreThreshold) {
80
+ return true
81
+ }
82
+ return false
83
+ }
84
+ return false
85
+ })
86
+ }
87
+
88
+ function createNameVariations(appName, bundleId) {
89
+ const appNameNorm = appName.toLowerCase().replace(" ", "")
90
+ const appNameWithoutDot = appNameNorm.toLowerCase().replace(".", "")
91
+ const appNameUnderscore = appName.toLowerCase().replace(" ", "_")
92
+ const appNameDash = appName.toLowerCase().replace(" ", "-")
93
+ const appNameDot = appName.toLowerCase().replace(" ", ".")
94
+
95
+ const bundleIdNorm = bundleId.toLowerCase().replace(" ", "")
96
+
97
+ return [
98
+ appNameNorm,
99
+ appNameWithoutDot,
100
+ appNameUnderscore,
101
+ appNameDash,
102
+ appNameDot,
103
+ bundleIdNorm,
104
+ ]
105
+ }
106
+
107
+ function appNameFromPath(appPath) {
108
+ const pathArr = appPath.split("/")
109
+ const appNameWithExt = pathArr[pathArr.length - 1]
110
+ // remove .app extension
111
+ return appNameWithExt.replace(".app", "")
112
+ }
113
+
114
+ export const appNameFromCaskName = async (caskName) => {
115
+ try {
116
+ const { stdout: info } = await $`brew info ${caskName} --json=v2`
117
+ const caskData = JSON.parse(info).casks?.[0]
118
+
119
+ if (!caskData) {
120
+ throw new Error("Cask data not available.")
121
+ }
122
+
123
+ return caskData.artifacts?.[0]?.app?.[0] || null
124
+ } catch (error) {
125
+ console.error(`Error fetching app name for cask ${caskName}:`, error)
126
+
127
+ return null
128
+ }
129
+ }
130
+
131
+ async function getComputerName() {
132
+ const compName = await execSync("scutil --get ComputerName").toString()
133
+
134
+ // remove empty space at end of string
135
+ return compName.substring(0, compName.length - 1)
136
+ }
137
+
138
+ async function getBundleIdentifier(appName) {
139
+ const bundleId = await execSync(
140
+ `osascript -e 'id of app "${appName}"'`,
141
+ ).toString()
142
+
143
+ // remove empty space at end of string
144
+ return bundleId.substring(0, bundleId.length - 1)
145
+ }
146
+
147
+ async function findAppFiles(appName, bundleId) {
148
+ try {
149
+ compNameGlob = await getComputerName()
150
+ const bundleIdComponents = bundleId.split(".")
151
+
152
+ const companyDirs = pathLocations.map(
153
+ (pathLocation) => `${pathLocation}/${bundleIdComponents[1]}`,
154
+ )
155
+ const pathsToSearch = [...pathLocations, ...companyDirs]
156
+ const directoryFilesPromiseArr = pathsToSearch.map((pathLocation) =>
157
+ fs.readdir(pathLocation),
158
+ )
159
+ const directoryFiles = await Promise.allSettled(directoryFilesPromiseArr)
160
+
161
+ const patternArray = await getFilePatternArray(appName, bundleId)
162
+
163
+ const filesToRemove = new Set([])
164
+
165
+ directoryFiles.forEach((dir, index) => {
166
+ if (dir.status === "fulfilled") {
167
+ dir.value.forEach((dirFile) => {
168
+ const dirFileNorm = dirFile.toLowerCase().replace(" ", "")
169
+ if (isPatternInFile(patternArray, dirFileNorm)) {
170
+ filesToRemove.add(
171
+ `${pathsToSearch[parseInt(index, 10)]}/${dirFile}`,
172
+ )
173
+ }
174
+ })
175
+ }
176
+ })
177
+
178
+ // convert set to array
179
+ return [...filesToRemove]
180
+ } catch (err) {
181
+ console.error(err)
182
+ throw err
183
+ }
184
+ }
185
+
186
+ export { appNameFromPath, getBundleIdentifier, findAppFiles }
@@ -0,0 +1,28 @@
1
+ import { homedir } from "os"
2
+
3
+ export const pathLocations = [
4
+ "/Applications",
5
+ "/private/var/db/receipts",
6
+ "/Library/LaunchDaemons",
7
+ `${homedir}/Downloads`,
8
+ `${homedir}/Library`,
9
+ `${homedir}/Library/Application Support`,
10
+ `${homedir}/Library/Application Scripts`,
11
+ `${homedir}/Library/Application Support/CrashReporter`,
12
+ `${homedir}/Library/Containers`,
13
+ `${homedir}/Library/Caches`,
14
+ `${homedir}/Library/HTTPStorages`,
15
+ `${homedir}/Library/Group Containers`,
16
+ `${homedir}/Library/Internet Plug-Ins`,
17
+ `${homedir}/Library/LaunchAgents`,
18
+ `${homedir}/Library/Logs`,
19
+ "/Library/Logs/DiagnosticReports",
20
+ `${homedir}/Library/Preferences`,
21
+ `${homedir}/Library/Preferences/ByHost`,
22
+ `${homedir}/Library/Saved Application State`,
23
+ `${homedir}/Library/WebKit`,
24
+ `${homedir}/Library/Caches/com.apple.helpd/Generated`,
25
+ "/Library/Audio/Plug-Ins/HAL",
26
+ ]
27
+
28
+ export const commonSuffix = ["install"]
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@kud/soap-cli",
3
+ "version": "1.0.0-4",
4
+ "description": "An app cleaner cli for macOS",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "soap": "index.js"
8
+ },
9
+ "directories": {
10
+ "lib": "lib"
11
+ },
12
+ "scripts": {
13
+ "dev": "node ./index.js",
14
+ "test": "echo \"Error: no test specified\" && exit 1"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/kud/soap.git"
19
+ },
20
+ "keywords": [],
21
+ "author": "Erwann Mest <m@kud.io>",
22
+ "license": "MIT",
23
+ "bugs": {
24
+ "url": "https://github.com/kud/soap/issues"
25
+ },
26
+ "homepage": "https://github.com/kud/soap#readme",
27
+ "type": "module",
28
+ "dependencies": {
29
+ "chalk": "5.3.0",
30
+ "del": "7.1.0",
31
+ "inquirer": "9.2.11",
32
+ "signale": "1.4.0",
33
+ "trash": "8.1.1",
34
+ "zx": "7.2.3"
35
+ },
36
+ "devDependencies": {}
37
+ }