@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 +17 -0
- package/dist/index.js +381 -0
- package/package.json +18 -8
- package/.claude/settings.local.json +0 -14
- package/index.js +0 -159
- package/lib/__tests__/app-name.test.js +0 -63
- package/lib/__tests__/file-regex.test.js +0 -46
- package/lib/__tests__/pattern-matching.test.js +0 -39
- package/lib/file-regex.js +0 -22
- package/lib/index.js +0 -233
- package/lib/path-locations.js +0 -28
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": "
|
|
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": "
|
|
14
|
-
"
|
|
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
|
}
|
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
|
-
}
|
package/lib/path-locations.js
DELETED
|
@@ -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"]
|