@kud/soap-cli 2.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
@@ -130,6 +130,23 @@ Enables verbose shell output — shows every subprocess command that runs.
130
130
 
131
131
  ---
132
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
146
+ ```
147
+
148
+ ---
149
+
133
150
  ## Credits
134
151
 
135
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": "2.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": "vitest run"
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,6 +27,9 @@
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
34
  "@kud/soap-cli": "1.0.0",
30
35
  "chalk": "5.6.2",
@@ -35,6 +40,11 @@
35
40
  "zx": "8.8.5"
36
41
  },
37
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",
38
48
  "vitest": "4.1.2"
39
49
  }
40
50
  }
@@ -1,14 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(node ./index.js)",
5
- "Bash(node:*)",
6
- "Bash(npm install:*)",
7
- "Bash(npm test:*)",
8
- "Bash(brew info:*)",
9
- "Bash(echo \"exit: $?\")",
10
- "Bash(git tag:*)",
11
- "Bash(git push:*)"
12
- ]
13
- }
14
- }
package/index.js DELETED
@@ -1,159 +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 { $, spinner } from "zx"
7
-
8
- import {
9
- appNameFromPath,
10
- getCaskInfo,
11
- getBundleIdentifier,
12
- findAppFiles,
13
- } from "./lib/index.js"
14
-
15
- $.verbose = process.env.SOAP_DEBUG === "1"
16
-
17
- const [param] = process.argv.slice(2)
18
-
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)
50
- }
51
-
52
- const isCask = !param.includes(".app")
53
-
54
- console.log(
55
- `\nWelcome to ${chalk.bold("soap")} 🧼, ${chalk.italic("the app cleaner")}.\n`,
56
- )
57
-
58
- try {
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
-
68
- const bundleId = await getBundleIdentifier(appName)
69
-
70
- const scannedFiles = await spinner(chalk.dim("Scanning for files…"), () =>
71
- findAppFiles(appName, bundleId),
72
- )
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)}`)
83
- signale.info(
84
- isCask
85
- ? `Mode: ${chalk.bold("cask")} (${param})`
86
- : `Mode: ${chalk.bold("path")} — no Homebrew uninstall will run`,
87
- )
88
-
89
- console.log("")
90
-
91
- const { deletedFilesWish, deletedCaskWish } = await inquirer.prompt([
92
- {
93
- type: "checkbox",
94
- name: "deletedFilesWish",
95
- when: !isAppFilesEmpty,
96
- message: `Found ${chalk.bold(appFiles.length)} files. Select what to move to Trash:`,
97
- choices: appFiles.map((appFile) => ({
98
- name: appFile,
99
- value: appFile,
100
- checked: true,
101
- })),
102
- },
103
- {
104
- type: "confirm",
105
- when: isCask,
106
- name: "deletedCaskWish",
107
- message: `Run ${chalk.bold(`brew uninstall --zap ${param}`)}?`,
108
- default: true,
109
- },
110
- ])
111
-
112
- if (!isAppFilesEmpty && deletedFilesWish?.length > 0) {
113
- await trash(deletedFilesWish, { force: true })
114
-
115
- console.log("")
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.")
124
- }
125
-
126
- if (isCask && deletedCaskWish) {
127
- console.log("")
128
- signale.pending("Running Homebrew uninstall…")
129
- await $`brew uninstall --zap ${param}`
130
- }
131
-
132
- console.log("")
133
- signale.success("Done.")
134
- } catch (error) {
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
- }
141
-
142
- if (isCask) {
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
- }
158
- }
159
- }
@@ -1,63 +0,0 @@
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
- })
@@ -1,46 +0,0 @@
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
- })
@@ -1,39 +0,0 @@
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/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,233 +0,0 @@
1
- import { $ } from "zx"
2
- $.verbose = false
3
-
4
- import { execSync } from "child_process"
5
- import fs from "fs/promises"
6
- import { homedir } from "os"
7
- import { pathLocations, commonSuffix } from "./path-locations.js"
8
- import { fileRegex } from "./file-regex.js"
9
-
10
- const scoreThreshold = 0.4
11
-
12
- let compNameGlob = ""
13
-
14
- function stripString(file) {
15
- let transformedString = file
16
- fileRegex.forEach((regex1) => {
17
- transformedString = transformedString.replace(regex1, "")
18
- })
19
-
20
- const normCompName = normalizeString(compNameGlob, "-")
21
- .replace(/\u2019/g, "")
22
- .replace(/\(/g, "")
23
- .replace(/\)/g, "")
24
-
25
- transformedString = transformedString.replace(normCompName, "")
26
-
27
- return transformedString
28
- }
29
-
30
- export const normalizeString = (str, spacer = "") =>
31
- str.toLowerCase().replace(/ /g, spacer)
32
-
33
- async function getFilePatternArray(appName, bundleId) {
34
- const nameVariations = createNameVariations(appName, bundleId)
35
- const appNameNorm = normalizeString(appName)
36
- const bundleIdNorm = normalizeString(bundleId)
37
-
38
- let patternArray = [...nameVariations]
39
-
40
- const appNameComponents = appNameNorm.split(".")
41
- if (appNameComponents) patternArray.push(appNameComponents[0])
42
-
43
- const bundleIdComponents = bundleIdNorm.split(".")
44
- if (
45
- bundleIdComponents.length > 2 &&
46
- bundleIdComponents[bundleIdComponents.length - 1].toLowerCase() === "app"
47
- ) {
48
- patternArray.push(
49
- `${bundleIdComponents.slice(0, bundleIdComponents.length - 1).join(".")}`,
50
- )
51
- }
52
-
53
- const appWithSuffix = new Set([])
54
- commonSuffix.forEach((suffix) =>
55
- nameVariations.forEach((nameVariation) =>
56
- appWithSuffix.add(`${nameVariation}${suffix}`),
57
- ),
58
- )
59
-
60
- patternArray = [...patternArray, ...appWithSuffix]
61
-
62
- return patternArray
63
- }
64
-
65
- export function isPatternInFile(patterns, fileToCheck) {
66
- return patterns.find((filePatten) => {
67
- if (typeof filePatten !== "string") return false
68
- if (fileToCheck.includes(filePatten)) {
69
- const strippedFile = stripString(fileToCheck)
70
-
71
- let score = 0
72
- const indexOfString = strippedFile.indexOf(filePatten)
73
- for (let i = 0; i < strippedFile.length; i += 1) {
74
- if (i === indexOfString) {
75
- i += indexOfString + filePatten.length
76
- score += filePatten.length
77
- }
78
- if (strippedFile[parseInt(i, 10)] === ".") score += 0.5
79
- if (strippedFile[parseInt(i, 10)] === "_") score += 0.5
80
- }
81
- if (score / strippedFile.length > scoreThreshold) {
82
- return true
83
- }
84
- return false
85
- }
86
- return false
87
- })
88
- }
89
-
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, "")
97
-
98
- return [
99
- appNameNorm,
100
- appNameWithoutDot,
101
- appNameUnderscore,
102
- appNameDash,
103
- appNameDot,
104
- bundleIdNorm,
105
- ]
106
- }
107
-
108
- export function appNameFromPath(appPath) {
109
- const pathArr = appPath.split("/")
110
- const appNameWithExt = pathArr[pathArr.length - 1]
111
- return appNameWithExt.replace(".app", "")
112
- }
113
-
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) => {
149
- try {
150
- const caskData = await fetchCaskData(caskName)
151
-
152
- let appName = null
153
- for (const artifact of caskData.artifacts) {
154
- if (artifact.app) {
155
- appName = artifact.app[0]
156
- break
157
- }
158
- }
159
-
160
- const zapFiles = await resolveZapPaths(caskData)
161
-
162
- return { appName, zapFiles }
163
- } catch (error) {
164
- throw new Error(
165
- `Failed to get cask info for "${caskName}": ${error.message}`,
166
- )
167
- }
168
- }
169
-
170
- export const appNameFromCaskName = async (caskName) => {
171
- const { appName } = await getCaskInfo(caskName)
172
- return appName
173
- }
174
-
175
- export const getZapFiles = async (caskName) => {
176
- const { zapFiles } = await getCaskInfo(caskName)
177
- return zapFiles
178
- }
179
-
180
- function getComputerName() {
181
- return execSync("scutil --get ComputerName").toString().trimEnd()
182
- }
183
-
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
- }
195
- }
196
-
197
- export async function findAppFiles(appName, bundleId) {
198
- try {
199
- compNameGlob = getComputerName()
200
- const bundleIdComponents = bundleId.split(".")
201
-
202
- const companyDirs = pathLocations.map(
203
- (pathLocation) => `${pathLocation}/${bundleIdComponents[1]}`,
204
- )
205
- const pathsToSearch = [...pathLocations, ...companyDirs]
206
- const directoryFilesPromiseArr = pathsToSearch.map((pathLocation) =>
207
- fs.readdir(pathLocation),
208
- )
209
- const directoryFiles = await Promise.allSettled(directoryFilesPromiseArr)
210
-
211
- const patternArray = await getFilePatternArray(appName, bundleId)
212
-
213
- const filesToRemove = new Set([])
214
-
215
- directoryFiles.forEach((dir, index) => {
216
- if (dir.status === "fulfilled") {
217
- dir.value.forEach((dirFile) => {
218
- const dirFileNorm = dirFile.toLowerCase().replace(/ /g, "")
219
- if (isPatternInFile(patternArray, dirFileNorm)) {
220
- filesToRemove.add(
221
- `${pathsToSearch[parseInt(index, 10)]}/${dirFile}`,
222
- )
223
- }
224
- })
225
- }
226
- })
227
-
228
- return [...filesToRemove]
229
- } catch (err) {
230
- console.error(err)
231
- throw err
232
- }
233
- }
@@ -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"]