@kud/soap-cli 1.0.0 → 3.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/README.md CHANGED
@@ -1,53 +1,152 @@
1
- # Soap 🧼 - An app cleaner cli for macOS
1
+ # soap 🧼
2
2
 
3
- > I was so lazy to use any GUI to clean my macOS. - @kud
3
+ > _"I was so lazy to use any GUI to clean my macOS."_ @kud
4
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).
5
+ A macOS CLI that removes an app **and all its leftover files** preferences, caches, containers, launch agents, and more.
6
6
 
7
- ## Motivation
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
- 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_.
9
+ ---
10
10
 
11
11
  ## Install
12
12
 
13
- ```shell
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
- You've got two ways to uninstall an application, via its path or via its cask name.
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
- #### Application path
33
+ **Examples:**
22
34
 
23
- ```shell
24
- soap <app-path>
25
- # ex: soap '/Applications/Android Studio.app'
35
+ ```sh
36
+ soap spotify
37
+ soap android-studio
38
+ soap '/Applications/Android Studio.app'
26
39
  ```
27
40
 
28
- _warning: it won't remove the cask formula even if you've installed the app via homebrew._
41
+ ---
42
+
43
+ ## What it does
29
44
 
30
- #### Cask name
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.
57
+
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)
36
70
 
37
- It will delete for instance this kind of files:
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
+ ◉ ...
38
78
 
79
+ ? Run `brew uninstall --zap spotify`? Yes
80
+
81
+ ✔ Moved 11 file(s) to Trash.
82
+
83
+ ◼ Running Homebrew uninstall…
84
+ ✔ Done.
85
+ ```
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
39
118
  ```
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
119
+
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
+ ---
132
+
133
+ ## Contributing
134
+
135
+ Built with TypeScript + ESM. Requires Node 20+.
136
+
137
+ ```sh
138
+ git clone https://github.com/kud/soap-cli.git
139
+ cd soap-cli
140
+ npm install
141
+
142
+ npm run dev # run from source via tsx
143
+ npm run build # compile to dist/
144
+ npm run typecheck # type-check without emitting
145
+ npm test # vitest unit tests
47
146
  ```
48
147
 
49
- No worries, soap will move them into your Trash, so if something wrong has happened, you can check there your files.
148
+ ---
50
149
 
51
150
  ## Credits
52
151
 
53
- Inspired from [App Eraser](https://github.com/davunt/app-eraser)
152
+ Inspired by [App Eraser](https://github.com/davunt/app-eraser) and [AppCleaner](https://freemacsoft.net/appcleaner/).
package/dist/index.js ADDED
@@ -0,0 +1,381 @@
1
+ #!/usr/bin/env node
2
+
3
+ // index.ts
4
+ import inquirer from "inquirer";
5
+ import signale from "signale";
6
+ import trash from "trash";
7
+ import chalk from "chalk";
8
+ import { $ as $2, spinner } from "zx";
9
+
10
+ // lib/index.ts
11
+ import { $ } from "zx";
12
+ import { execSync } from "child_process";
13
+ import fs from "fs/promises";
14
+ import { homedir as homedir2 } from "os";
15
+
16
+ // lib/path-locations.ts
17
+ import { homedir } from "os";
18
+ var pathLocations = [
19
+ "/Applications",
20
+ "/private/var/db/receipts",
21
+ "/Library/LaunchDaemons",
22
+ `${homedir()}/Downloads`,
23
+ `${homedir()}/Library`,
24
+ `${homedir()}/Library/Application Support`,
25
+ `${homedir()}/Library/Application Scripts`,
26
+ `${homedir()}/Library/Application Support/CrashReporter`,
27
+ `${homedir()}/Library/Containers`,
28
+ `${homedir()}/Library/Caches`,
29
+ `${homedir()}/Library/HTTPStorages`,
30
+ `${homedir()}/Library/Group Containers`,
31
+ `${homedir()}/Library/Internet Plug-Ins`,
32
+ `${homedir()}/Library/LaunchAgents`,
33
+ `${homedir()}/Library/Logs`,
34
+ "/Library/Logs/DiagnosticReports",
35
+ `${homedir()}/Library/Preferences`,
36
+ `${homedir()}/Library/Preferences/ByHost`,
37
+ `${homedir()}/Library/Saved Application State`,
38
+ `${homedir()}/Library/WebKit`,
39
+ `${homedir()}/Library/Caches/com.apple.helpd/Generated`,
40
+ "/Library/Audio/Plug-Ins/HAL"
41
+ ];
42
+ var commonSuffix = ["install"];
43
+
44
+ // lib/file-regex.ts
45
+ var uuidReg = /[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;
46
+ var dateReg = /[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}/g;
47
+ var diagReg = /.[a-zA-Z]+_resource.diag/;
48
+ var commonExtensions = [
49
+ ".dmg",
50
+ ".app",
51
+ ".bom",
52
+ ".plist",
53
+ ".XPCHelper",
54
+ ".beta",
55
+ ".extensions",
56
+ ".savedState",
57
+ ".driver",
58
+ ".wakeups_resource",
59
+ ".diag"
60
+ ];
61
+ var fileRegex = [
62
+ uuidReg,
63
+ dateReg,
64
+ diagReg,
65
+ ...commonExtensions
66
+ ];
67
+
68
+ // lib/index.ts
69
+ $.verbose = false;
70
+ var scoreThreshold = 0.4;
71
+ var compNameGlob = "";
72
+ var stripString = (file) => {
73
+ let transformedString = file;
74
+ fileRegex.forEach((regex1) => {
75
+ transformedString = transformedString.replace(regex1, "");
76
+ });
77
+ const normCompName = normalizeString(compNameGlob, "-").replace(/\u2019/g, "").replace(/\(/g, "").replace(/\)/g, "");
78
+ transformedString = transformedString.replace(normCompName, "");
79
+ return transformedString;
80
+ };
81
+ var normalizeString = (str, spacer = "") => str.toLowerCase().replace(/ /g, spacer);
82
+ var getFilePatternArray = async (appName, bundleId) => {
83
+ const nameVariations = createNameVariations(appName, bundleId);
84
+ const appNameNorm = normalizeString(appName);
85
+ const bundleIdNorm = normalizeString(bundleId);
86
+ let patternArray = [...nameVariations];
87
+ const appNameComponents = appNameNorm.split(".");
88
+ if (appNameComponents) patternArray.push(appNameComponents[0]);
89
+ const bundleIdComponents = bundleIdNorm.split(".");
90
+ if (bundleIdComponents.length > 2 && bundleIdComponents[bundleIdComponents.length - 1].toLowerCase() === "app") {
91
+ patternArray.push(
92
+ `${bundleIdComponents.slice(0, bundleIdComponents.length - 1).join(".")}`
93
+ );
94
+ }
95
+ const appWithSuffix = /* @__PURE__ */ new Set([]);
96
+ commonSuffix.forEach(
97
+ (suffix) => nameVariations.forEach(
98
+ (nameVariation) => appWithSuffix.add(`${nameVariation}${suffix}`)
99
+ )
100
+ );
101
+ patternArray = [...patternArray, ...appWithSuffix];
102
+ return patternArray;
103
+ };
104
+ var isPatternInFile = (patterns, fileToCheck) => {
105
+ return patterns.find((filePatten) => {
106
+ if (typeof filePatten !== "string") return false;
107
+ if (fileToCheck.includes(filePatten)) {
108
+ const strippedFile = stripString(fileToCheck);
109
+ let score = 0;
110
+ const indexOfString = strippedFile.indexOf(filePatten);
111
+ for (let i = 0; i < strippedFile.length; i += 1) {
112
+ if (i === indexOfString) {
113
+ i += indexOfString + filePatten.length;
114
+ score += filePatten.length;
115
+ }
116
+ if (strippedFile[parseInt(i.toString(), 10)] === ".") score += 0.5;
117
+ if (strippedFile[parseInt(i.toString(), 10)] === "_") score += 0.5;
118
+ }
119
+ if (score / strippedFile.length > scoreThreshold) {
120
+ return true;
121
+ }
122
+ return false;
123
+ }
124
+ return false;
125
+ });
126
+ };
127
+ var createNameVariations = (appName, bundleId) => {
128
+ const appNameNorm = appName.toLowerCase().replace(/ /g, "");
129
+ const appNameWithoutDot = appNameNorm.replace(/\./g, "");
130
+ const appNameUnderscore = appName.toLowerCase().replace(/ /g, "_");
131
+ const appNameDash = appName.toLowerCase().replace(/ /g, "-");
132
+ const appNameDot = appName.toLowerCase().replace(/ /g, ".");
133
+ const bundleIdNorm = bundleId.toLowerCase().replace(/ /g, "");
134
+ return [
135
+ appNameNorm,
136
+ appNameWithoutDot,
137
+ appNameUnderscore,
138
+ appNameDash,
139
+ appNameDot,
140
+ bundleIdNorm
141
+ ];
142
+ };
143
+ var appNameFromPath = (appPath) => {
144
+ const pathArr = appPath.split("/");
145
+ const appNameWithExt = pathArr[pathArr.length - 1];
146
+ return appNameWithExt.replace(".app", "");
147
+ };
148
+ var fetchCaskData = async (caskName) => {
149
+ const { stdout: info } = await $`brew info ${caskName} --json=v2`;
150
+ const caskData = JSON.parse(info).casks?.[0];
151
+ if (!caskData) throw new Error(`Cask "${caskName}" not found.`);
152
+ return caskData;
153
+ };
154
+ var resolveZapPaths = async (caskData) => {
155
+ const zapPaths = caskData.artifacts.filter((a) => a.zap).flatMap((a) => a.zap).filter((entry) => entry?.trash).flatMap((entry) => entry?.trash ?? []).map((p) => p.replace(/^~/, homedir2()));
156
+ const resolved = await Promise.all(
157
+ zapPaths.map(async (p) => {
158
+ if (p.includes("*")) {
159
+ const matches = [];
160
+ for await (const f of fs.glob(p)) matches.push(f);
161
+ return matches;
162
+ }
163
+ try {
164
+ await fs.access(p);
165
+ return [p];
166
+ } catch {
167
+ return [];
168
+ }
169
+ })
170
+ );
171
+ return resolved.flat();
172
+ };
173
+ var getCaskInfo = async (caskName) => {
174
+ try {
175
+ const caskData = await fetchCaskData(caskName);
176
+ let appName = null;
177
+ for (const artifact of caskData.artifacts) {
178
+ if (artifact.app) {
179
+ appName = artifact.app[0];
180
+ break;
181
+ }
182
+ }
183
+ const zapFiles = await resolveZapPaths(caskData);
184
+ return { appName, zapFiles };
185
+ } catch (error) {
186
+ throw new Error(
187
+ `Failed to get cask info for "${caskName}": ${error instanceof Error ? error.message : String(error)}`
188
+ );
189
+ }
190
+ };
191
+ var getComputerName = () => {
192
+ return execSync("scutil --get ComputerName").toString().trimEnd();
193
+ };
194
+ var getBundleIdentifier = async (appName) => {
195
+ try {
196
+ const bundleId = execSync(
197
+ `osascript -e 'id of app "${appName}"'`
198
+ ).toString();
199
+ return bundleId.trimEnd();
200
+ } catch {
201
+ throw new Error(
202
+ `Could not find bundle identifier for "${appName}". Is the app installed?`
203
+ );
204
+ }
205
+ };
206
+ var findAppFiles = async (appName, bundleId) => {
207
+ try {
208
+ compNameGlob = getComputerName();
209
+ const bundleIdComponents = bundleId.split(".");
210
+ const companyDirs = pathLocations.map(
211
+ (pathLocation) => `${pathLocation}/${bundleIdComponents[1]}`
212
+ );
213
+ const pathsToSearch = [...pathLocations, ...companyDirs];
214
+ const directoryFilesPromiseArr = pathsToSearch.map(
215
+ (pathLocation) => fs.readdir(pathLocation)
216
+ );
217
+ const directoryFiles = await Promise.allSettled(directoryFilesPromiseArr);
218
+ const patternArray = await getFilePatternArray(appName, bundleId);
219
+ const filesToRemove = /* @__PURE__ */ new Set([]);
220
+ directoryFiles.forEach((dir, index) => {
221
+ if (dir.status === "fulfilled") {
222
+ dir.value.forEach((dirFile) => {
223
+ const dirFileNorm = dirFile.toLowerCase().replace(/ /g, "");
224
+ if (isPatternInFile(patternArray, dirFileNorm)) {
225
+ filesToRemove.add(
226
+ `${pathsToSearch[parseInt(index.toString(), 10)]}/${dirFile}`
227
+ );
228
+ }
229
+ });
230
+ }
231
+ });
232
+ return [...filesToRemove];
233
+ } catch (err) {
234
+ console.error(err);
235
+ throw err;
236
+ }
237
+ };
238
+
239
+ // index.ts
240
+ $2.verbose = process.env.SOAP_DEBUG === "1";
241
+ var [param] = process.argv.slice(2);
242
+ var isFlag = param?.startsWith("-");
243
+ if (!param || param === "--help" || param === "-h" || param === "--version" || param === "-v" || isFlag && param !== "--help" && param !== "-h") {
244
+ if (param === "--version" || param === "-v") {
245
+ const { createRequire } = await import("module");
246
+ const require2 = createRequire(import.meta.url);
247
+ const { version } = require2("./package.json");
248
+ console.log(`soap v${version}`);
249
+ process.exit(0);
250
+ }
251
+ if (param === "--help" || param === "-h") {
252
+ console.log(`
253
+ ${chalk.bold("soap")} \u{1F9FC} ${chalk.italic("the app cleaner")}
254
+
255
+ ${chalk.bold("Usage:")}
256
+ soap ${chalk.cyan("<cask-name>")} | ${chalk.cyan("<path-to-app>")}
257
+
258
+ ${chalk.bold("Examples:")}
259
+ soap ${chalk.green("spotify")} Uninstall Spotify (cask) + all its leftover files
260
+ soap ${chalk.green("android-studio")} Uninstall Android Studio (cask)
261
+ soap ${chalk.green("/Applications/Slack.app")}
262
+ Uninstall Slack by path (no brew step)
263
+
264
+ ${chalk.bold("What it removes:")}
265
+ ${chalk.dim("\xB7")} The .app bundle ${chalk.dim("(via brew uninstall --zap or manual selection)")}
266
+ ${chalk.dim("\xB7")} Preferences ${chalk.dim("~/Library/Preferences/com.<vendor>.<app>.plist")}
267
+ ${chalk.dim("\xB7")} Caches ${chalk.dim("~/Library/Caches/com.<vendor>.<app>")}
268
+ ${chalk.dim("\xB7")} App support ${chalk.dim("~/Library/Application Support/<App>")}
269
+ ${chalk.dim("\xB7")} Containers ${chalk.dim("~/Library/Containers/com.<vendor>.<app>")}
270
+ ${chalk.dim("\xB7")} Launch agents, logs, crash reports, DMG files, and more
271
+
272
+ ${chalk.bold("Environment:")}
273
+ ${chalk.yellow("SOAP_DEBUG=1")} Enable verbose shell output
274
+ `);
275
+ process.exit(0);
276
+ }
277
+ if (isFlag) {
278
+ console.error(
279
+ chalk.red(`Unknown option: ${param}. Run \`soap --help\` for usage.`)
280
+ );
281
+ } else {
282
+ console.error(
283
+ chalk.red("No parameter specified. Run `soap --help` for usage.")
284
+ );
285
+ }
286
+ process.exit(1);
287
+ }
288
+ var isCask = !param.includes(".app");
289
+ console.log(
290
+ `
291
+ Welcome to ${chalk.bold("soap")} \u{1F9FC}, ${chalk.italic("the app cleaner")}.
292
+ `
293
+ );
294
+ try {
295
+ const { appName, zapFiles } = isCask ? await spinner(chalk.dim("Fetching cask info\u2026"), () => getCaskInfo(param)) : { appName: appNameFromPath(param), zapFiles: [] };
296
+ if (!appName) {
297
+ signale.error(`Could not determine app name for "${param}".`);
298
+ process.exit(1);
299
+ }
300
+ const bundleId = await getBundleIdentifier(appName);
301
+ const scannedFiles = await spinner(
302
+ chalk.dim("Scanning for files\u2026"),
303
+ () => findAppFiles(appName, bundleId)
304
+ );
305
+ const appFiles = [
306
+ .../* @__PURE__ */ new Set([
307
+ ...isCask ? scannedFiles.slice(1) : scannedFiles,
308
+ ...zapFiles
309
+ ])
310
+ ];
311
+ const isAppFilesEmpty = appFiles.length === 0;
312
+ signale.info(`Cleaning: ${chalk.bold(appName)}`);
313
+ signale.info(
314
+ isCask ? `Mode: ${chalk.bold("cask")} (${param})` : `Mode: ${chalk.bold("path")} \u2014 no Homebrew uninstall will run`
315
+ );
316
+ console.log("");
317
+ const { deletedFilesWish, deletedCaskWish } = await inquirer.prompt([
318
+ {
319
+ type: "checkbox",
320
+ name: "deletedFilesWish",
321
+ when: !isAppFilesEmpty,
322
+ message: `Found ${chalk.bold(appFiles.length)} files. Select what to move to Trash:`,
323
+ choices: appFiles.map((appFile) => ({
324
+ name: appFile,
325
+ value: appFile,
326
+ checked: true
327
+ }))
328
+ },
329
+ {
330
+ type: "confirm",
331
+ when: isCask,
332
+ name: "deletedCaskWish",
333
+ message: `Run ${chalk.bold(`brew uninstall --zap ${param}`)}?`,
334
+ default: true
335
+ }
336
+ ]);
337
+ if (!isAppFilesEmpty && deletedFilesWish && deletedFilesWish.length > 0) {
338
+ await trash(deletedFilesWish);
339
+ console.log("");
340
+ signale.success(
341
+ `Moved ${chalk.bold(deletedFilesWish.length)} file(s) to Trash:`
342
+ );
343
+ deletedFilesWish.forEach(
344
+ (p) => console.log(` ${chalk.dim("\xB7")} ${chalk.dim(p)}`)
345
+ );
346
+ } else if (!isAppFilesEmpty) {
347
+ signale.info("No files selected \u2014 nothing moved to Trash.");
348
+ }
349
+ if (isCask && deletedCaskWish) {
350
+ console.log("");
351
+ signale.pending("Running Homebrew uninstall\u2026");
352
+ await $2`brew uninstall --zap ${param}`;
353
+ }
354
+ console.log("");
355
+ signale.success("Done.");
356
+ } catch (error) {
357
+ console.log("");
358
+ signale.error(
359
+ error instanceof Error ? error.message : "An unexpected error occurred."
360
+ );
361
+ if (process.env.SOAP_DEBUG === "1") {
362
+ console.error(error);
363
+ }
364
+ if (isCask) {
365
+ const { forceUninstall } = await inquirer.prompt([
366
+ {
367
+ type: "confirm",
368
+ name: "forceUninstall",
369
+ message: `The app may already be removed but the cask is still registered.
370
+ Force-run ${chalk.bold(`brew uninstall --zap --force ${param}`)}?`,
371
+ default: false
372
+ }
373
+ ]);
374
+ if (forceUninstall) {
375
+ console.log("");
376
+ signale.pending("Force-uninstalling cask\u2026");
377
+ await $2`brew uninstall --zap --force ${param}`;
378
+ signale.success("Force uninstall complete.");
379
+ }
380
+ }
381
+ }
package/package.json CHANGED
@@ -1,17 +1,19 @@
1
1
  {
2
2
  "name": "@kud/soap-cli",
3
- "version": "1.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "An app cleaner cli for macOS",
5
- "main": "index.js",
5
+ "main": "dist/index.js",
6
6
  "bin": {
7
- "soap": "index.js"
8
- },
9
- "directories": {
10
- "lib": "lib"
7
+ "soap": "dist/index.js"
11
8
  },
12
9
  "scripts": {
13
- "dev": "node ./index.js",
14
- "test": "echo \"Error: no test specified\" && exit 1"
10
+ "dev": "tsx index.ts",
11
+ "build": "tsup",
12
+ "postbuild": "chmod +x dist/index.js",
13
+ "typecheck": "tsc --noEmit",
14
+ "clean": "rm -rf dist",
15
+ "test": "vitest run",
16
+ "prepublishOnly": "npm run build"
15
17
  },
16
18
  "repository": {
17
19
  "type": "git",
@@ -25,13 +27,24 @@
25
27
  },
26
28
  "homepage": "https://github.com/kud/soap-cli#readme",
27
29
  "type": "module",
30
+ "files": [
31
+ "dist"
32
+ ],
28
33
  "dependencies": {
29
- "@kud/soap-cli": "1.0.0-5",
30
- "chalk": "5.3.0",
31
- "del": "7.1.0",
32
- "inquirer": "9.2.23",
34
+ "@kud/soap-cli": "1.0.0",
35
+ "chalk": "5.6.2",
36
+ "del": "8.0.1",
37
+ "inquirer": "13.3.2",
33
38
  "signale": "1.4.0",
34
- "trash": "8.1.1",
35
- "zx": "8.1.3"
39
+ "trash": "10.1.1",
40
+ "zx": "8.8.5"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "25.5.0",
44
+ "@types/signale": "1.4.7",
45
+ "tsup": "8.5.1",
46
+ "tsx": "4.21.0",
47
+ "typescript": "6.0.2",
48
+ "vitest": "4.1.2"
36
49
  }
37
50
  }
package/index.js DELETED
@@ -1,120 +0,0 @@
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
-
36
- 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
-
41
- signale.info(
42
- `You want me to clean this application: ${chalk.bold(appName)} 📦.`,
43
- )
44
-
45
- signale.info(
46
- isCask
47
- ? `I also assume ${chalk.bold("you gave me a cask name")}.`
48
- : `I also assume you gave me an application path. ${chalk.bold(
49
- "No homebrew cask will be deleted then",
50
- )}.`,
51
- )
52
-
53
- console.log("")
54
-
55
- const { deletedFilesWish } = await inquirer.prompt([
56
- {
57
- type: "checkbox",
58
- name: "deletedFilesWish",
59
- when: !isAppFilesEmpty,
60
- message:
61
- "This is what I've found about it. Please select the files you want to delete.",
62
- choices: appFiles.map((appFile) => ({
63
- name: appFile,
64
- value: appFile,
65
- checked: true,
66
- })),
67
- },
68
- {
69
- type: "confirm",
70
- when: isCask,
71
- name: "deletedCaskWish",
72
- message: `Do you want to uninstall "${param}" via homebrew?`,
73
- },
74
- ])
75
-
76
- if (!isAppFilesEmpty) {
77
- await trash(deletedFilesWish, { force: true })
78
-
79
- console.log("")
80
-
81
- console.log("Files deleted:")
82
- deletedFilesWish.forEach((deletedPath) => console.log(`· ${deletedPath}`))
83
- }
84
-
85
- if (isCask) {
86
- console.log("")
87
-
88
- signale.pending(`Starting cask uninstallation.`)
89
-
90
- await $`brew uninstall ${param}`
91
- }
92
-
93
- console.log("")
94
-
95
- signale.success("Operation successful.")
96
- } catch (error) {
97
- signale.error(`Something wrong appeared. Check the log below.\n`)
98
- console.error(error)
99
- console.error("")
100
-
101
- if (isCask) {
102
- inquirer
103
- .prompt([
104
- {
105
- type: "confirm",
106
- name: "forceUninstall",
107
- message:
108
- "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`?",
109
- },
110
- ])
111
- .then(async (answers) => {
112
- if (answers.forceUninstall) {
113
- console.error("")
114
- signale.pending(`Forcefully uninstalling cask.`)
115
- await $`brew uninstall --force ${param}`
116
- signale.success("Force uninstallation successful.")
117
- }
118
- })
119
- }
120
- }
package/lib/file-regex.js DELETED
@@ -1,22 +0,0 @@
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 DELETED
@@ -1,191 +0,0 @@
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
- for (const artifact of caskData.artifacts) {
124
- if (artifact.app) {
125
- return artifact.app[0]
126
- }
127
- }
128
-
129
- return null
130
- } catch (error) {
131
- console.error(`Error fetching app name for cask ${caskName}:`, error)
132
- return null
133
- }
134
- }
135
-
136
- async function getComputerName() {
137
- const compName = await execSync("scutil --get ComputerName").toString()
138
-
139
- // remove empty space at end of string
140
- return compName.substring(0, compName.length - 1)
141
- }
142
-
143
- async function getBundleIdentifier(appName) {
144
- const bundleId = await execSync(
145
- `osascript -e 'id of app "${appName}"'`,
146
- ).toString()
147
-
148
- // remove empty space at end of string
149
- return bundleId.substring(0, bundleId.length - 1)
150
- }
151
-
152
- async function findAppFiles(appName, bundleId) {
153
- try {
154
- compNameGlob = await getComputerName()
155
- const bundleIdComponents = bundleId.split(".")
156
-
157
- const companyDirs = pathLocations.map(
158
- (pathLocation) => `${pathLocation}/${bundleIdComponents[1]}`,
159
- )
160
- const pathsToSearch = [...pathLocations, ...companyDirs]
161
- const directoryFilesPromiseArr = pathsToSearch.map((pathLocation) =>
162
- fs.readdir(pathLocation),
163
- )
164
- const directoryFiles = await Promise.allSettled(directoryFilesPromiseArr)
165
-
166
- const patternArray = await getFilePatternArray(appName, bundleId)
167
-
168
- const filesToRemove = new Set([])
169
-
170
- directoryFiles.forEach((dir, index) => {
171
- if (dir.status === "fulfilled") {
172
- dir.value.forEach((dirFile) => {
173
- const dirFileNorm = dirFile.toLowerCase().replace(" ", "")
174
- if (isPatternInFile(patternArray, dirFileNorm)) {
175
- filesToRemove.add(
176
- `${pathsToSearch[parseInt(index, 10)]}/${dirFile}`,
177
- )
178
- }
179
- })
180
- }
181
- })
182
-
183
- // convert set to array
184
- return [...filesToRemove]
185
- } catch (err) {
186
- console.error(err)
187
- throw err
188
- }
189
- }
190
-
191
- export { appNameFromPath, getBundleIdentifier, findAppFiles }
@@ -1,28 +0,0 @@
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"]