@kud/soap-cli 1.0.0-5 → 2.0.0
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/.claude/settings.local.json +14 -0
- package/README.md +108 -26
- package/index.js +95 -55
- package/lib/__tests__/app-name.test.js +63 -0
- package/lib/__tests__/file-regex.test.js +46 -0
- package/lib/__tests__/pattern-matching.test.js +39 -0
- package/lib/index.js +86 -39
- package/package.json +11 -8
package/README.md
CHANGED
|
@@ -1,53 +1,135 @@
|
|
|
1
|
-
#
|
|
1
|
+
# soap 🧼
|
|
2
2
|
|
|
3
|
-
> I was so lazy to use any GUI to clean my macOS.
|
|
3
|
+
> _"I was so lazy to use any GUI to clean my macOS."_ — @kud
|
|
4
4
|
|
|
5
|
-
A
|
|
5
|
+
A macOS CLI that removes an app **and all its leftover files** — preferences, caches, containers, launch agents, and more.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Inspired by [AppCleaner](https://freemacsoft.net/appcleaner/) and [App Eraser](https://github.com/davunt/app-eraser), but open-source and terminal-native.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
---
|
|
10
10
|
|
|
11
11
|
## Install
|
|
12
12
|
|
|
13
|
-
```
|
|
13
|
+
```sh
|
|
14
14
|
npm install -g @kud/soap-cli
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
Or via Homebrew:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
brew install kud/tap/soap-cli
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
17
25
|
## Usage
|
|
18
26
|
|
|
19
|
-
|
|
27
|
+
```
|
|
28
|
+
soap <cask-name> Clean an app installed via Homebrew cask
|
|
29
|
+
soap <path-to-app> Clean a manually installed app
|
|
30
|
+
soap --help Show this help
|
|
31
|
+
```
|
|
20
32
|
|
|
21
|
-
|
|
33
|
+
**Examples:**
|
|
22
34
|
|
|
23
|
-
```
|
|
24
|
-
soap
|
|
25
|
-
|
|
35
|
+
```sh
|
|
36
|
+
soap spotify
|
|
37
|
+
soap android-studio
|
|
38
|
+
soap '/Applications/Android Studio.app'
|
|
26
39
|
```
|
|
27
40
|
|
|
28
|
-
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## What it does
|
|
44
|
+
|
|
45
|
+
### Cask mode — `soap spotify`
|
|
46
|
+
|
|
47
|
+
1. Resolves the `.app` name from `brew info`
|
|
48
|
+
2. Scans ~40 macOS directories for files matching the app name or bundle identifier
|
|
49
|
+
3. Fetches the cask's [zap stanza](https://docs.brew.sh/Cask-Cookbook#stanza-zap) — the maintainer-curated list of known leftover files
|
|
50
|
+
4. Presents a checkbox list of everything found (all pre-selected)
|
|
51
|
+
5. Moves selected files to **Trash** (recoverable from `~/.Trash`)
|
|
52
|
+
6. Optionally runs `brew uninstall --zap` to unregister the cask from Homebrew
|
|
53
|
+
|
|
54
|
+
### Path mode — `soap '/Applications/Spotify.app'`
|
|
55
|
+
|
|
56
|
+
Same as above, but skips the Homebrew step. Use this for apps installed manually from a DMG.
|
|
29
57
|
|
|
30
|
-
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Example output
|
|
31
61
|
|
|
32
|
-
```shell
|
|
33
|
-
soap <cask-name>
|
|
34
|
-
# ex: soap android-studio
|
|
35
62
|
```
|
|
63
|
+
Welcome to soap 🧼, the app cleaner.
|
|
64
|
+
|
|
65
|
+
⠋ Fetching cask info…
|
|
66
|
+
⠋ Scanning for files…
|
|
67
|
+
|
|
68
|
+
ℹ Cleaning: Spotify
|
|
69
|
+
ℹ Mode: cask (spotify)
|
|
70
|
+
|
|
71
|
+
? Found 11 files. Select what to move to Trash:
|
|
72
|
+
◉ /Users/kud/Library/Application Support/Spotify
|
|
73
|
+
◉ /Users/kud/Library/Caches/com.spotify.client
|
|
74
|
+
◉ /Users/kud/Library/Preferences/com.spotify.client.plist
|
|
75
|
+
◉ /Users/kud/Library/Saved Application State/com.spotify.client.savedState
|
|
76
|
+
◉ /Users/kud/Library/Logs/Spotify
|
|
77
|
+
◉ ...
|
|
78
|
+
|
|
79
|
+
? Run `brew uninstall --zap spotify`? Yes
|
|
36
80
|
|
|
37
|
-
|
|
81
|
+
✔ Moved 11 file(s) to Trash.
|
|
38
82
|
|
|
83
|
+
◼ Running Homebrew uninstall…
|
|
84
|
+
✔ Done.
|
|
39
85
|
```
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Locations scanned
|
|
90
|
+
|
|
91
|
+
`soap` scans these directories, plus `<vendor>/` subdirectories derived from the bundle ID:
|
|
92
|
+
|
|
93
|
+
| Location | What's typically there |
|
|
94
|
+
| ----------------------------------- | ------------------------------- |
|
|
95
|
+
| `/Applications` | The app bundle |
|
|
96
|
+
| `~/Library/Application Support` | App data |
|
|
97
|
+
| `~/Library/Caches` | Cached data |
|
|
98
|
+
| `~/Library/Containers` | Sandboxed app data |
|
|
99
|
+
| `~/Library/Preferences` | `.plist` config files |
|
|
100
|
+
| `~/Library/Logs` | App logs |
|
|
101
|
+
| `~/Library/LaunchAgents` | Per-user background services |
|
|
102
|
+
| `/Library/LaunchDaemons` | System-wide background services |
|
|
103
|
+
| `~/Library/HTTPStorages` | HTTP caches |
|
|
104
|
+
| `~/Library/Group Containers` | Shared app-group data |
|
|
105
|
+
| `~/Library/Saved Application State` | Window state (`.savedState`) |
|
|
106
|
+
| `~/Library/WebKit` | WebKit storage |
|
|
107
|
+
| `/private/var/db/receipts` | Package receipts |
|
|
108
|
+
| `/Library/Logs/DiagnosticReports` | Crash reports |
|
|
109
|
+
| `/Library/Audio/Plug-Ins/HAL` | Audio drivers |
|
|
110
|
+
| `~/Downloads` | Leftover `.dmg` files |
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Debug mode
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
SOAP_DEBUG=1 soap spotify
|
|
47
118
|
```
|
|
48
119
|
|
|
49
|
-
|
|
120
|
+
Enables verbose shell output — shows every subprocess command that runs.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Safety
|
|
125
|
+
|
|
126
|
+
- Files are **moved to Trash**, not permanently deleted.
|
|
127
|
+
- You get a full checkbox list before anything is removed — deselect anything you want to keep.
|
|
128
|
+
- The Homebrew uninstall is a separate confirmation step.
|
|
129
|
+
- The force-uninstall fallback (for already-removed apps still registered in Homebrew) defaults to **No**.
|
|
130
|
+
|
|
131
|
+
---
|
|
50
132
|
|
|
51
133
|
## Credits
|
|
52
134
|
|
|
53
|
-
Inspired
|
|
135
|
+
Inspired by [App Eraser](https://github.com/davunt/app-eraser) and [AppCleaner](https://freemacsoft.net/appcleaner/).
|
package/index.js
CHANGED
|
@@ -3,61 +3,97 @@ import inquirer from "inquirer"
|
|
|
3
3
|
import signale from "signale"
|
|
4
4
|
import trash from "trash"
|
|
5
5
|
import chalk from "chalk"
|
|
6
|
-
import {
|
|
6
|
+
import { $, spinner } from "zx"
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
appNameFromPath,
|
|
10
|
-
|
|
10
|
+
getCaskInfo,
|
|
11
11
|
getBundleIdentifier,
|
|
12
12
|
findAppFiles,
|
|
13
13
|
} from "./lib/index.js"
|
|
14
14
|
|
|
15
|
-
$.verbose =
|
|
15
|
+
$.verbose = process.env.SOAP_DEBUG === "1"
|
|
16
16
|
|
|
17
17
|
const [param] = process.argv.slice(2)
|
|
18
18
|
|
|
19
|
-
if (!param) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
if (!param || param === "--help" || param === "-h") {
|
|
20
|
+
if (param === "--help" || param === "-h") {
|
|
21
|
+
console.log(`
|
|
22
|
+
${chalk.bold("soap")} 🧼 ${chalk.italic("the app cleaner")}
|
|
23
|
+
|
|
24
|
+
${chalk.bold("Usage:")}
|
|
25
|
+
soap ${chalk.cyan("<cask-name>")} | ${chalk.cyan("<path-to-app>")}
|
|
26
|
+
|
|
27
|
+
${chalk.bold("Examples:")}
|
|
28
|
+
soap ${chalk.green("spotify")} Uninstall Spotify (cask) + all its leftover files
|
|
29
|
+
soap ${chalk.green("android-studio")} Uninstall Android Studio (cask)
|
|
30
|
+
soap ${chalk.green("/Applications/Slack.app")}
|
|
31
|
+
Uninstall Slack by path (no brew step)
|
|
32
|
+
|
|
33
|
+
${chalk.bold("What it removes:")}
|
|
34
|
+
${chalk.dim("·")} The .app bundle ${chalk.dim("(via brew uninstall --zap or manual selection)")}
|
|
35
|
+
${chalk.dim("·")} Preferences ${chalk.dim("~/Library/Preferences/com.<vendor>.<app>.plist")}
|
|
36
|
+
${chalk.dim("·")} Caches ${chalk.dim("~/Library/Caches/com.<vendor>.<app>")}
|
|
37
|
+
${chalk.dim("·")} App support ${chalk.dim("~/Library/Application Support/<App>")}
|
|
38
|
+
${chalk.dim("·")} Containers ${chalk.dim("~/Library/Containers/com.<vendor>.<app>")}
|
|
39
|
+
${chalk.dim("·")} Launch agents, logs, crash reports, DMG files, and more
|
|
40
|
+
|
|
41
|
+
${chalk.bold("Environment:")}
|
|
42
|
+
${chalk.yellow("SOAP_DEBUG=1")} Enable verbose shell output
|
|
43
|
+
`)
|
|
44
|
+
process.exit(0)
|
|
45
|
+
}
|
|
46
|
+
console.error(
|
|
47
|
+
chalk.red("No parameter specified. Run `soap --help` for usage."),
|
|
48
|
+
)
|
|
49
|
+
process.exit(1)
|
|
23
50
|
}
|
|
24
51
|
|
|
25
52
|
const isCask = !param.includes(".app")
|
|
26
53
|
|
|
27
54
|
console.log(
|
|
28
|
-
|
|
55
|
+
`\nWelcome to ${chalk.bold("soap")} 🧼, ${chalk.italic("the app cleaner")}.\n`,
|
|
29
56
|
)
|
|
30
57
|
|
|
31
58
|
try {
|
|
32
|
-
const appName = isCask
|
|
33
|
-
? await
|
|
34
|
-
: appNameFromPath(param)
|
|
59
|
+
const { appName, zapFiles } = isCask
|
|
60
|
+
? await spinner(chalk.dim("Fetching cask info…"), () => getCaskInfo(param))
|
|
61
|
+
: { appName: appNameFromPath(param), zapFiles: [] }
|
|
62
|
+
|
|
63
|
+
if (!appName) {
|
|
64
|
+
signale.error(`Could not determine app name for "${param}".`)
|
|
65
|
+
process.exit(1)
|
|
66
|
+
}
|
|
67
|
+
|
|
35
68
|
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
69
|
|
|
40
|
-
|
|
41
|
-
|
|
70
|
+
const scannedFiles = await spinner(chalk.dim("Scanning for files…"), () =>
|
|
71
|
+
findAppFiles(appName, bundleId),
|
|
42
72
|
)
|
|
43
73
|
|
|
74
|
+
const appFiles = [
|
|
75
|
+
...new Set([
|
|
76
|
+
...(isCask ? scannedFiles.slice(1) : scannedFiles),
|
|
77
|
+
...zapFiles,
|
|
78
|
+
]),
|
|
79
|
+
]
|
|
80
|
+
const isAppFilesEmpty = appFiles.length === 0
|
|
81
|
+
|
|
82
|
+
signale.info(`Cleaning: ${chalk.bold(appName)}`)
|
|
44
83
|
signale.info(
|
|
45
84
|
isCask
|
|
46
|
-
? `
|
|
47
|
-
: `
|
|
48
|
-
"No homebrew cask will be deleted then",
|
|
49
|
-
)}.`,
|
|
85
|
+
? `Mode: ${chalk.bold("cask")} (${param})`
|
|
86
|
+
: `Mode: ${chalk.bold("path")} — no Homebrew uninstall will run`,
|
|
50
87
|
)
|
|
51
88
|
|
|
52
89
|
console.log("")
|
|
53
90
|
|
|
54
|
-
const { deletedFilesWish } = await inquirer.prompt([
|
|
91
|
+
const { deletedFilesWish, deletedCaskWish } = await inquirer.prompt([
|
|
55
92
|
{
|
|
56
93
|
type: "checkbox",
|
|
57
94
|
name: "deletedFilesWish",
|
|
58
95
|
when: !isAppFilesEmpty,
|
|
59
|
-
message:
|
|
60
|
-
"This is what I've found about it. Please select the files you want to delete.",
|
|
96
|
+
message: `Found ${chalk.bold(appFiles.length)} files. Select what to move to Trash:`,
|
|
61
97
|
choices: appFiles.map((appFile) => ({
|
|
62
98
|
name: appFile,
|
|
63
99
|
value: appFile,
|
|
@@ -68,52 +104,56 @@ try {
|
|
|
68
104
|
type: "confirm",
|
|
69
105
|
when: isCask,
|
|
70
106
|
name: "deletedCaskWish",
|
|
71
|
-
message: `
|
|
107
|
+
message: `Run ${chalk.bold(`brew uninstall --zap ${param}`)}?`,
|
|
108
|
+
default: true,
|
|
72
109
|
},
|
|
73
110
|
])
|
|
74
111
|
|
|
75
|
-
if (!isAppFilesEmpty) {
|
|
112
|
+
if (!isAppFilesEmpty && deletedFilesWish?.length > 0) {
|
|
76
113
|
await trash(deletedFilesWish, { force: true })
|
|
77
114
|
|
|
78
115
|
console.log("")
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
116
|
+
signale.success(
|
|
117
|
+
`Moved ${chalk.bold(deletedFilesWish.length)} file(s) to Trash:`,
|
|
118
|
+
)
|
|
119
|
+
deletedFilesWish.forEach((p) =>
|
|
120
|
+
console.log(` ${chalk.dim("·")} ${chalk.dim(p)}`),
|
|
121
|
+
)
|
|
122
|
+
} else if (!isAppFilesEmpty) {
|
|
123
|
+
signale.info("No files selected — nothing moved to Trash.")
|
|
82
124
|
}
|
|
83
125
|
|
|
84
|
-
if (isCask) {
|
|
126
|
+
if (isCask && deletedCaskWish) {
|
|
85
127
|
console.log("")
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
await $`brew uninstall ${param}`
|
|
128
|
+
signale.pending("Running Homebrew uninstall…")
|
|
129
|
+
await $`brew uninstall --zap ${param}`
|
|
90
130
|
}
|
|
91
131
|
|
|
92
132
|
console.log("")
|
|
93
|
-
|
|
94
|
-
signale.success("Operation successful.")
|
|
133
|
+
signale.success("Done.")
|
|
95
134
|
} catch (error) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
135
|
+
console.log("")
|
|
136
|
+
signale.error(error.message ?? "An unexpected error occurred.")
|
|
137
|
+
|
|
138
|
+
if (process.env.SOAP_DEBUG === "1") {
|
|
139
|
+
console.error(error)
|
|
140
|
+
}
|
|
99
141
|
|
|
100
142
|
if (isCask) {
|
|
101
|
-
inquirer
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
})
|
|
143
|
+
const { forceUninstall } = await inquirer.prompt([
|
|
144
|
+
{
|
|
145
|
+
type: "confirm",
|
|
146
|
+
name: "forceUninstall",
|
|
147
|
+
message: `The app may already be removed but the cask is still registered.\nForce-run ${chalk.bold(`brew uninstall --zap --force ${param}`)}?`,
|
|
148
|
+
default: false,
|
|
149
|
+
},
|
|
150
|
+
])
|
|
151
|
+
|
|
152
|
+
if (forceUninstall) {
|
|
153
|
+
console.log("")
|
|
154
|
+
signale.pending("Force-uninstalling cask…")
|
|
155
|
+
await $`brew uninstall --zap --force ${param}`
|
|
156
|
+
signale.success("Force uninstall complete.")
|
|
157
|
+
}
|
|
118
158
|
}
|
|
119
159
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
appNameFromPath,
|
|
4
|
+
createNameVariations,
|
|
5
|
+
normalizeString,
|
|
6
|
+
} from "../index.js"
|
|
7
|
+
|
|
8
|
+
describe("appNameFromPath", () => {
|
|
9
|
+
it("extracts name from full path", () => {
|
|
10
|
+
expect(appNameFromPath("/Applications/Spotify.app")).toBe("Spotify")
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("extracts name from filename only", () => {
|
|
14
|
+
expect(appNameFromPath("Spotify.app")).toBe("Spotify")
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it("handles app names with spaces", () => {
|
|
18
|
+
expect(appNameFromPath("/Applications/Affinity Designer 2.app")).toBe(
|
|
19
|
+
"Affinity Designer 2",
|
|
20
|
+
)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("handles nested path", () => {
|
|
24
|
+
expect(appNameFromPath("/Users/kud/Downloads/MyApp.app")).toBe("MyApp")
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe("normalizeString", () => {
|
|
29
|
+
it("lowercases the string", () => {
|
|
30
|
+
expect(normalizeString("Spotify")).toBe("spotify")
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("replaces spaces with empty string by default", () => {
|
|
34
|
+
expect(normalizeString("Affinity Designer")).toBe("affinitydesigner")
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("replaces spaces with custom spacer", () => {
|
|
38
|
+
expect(normalizeString("Affinity Designer", "-")).toBe("affinity-designer")
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe("createNameVariations", () => {
|
|
43
|
+
it("returns all expected variation keys for a simple name", () => {
|
|
44
|
+
const variations = createNameVariations("Spotify", "com.spotify.client")
|
|
45
|
+
expect(variations).toContain("spotify")
|
|
46
|
+
expect(variations).toContain("com.spotify.client")
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("produces dash and underscore variants for multi-word names", () => {
|
|
50
|
+
const variations = createNameVariations(
|
|
51
|
+
"Affinity Designer",
|
|
52
|
+
"com.affinity.designer2",
|
|
53
|
+
)
|
|
54
|
+
expect(variations).toContain("affinity-designer")
|
|
55
|
+
expect(variations).toContain("affinity_designer")
|
|
56
|
+
expect(variations).toContain("affinity.designer")
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("returns 6 variations", () => {
|
|
60
|
+
const variations = createNameVariations("Spotify", "com.spotify.client")
|
|
61
|
+
expect(variations).toHaveLength(6)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { fileRegex } from "../file-regex.js"
|
|
3
|
+
|
|
4
|
+
const strip = (str) => {
|
|
5
|
+
let result = str
|
|
6
|
+
fileRegex.forEach((r) => (result = result.replace(r, "")))
|
|
7
|
+
return result
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("fileRegex", () => {
|
|
11
|
+
it("strips UUID patterns", () => {
|
|
12
|
+
expect(
|
|
13
|
+
strip("app-550e8400-e29b-41d4-a716-446655440000.plist"),
|
|
14
|
+
).not.toContain("550e8400-e29b-41d4-a716-446655440000")
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it("strips date patterns", () => {
|
|
18
|
+
expect(strip("crash-2024-03-15-142500.log")).not.toContain(
|
|
19
|
+
"2024-03-15-142500",
|
|
20
|
+
)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("strips .app extension", () => {
|
|
24
|
+
expect(strip("Spotify.app")).toBe("Spotify")
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("strips .plist extension", () => {
|
|
28
|
+
expect(strip("com.spotify.client.plist")).toBe("com.spotify.client")
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("strips .dmg extension", () => {
|
|
32
|
+
expect(strip("Spotify.dmg")).toBe("Spotify")
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("strips .savedState extension", () => {
|
|
36
|
+
expect(strip("Spotify.savedState")).toBe("Spotify")
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("strips diag resource pattern", () => {
|
|
40
|
+
expect(strip("Spotify_resource.diag")).not.toContain("_resource.diag")
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("leaves unrelated strings intact", () => {
|
|
44
|
+
expect(strip("spotify")).toBe("spotify")
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest"
|
|
2
|
+
import { isPatternInFile } from "../index.js"
|
|
3
|
+
|
|
4
|
+
// isPatternInFile relies on the module-level compNameGlob for stripString.
|
|
5
|
+
// We test it with patterns and filenames where the computer name is irrelevant.
|
|
6
|
+
|
|
7
|
+
describe("isPatternInFile", () => {
|
|
8
|
+
it("matches when pattern is prominent in the filename", () => {
|
|
9
|
+
const result = isPatternInFile(["spotify"], "spotify.plist")
|
|
10
|
+
expect(result).toBeTruthy()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("matches bundle-id style filename", () => {
|
|
14
|
+
const result = isPatternInFile(
|
|
15
|
+
["com.spotify.client"],
|
|
16
|
+
"com.spotify.client.plist",
|
|
17
|
+
)
|
|
18
|
+
expect(result).toBeTruthy()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("does not match when pattern is a minor substring of a long unrelated filename", () => {
|
|
22
|
+
// 'it' is a substring of 'microsoft' but far too short relative to the full filename
|
|
23
|
+
const result = isPatternInFile(
|
|
24
|
+
["it"],
|
|
25
|
+
"com.microsoft.office.reminders.plist",
|
|
26
|
+
)
|
|
27
|
+
expect(result).toBeFalsy()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("returns falsy when no patterns match", () => {
|
|
31
|
+
const result = isPatternInFile(["spotify"], "com.apple.finder.plist")
|
|
32
|
+
expect(result).toBeFalsy()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("returns falsy for empty patterns array", () => {
|
|
36
|
+
const result = isPatternInFile([], "spotify.plist")
|
|
37
|
+
expect(result).toBeFalsy()
|
|
38
|
+
})
|
|
39
|
+
})
|
package/lib/index.js
CHANGED
|
@@ -3,12 +3,13 @@ $.verbose = false
|
|
|
3
3
|
|
|
4
4
|
import { execSync } from "child_process"
|
|
5
5
|
import fs from "fs/promises"
|
|
6
|
+
import { homedir } from "os"
|
|
6
7
|
import { pathLocations, commonSuffix } from "./path-locations.js"
|
|
7
8
|
import { fileRegex } from "./file-regex.js"
|
|
8
9
|
|
|
9
10
|
const scoreThreshold = 0.4
|
|
10
11
|
|
|
11
|
-
let compNameGlob
|
|
12
|
+
let compNameGlob = ""
|
|
12
13
|
|
|
13
14
|
function stripString(file) {
|
|
14
15
|
let transformedString = file
|
|
@@ -26,7 +27,7 @@ function stripString(file) {
|
|
|
26
27
|
return transformedString
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
const normalizeString = (str, spacer = "") =>
|
|
30
|
+
export const normalizeString = (str, spacer = "") =>
|
|
30
31
|
str.toLowerCase().replace(/ /g, spacer)
|
|
31
32
|
|
|
32
33
|
async function getFilePatternArray(appName, bundleId) {
|
|
@@ -56,13 +57,14 @@ async function getFilePatternArray(appName, bundleId) {
|
|
|
56
57
|
),
|
|
57
58
|
)
|
|
58
59
|
|
|
59
|
-
patternArray = [...patternArray,
|
|
60
|
+
patternArray = [...patternArray, ...appWithSuffix]
|
|
60
61
|
|
|
61
62
|
return patternArray
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
function isPatternInFile(patterns, fileToCheck) {
|
|
65
|
+
export function isPatternInFile(patterns, fileToCheck) {
|
|
65
66
|
return patterns.find((filePatten) => {
|
|
67
|
+
if (typeof filePatten !== "string") return false
|
|
66
68
|
if (fileToCheck.includes(filePatten)) {
|
|
67
69
|
const strippedFile = stripString(fileToCheck)
|
|
68
70
|
|
|
@@ -85,14 +87,13 @@ function isPatternInFile(patterns, fileToCheck) {
|
|
|
85
87
|
})
|
|
86
88
|
}
|
|
87
89
|
|
|
88
|
-
function createNameVariations(appName, bundleId) {
|
|
89
|
-
const appNameNorm = appName.toLowerCase().replace(
|
|
90
|
-
const appNameWithoutDot = appNameNorm.
|
|
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(" ", "")
|
|
90
|
+
export function createNameVariations(appName, bundleId) {
|
|
91
|
+
const appNameNorm = appName.toLowerCase().replace(/ /g, "")
|
|
92
|
+
const appNameWithoutDot = appNameNorm.replace(/\./g, "")
|
|
93
|
+
const appNameUnderscore = appName.toLowerCase().replace(/ /g, "_")
|
|
94
|
+
const appNameDash = appName.toLowerCase().replace(/ /g, "-")
|
|
95
|
+
const appNameDot = appName.toLowerCase().replace(/ /g, ".")
|
|
96
|
+
const bundleIdNorm = bundleId.toLowerCase().replace(/ /g, "")
|
|
96
97
|
|
|
97
98
|
return [
|
|
98
99
|
appNameNorm,
|
|
@@ -104,49 +105,98 @@ function createNameVariations(appName, bundleId) {
|
|
|
104
105
|
]
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
function appNameFromPath(appPath) {
|
|
108
|
+
export function appNameFromPath(appPath) {
|
|
108
109
|
const pathArr = appPath.split("/")
|
|
109
110
|
const appNameWithExt = pathArr[pathArr.length - 1]
|
|
110
|
-
// remove .app extension
|
|
111
111
|
return appNameWithExt.replace(".app", "")
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
async function fetchCaskData(caskName) {
|
|
115
|
+
const { stdout: info } = await $`brew info ${caskName} --json=v2`
|
|
116
|
+
const caskData = JSON.parse(info).casks?.[0]
|
|
117
|
+
if (!caskData) throw new Error(`Cask "${caskName}" not found.`)
|
|
118
|
+
return caskData
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function resolveZapPaths(caskData) {
|
|
122
|
+
const zapPaths = caskData.artifacts
|
|
123
|
+
.filter((a) => a.zap)
|
|
124
|
+
.flatMap((a) => a.zap)
|
|
125
|
+
.filter((entry) => entry.trash)
|
|
126
|
+
.flatMap((entry) => entry.trash)
|
|
127
|
+
.map((p) => p.replace(/^~/, homedir()))
|
|
128
|
+
|
|
129
|
+
const resolved = await Promise.all(
|
|
130
|
+
zapPaths.map(async (p) => {
|
|
131
|
+
if (p.includes("*")) {
|
|
132
|
+
const matches = []
|
|
133
|
+
for await (const f of fs.glob(p)) matches.push(f)
|
|
134
|
+
return matches
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
await fs.access(p)
|
|
138
|
+
return [p]
|
|
139
|
+
} catch {
|
|
140
|
+
return []
|
|
141
|
+
}
|
|
142
|
+
}),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return resolved.flat()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const getCaskInfo = async (caskName) => {
|
|
115
149
|
try {
|
|
116
|
-
const
|
|
117
|
-
const caskData = JSON.parse(info).casks?.[0]
|
|
150
|
+
const caskData = await fetchCaskData(caskName)
|
|
118
151
|
|
|
119
|
-
|
|
120
|
-
|
|
152
|
+
let appName = null
|
|
153
|
+
for (const artifact of caskData.artifacts) {
|
|
154
|
+
if (artifact.app) {
|
|
155
|
+
appName = artifact.app[0]
|
|
156
|
+
break
|
|
157
|
+
}
|
|
121
158
|
}
|
|
122
159
|
|
|
123
|
-
|
|
124
|
-
} catch (error) {
|
|
125
|
-
console.error(`Error fetching app name for cask ${caskName}:`, error)
|
|
160
|
+
const zapFiles = await resolveZapPaths(caskData)
|
|
126
161
|
|
|
127
|
-
return
|
|
162
|
+
return { appName, zapFiles }
|
|
163
|
+
} catch (error) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`Failed to get cask info for "${caskName}": ${error.message}`,
|
|
166
|
+
)
|
|
128
167
|
}
|
|
129
168
|
}
|
|
130
169
|
|
|
131
|
-
async
|
|
132
|
-
const
|
|
170
|
+
export const appNameFromCaskName = async (caskName) => {
|
|
171
|
+
const { appName } = await getCaskInfo(caskName)
|
|
172
|
+
return appName
|
|
173
|
+
}
|
|
133
174
|
|
|
134
|
-
|
|
135
|
-
|
|
175
|
+
export const getZapFiles = async (caskName) => {
|
|
176
|
+
const { zapFiles } = await getCaskInfo(caskName)
|
|
177
|
+
return zapFiles
|
|
136
178
|
}
|
|
137
179
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
).toString()
|
|
180
|
+
function getComputerName() {
|
|
181
|
+
return execSync("scutil --get ComputerName").toString().trimEnd()
|
|
182
|
+
}
|
|
142
183
|
|
|
143
|
-
|
|
144
|
-
|
|
184
|
+
export async function getBundleIdentifier(appName) {
|
|
185
|
+
try {
|
|
186
|
+
const bundleId = execSync(
|
|
187
|
+
`osascript -e 'id of app "${appName}"'`,
|
|
188
|
+
).toString()
|
|
189
|
+
return bundleId.trimEnd()
|
|
190
|
+
} catch {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`Could not find bundle identifier for "${appName}". Is the app installed?`,
|
|
193
|
+
)
|
|
194
|
+
}
|
|
145
195
|
}
|
|
146
196
|
|
|
147
|
-
async function findAppFiles(appName, bundleId) {
|
|
197
|
+
export async function findAppFiles(appName, bundleId) {
|
|
148
198
|
try {
|
|
149
|
-
compNameGlob =
|
|
199
|
+
compNameGlob = getComputerName()
|
|
150
200
|
const bundleIdComponents = bundleId.split(".")
|
|
151
201
|
|
|
152
202
|
const companyDirs = pathLocations.map(
|
|
@@ -165,7 +215,7 @@ async function findAppFiles(appName, bundleId) {
|
|
|
165
215
|
directoryFiles.forEach((dir, index) => {
|
|
166
216
|
if (dir.status === "fulfilled") {
|
|
167
217
|
dir.value.forEach((dirFile) => {
|
|
168
|
-
const dirFileNorm = dirFile.toLowerCase().replace(
|
|
218
|
+
const dirFileNorm = dirFile.toLowerCase().replace(/ /g, "")
|
|
169
219
|
if (isPatternInFile(patternArray, dirFileNorm)) {
|
|
170
220
|
filesToRemove.add(
|
|
171
221
|
`${pathsToSearch[parseInt(index, 10)]}/${dirFile}`,
|
|
@@ -175,12 +225,9 @@ async function findAppFiles(appName, bundleId) {
|
|
|
175
225
|
}
|
|
176
226
|
})
|
|
177
227
|
|
|
178
|
-
// convert set to array
|
|
179
228
|
return [...filesToRemove]
|
|
180
229
|
} catch (err) {
|
|
181
230
|
console.error(err)
|
|
182
231
|
throw err
|
|
183
232
|
}
|
|
184
233
|
}
|
|
185
|
-
|
|
186
|
-
export { appNameFromPath, getBundleIdentifier, findAppFiles }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kud/soap-cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "An app cleaner cli for macOS",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
13
|
"dev": "node ./index.js",
|
|
14
|
-
"test": "
|
|
14
|
+
"test": "vitest run"
|
|
15
15
|
},
|
|
16
16
|
"repository": {
|
|
17
17
|
"type": "git",
|
|
@@ -26,12 +26,15 @@
|
|
|
26
26
|
"homepage": "https://github.com/kud/soap-cli#readme",
|
|
27
27
|
"type": "module",
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
29
|
+
"@kud/soap-cli": "1.0.0",
|
|
30
|
+
"chalk": "5.6.2",
|
|
31
|
+
"del": "8.0.1",
|
|
32
|
+
"inquirer": "13.3.2",
|
|
32
33
|
"signale": "1.4.0",
|
|
33
|
-
"trash": "
|
|
34
|
-
"zx": "
|
|
34
|
+
"trash": "10.1.1",
|
|
35
|
+
"zx": "8.8.5"
|
|
35
36
|
},
|
|
36
|
-
"devDependencies": {
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"vitest": "4.1.2"
|
|
39
|
+
}
|
|
37
40
|
}
|