@kud/soap-cli 1.0.0 → 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 +94 -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 +83 -41
- 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,62 +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
|
+
}
|
|
35
67
|
|
|
36
68
|
const bundleId = await getBundleIdentifier(appName)
|
|
37
|
-
const _appFiles = await findAppFiles(appName, bundleId)
|
|
38
|
-
const appFiles = isCask ? _appFiles.slice(1) : _appFiles
|
|
39
|
-
const isAppFilesEmpty = appFiles.length === 0
|
|
40
69
|
|
|
41
|
-
|
|
42
|
-
|
|
70
|
+
const scannedFiles = await spinner(chalk.dim("Scanning for files…"), () =>
|
|
71
|
+
findAppFiles(appName, bundleId),
|
|
43
72
|
)
|
|
44
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)}`)
|
|
45
83
|
signale.info(
|
|
46
84
|
isCask
|
|
47
|
-
? `
|
|
48
|
-
: `
|
|
49
|
-
"No homebrew cask will be deleted then",
|
|
50
|
-
)}.`,
|
|
85
|
+
? `Mode: ${chalk.bold("cask")} (${param})`
|
|
86
|
+
: `Mode: ${chalk.bold("path")} — no Homebrew uninstall will run`,
|
|
51
87
|
)
|
|
52
88
|
|
|
53
89
|
console.log("")
|
|
54
90
|
|
|
55
|
-
const { deletedFilesWish } = await inquirer.prompt([
|
|
91
|
+
const { deletedFilesWish, deletedCaskWish } = await inquirer.prompt([
|
|
56
92
|
{
|
|
57
93
|
type: "checkbox",
|
|
58
94
|
name: "deletedFilesWish",
|
|
59
95
|
when: !isAppFilesEmpty,
|
|
60
|
-
message:
|
|
61
|
-
"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:`,
|
|
62
97
|
choices: appFiles.map((appFile) => ({
|
|
63
98
|
name: appFile,
|
|
64
99
|
value: appFile,
|
|
@@ -69,52 +104,56 @@ try {
|
|
|
69
104
|
type: "confirm",
|
|
70
105
|
when: isCask,
|
|
71
106
|
name: "deletedCaskWish",
|
|
72
|
-
message: `
|
|
107
|
+
message: `Run ${chalk.bold(`brew uninstall --zap ${param}`)}?`,
|
|
108
|
+
default: true,
|
|
73
109
|
},
|
|
74
110
|
])
|
|
75
111
|
|
|
76
|
-
if (!isAppFilesEmpty) {
|
|
112
|
+
if (!isAppFilesEmpty && deletedFilesWish?.length > 0) {
|
|
77
113
|
await trash(deletedFilesWish, { force: true })
|
|
78
114
|
|
|
79
115
|
console.log("")
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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.")
|
|
83
124
|
}
|
|
84
125
|
|
|
85
|
-
if (isCask) {
|
|
126
|
+
if (isCask && deletedCaskWish) {
|
|
86
127
|
console.log("")
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
await $`brew uninstall ${param}`
|
|
128
|
+
signale.pending("Running Homebrew uninstall…")
|
|
129
|
+
await $`brew uninstall --zap ${param}`
|
|
91
130
|
}
|
|
92
131
|
|
|
93
132
|
console.log("")
|
|
94
|
-
|
|
95
|
-
signale.success("Operation successful.")
|
|
133
|
+
signale.success("Done.")
|
|
96
134
|
} catch (error) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
+
}
|
|
100
141
|
|
|
101
142
|
if (isCask) {
|
|
102
|
-
inquirer
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
})
|
|
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
|
+
}
|
|
119
158
|
}
|
|
120
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,54 +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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
+
}
|
|
118
120
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
)
|
|
122
144
|
|
|
145
|
+
return resolved.flat()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const getCaskInfo = async (caskName) => {
|
|
149
|
+
try {
|
|
150
|
+
const caskData = await fetchCaskData(caskName)
|
|
151
|
+
|
|
152
|
+
let appName = null
|
|
123
153
|
for (const artifact of caskData.artifacts) {
|
|
124
154
|
if (artifact.app) {
|
|
125
|
-
|
|
155
|
+
appName = artifact.app[0]
|
|
156
|
+
break
|
|
126
157
|
}
|
|
127
158
|
}
|
|
128
159
|
|
|
129
|
-
|
|
160
|
+
const zapFiles = await resolveZapPaths(caskData)
|
|
161
|
+
|
|
162
|
+
return { appName, zapFiles }
|
|
130
163
|
} catch (error) {
|
|
131
|
-
|
|
132
|
-
|
|
164
|
+
throw new Error(
|
|
165
|
+
`Failed to get cask info for "${caskName}": ${error.message}`,
|
|
166
|
+
)
|
|
133
167
|
}
|
|
134
168
|
}
|
|
135
169
|
|
|
136
|
-
async
|
|
137
|
-
const
|
|
170
|
+
export const appNameFromCaskName = async (caskName) => {
|
|
171
|
+
const { appName } = await getCaskInfo(caskName)
|
|
172
|
+
return appName
|
|
173
|
+
}
|
|
138
174
|
|
|
139
|
-
|
|
140
|
-
|
|
175
|
+
export const getZapFiles = async (caskName) => {
|
|
176
|
+
const { zapFiles } = await getCaskInfo(caskName)
|
|
177
|
+
return zapFiles
|
|
141
178
|
}
|
|
142
179
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
).toString()
|
|
180
|
+
function getComputerName() {
|
|
181
|
+
return execSync("scutil --get ComputerName").toString().trimEnd()
|
|
182
|
+
}
|
|
147
183
|
|
|
148
|
-
|
|
149
|
-
|
|
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
|
+
}
|
|
150
195
|
}
|
|
151
196
|
|
|
152
|
-
async function findAppFiles(appName, bundleId) {
|
|
197
|
+
export async function findAppFiles(appName, bundleId) {
|
|
153
198
|
try {
|
|
154
|
-
compNameGlob =
|
|
199
|
+
compNameGlob = getComputerName()
|
|
155
200
|
const bundleIdComponents = bundleId.split(".")
|
|
156
201
|
|
|
157
202
|
const companyDirs = pathLocations.map(
|
|
@@ -170,7 +215,7 @@ async function findAppFiles(appName, bundleId) {
|
|
|
170
215
|
directoryFiles.forEach((dir, index) => {
|
|
171
216
|
if (dir.status === "fulfilled") {
|
|
172
217
|
dir.value.forEach((dirFile) => {
|
|
173
|
-
const dirFileNorm = dirFile.toLowerCase().replace(
|
|
218
|
+
const dirFileNorm = dirFile.toLowerCase().replace(/ /g, "")
|
|
174
219
|
if (isPatternInFile(patternArray, dirFileNorm)) {
|
|
175
220
|
filesToRemove.add(
|
|
176
221
|
`${pathsToSearch[parseInt(index, 10)]}/${dirFile}`,
|
|
@@ -180,12 +225,9 @@ async function findAppFiles(appName, bundleId) {
|
|
|
180
225
|
}
|
|
181
226
|
})
|
|
182
227
|
|
|
183
|
-
// convert set to array
|
|
184
228
|
return [...filesToRemove]
|
|
185
229
|
} catch (err) {
|
|
186
230
|
console.error(err)
|
|
187
231
|
throw err
|
|
188
232
|
}
|
|
189
233
|
}
|
|
190
|
-
|
|
191
|
-
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
|
-
"@kud/soap-cli": "1.0.0
|
|
30
|
-
"chalk": "5.
|
|
31
|
-
"del": "
|
|
32
|
-
"inquirer": "
|
|
29
|
+
"@kud/soap-cli": "1.0.0",
|
|
30
|
+
"chalk": "5.6.2",
|
|
31
|
+
"del": "8.0.1",
|
|
32
|
+
"inquirer": "13.3.2",
|
|
33
33
|
"signale": "1.4.0",
|
|
34
|
-
"trash": "
|
|
35
|
-
"zx": "8.
|
|
34
|
+
"trash": "10.1.1",
|
|
35
|
+
"zx": "8.8.5"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"vitest": "4.1.2"
|
|
36
39
|
}
|
|
37
40
|
}
|