@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 +53 -0
- package/index.js +119 -0
- package/lib/file-regex.js +22 -0
- package/lib/index.js +186 -0
- package/lib/path-locations.js +28 -0
- package/package.json +37 -0
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
|
+
}
|