@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.
@@ -0,0 +1,14 @@
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/README.md CHANGED
@@ -1,53 +1,135 @@
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
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
- #### Cask name
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
- It will delete for instance this kind of files:
81
+ Moved 11 file(s) to Trash.
38
82
 
83
+ ◼ Running Homebrew uninstall…
84
+ ✔ Done.
39
85
  ```
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
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
- No worries, soap will move them into your Trash, so if something wrong has happened, you can check there your files.
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 from [App Eraser](https://github.com/davunt/app-eraser)
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 { $ } from "zx/core"
6
+ import { $, spinner } from "zx"
7
7
 
8
8
  import {
9
9
  appNameFromPath,
10
- appNameFromCaskName,
10
+ getCaskInfo,
11
11
  getBundleIdentifier,
12
12
  findAppFiles,
13
13
  } from "./lib/index.js"
14
14
 
15
- $.verbose = false // true for debugging
15
+ $.verbose = process.env.SOAP_DEBUG === "1"
16
16
 
17
17
  const [param] = process.argv.slice(2)
18
18
 
19
- if (!param) {
20
- console.error(`No parameter specified.`)
21
-
22
- process.exit(0)
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
- `Welcome to ${chalk.bold("soap")} 🧼, ${chalk.italic("the app cleaner")}.\n`,
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 appNameFromCaskName(param)
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
- signale.info(
42
- `You want me to clean this application: ${chalk.bold(appName)} 📦.`,
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
- ? `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
- )}.`,
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: `Do you want to uninstall "${param}" via homebrew?`,
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
- console.log("Files deleted:")
82
- deletedFilesWish.forEach((deletedPath) => console.log(`· ${deletedPath}`))
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
- signale.pending(`Starting cask uninstallation.`)
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
- signale.error(`Something wrong appeared. Check the log below.\n`)
98
- console.error(error)
99
- console.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
+ }
100
141
 
101
142
  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
- })
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, [...appWithSuffix]]
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.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(" ", "")
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
- 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]
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
- if (!caskData) {
120
- throw new Error("Cask data not available.")
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
- return artifact.app[0]
155
+ appName = artifact.app[0]
156
+ break
126
157
  }
127
158
  }
128
159
 
129
- return null
160
+ const zapFiles = await resolveZapPaths(caskData)
161
+
162
+ return { appName, zapFiles }
130
163
  } catch (error) {
131
- console.error(`Error fetching app name for cask ${caskName}:`, error)
132
- return null
164
+ throw new Error(
165
+ `Failed to get cask info for "${caskName}": ${error.message}`,
166
+ )
133
167
  }
134
168
  }
135
169
 
136
- async function getComputerName() {
137
- const compName = await execSync("scutil --get ComputerName").toString()
170
+ export const appNameFromCaskName = async (caskName) => {
171
+ const { appName } = await getCaskInfo(caskName)
172
+ return appName
173
+ }
138
174
 
139
- // remove empty space at end of string
140
- return compName.substring(0, compName.length - 1)
175
+ export const getZapFiles = async (caskName) => {
176
+ const { zapFiles } = await getCaskInfo(caskName)
177
+ return zapFiles
141
178
  }
142
179
 
143
- async function getBundleIdentifier(appName) {
144
- const bundleId = await execSync(
145
- `osascript -e 'id of app "${appName}"'`,
146
- ).toString()
180
+ function getComputerName() {
181
+ return execSync("scutil --get ComputerName").toString().trimEnd()
182
+ }
147
183
 
148
- // remove empty space at end of string
149
- return bundleId.substring(0, bundleId.length - 1)
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 = await getComputerName()
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": "1.0.0",
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": "echo \"Error: no test specified\" && exit 1"
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-5",
30
- "chalk": "5.3.0",
31
- "del": "7.1.0",
32
- "inquirer": "9.2.23",
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": "8.1.1",
35
- "zx": "8.1.3"
34
+ "trash": "10.1.1",
35
+ "zx": "8.8.5"
36
+ },
37
+ "devDependencies": {
38
+ "vitest": "4.1.2"
36
39
  }
37
40
  }