@coreify/quitx 1.0.0 → 1.2.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.
Files changed (3) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/cli.js +1192 -212
  3. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -1,12 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { readFile } from 'fs/promises';
3
- import { select, isCancel, spinner, log, intro, multiselect, outro, confirm, cancel } from '@clack/prompts';
2
+ import { spinner, select, isCancel, log, intro, outro, multiselect, confirm, cancel } from '@clack/prompts';
3
+ import { readFile, mkdir, writeFile } from 'fs/promises';
4
4
  import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
5
5
  import { homedir } from 'os';
6
- import { join } from 'path';
6
+ import { join, dirname } from 'path';
7
7
  import { execa } from 'execa';
8
8
  import { spawn } from 'child_process';
9
- import { MultiSelectPrompt } from '@clack/core';
10
9
 
11
10
  var CONFIG_DIR = join(homedir(), ".quitx");
12
11
  var CONFIG_FILE = join(CONFIG_DIR, "config.json");
@@ -14,27 +13,43 @@ var DEFAULT_CONFIG = {
14
13
  exclude: [],
15
14
  force: "normal",
16
15
  includeFinder: false,
17
- includeBackground: false
16
+ includeTrash: false,
17
+ includeBackground: false,
18
+ groupBackground: true,
19
+ defaultSelectAll: true,
20
+ neverQuitMusic: false,
21
+ musicApps: [],
22
+ autoUpdate: true
18
23
  };
19
24
  function loadConfig(filePath = CONFIG_FILE) {
20
25
  try {
21
26
  if (!existsSync(filePath)) {
22
- return { ...DEFAULT_CONFIG, exclude: [] };
27
+ return { ...DEFAULT_CONFIG, exclude: [], musicApps: [] };
23
28
  }
24
29
  const raw = readFileSync(filePath, "utf-8");
25
30
  const parsed = JSON.parse(raw);
26
31
  if (typeof parsed !== "object" || parsed === null) {
27
- return { ...DEFAULT_CONFIG, exclude: [] };
32
+ return { ...DEFAULT_CONFIG, exclude: [], musicApps: [] };
28
33
  }
29
34
  const obj = parsed;
35
+ const defaultSelectVal = typeof obj.defaultSelectAll === "boolean" ? obj.defaultSelectAll : obj.defaultSelect === "deselect-all" || obj.defaultSelection === "deselect-all" || obj.defaultSelect === false ? false : typeof obj.defaultSelect === "boolean" ? obj.defaultSelect : DEFAULT_CONFIG.defaultSelectAll;
36
+ const groupBgVal = typeof obj.groupBackground === "boolean" ? obj.groupBackground : typeof obj.groupBackgroundInstances === "boolean" ? obj.groupBackgroundInstances : DEFAULT_CONFIG.groupBackground;
37
+ const neverQuitMusicVal = typeof obj.neverQuitMusic === "boolean" ? obj.neverQuitMusic : DEFAULT_CONFIG.neverQuitMusic;
38
+ const autoUpdateVal = typeof obj.autoUpdate === "boolean" ? obj.autoUpdate : typeof obj.checkUpdate === "boolean" ? obj.checkUpdate : DEFAULT_CONFIG.autoUpdate;
30
39
  return {
31
40
  exclude: Array.isArray(obj.exclude) ? obj.exclude : [],
32
41
  force: obj.force === "force" || obj.force === "normal" ? obj.force : DEFAULT_CONFIG.force,
33
42
  includeFinder: typeof obj.includeFinder === "boolean" ? obj.includeFinder : DEFAULT_CONFIG.includeFinder,
34
- includeBackground: typeof obj.includeBackground === "boolean" ? obj.includeBackground : DEFAULT_CONFIG.includeBackground
43
+ includeTrash: typeof obj.includeTrash === "boolean" ? obj.includeTrash : DEFAULT_CONFIG.includeTrash,
44
+ includeBackground: typeof obj.includeBackground === "boolean" ? obj.includeBackground : DEFAULT_CONFIG.includeBackground,
45
+ groupBackground: groupBgVal,
46
+ defaultSelectAll: defaultSelectVal,
47
+ neverQuitMusic: neverQuitMusicVal,
48
+ musicApps: Array.isArray(obj.musicApps) ? obj.musicApps : DEFAULT_CONFIG.musicApps,
49
+ autoUpdate: autoUpdateVal
35
50
  };
36
51
  } catch {
37
- return { ...DEFAULT_CONFIG, exclude: [] };
52
+ return { ...DEFAULT_CONFIG, exclude: [], musicApps: [] };
38
53
  }
39
54
  }
40
55
  function saveConfig(config, filePath = CONFIG_FILE) {
@@ -72,6 +87,31 @@ function removeExcludedApps(apps, filePath = CONFIG_FILE) {
72
87
  saveConfig(next, filePath);
73
88
  return next;
74
89
  }
90
+ function addMusicApps(apps, filePath = CONFIG_FILE) {
91
+ const current = loadConfig(filePath);
92
+ const set = new Set(current.musicApps.map((a) => a.toLowerCase().trim()));
93
+ const updated = [...current.musicApps];
94
+ for (const app of apps) {
95
+ const trimmed = app.trim();
96
+ if (trimmed && !set.has(trimmed.toLowerCase())) {
97
+ set.add(trimmed.toLowerCase());
98
+ updated.push(trimmed);
99
+ }
100
+ }
101
+ const next = { ...current, musicApps: updated };
102
+ saveConfig(next, filePath);
103
+ return next;
104
+ }
105
+ function removeMusicApps(apps, filePath = CONFIG_FILE) {
106
+ const current = loadConfig(filePath);
107
+ const toRemove = new Set(apps.map((a) => a.toLowerCase().trim()));
108
+ const updated = current.musicApps.filter(
109
+ (app) => !toRemove.has(app.toLowerCase().trim())
110
+ );
111
+ const next = { ...current, musicApps: updated };
112
+ saveConfig(next, filePath);
113
+ return next;
114
+ }
75
115
 
76
116
  // src/macos/jxa.ts
77
117
  var APPKIT_PREAMBLE = "ObjC.import('AppKit');";
@@ -98,6 +138,10 @@ async function runJXA(script, args = [], executor = execa) {
98
138
  ]);
99
139
  return stdout.trim();
100
140
  }
141
+ async function runAppleScript(script, executor = execa) {
142
+ const { stdout } = await executor("osascript", ["-e", script]);
143
+ return stdout.trim();
144
+ }
101
145
 
102
146
  // src/macos/apps.ts
103
147
  function buildDiscoveryScript(includeBackground = false) {
@@ -114,7 +158,18 @@ function run() {
114
158
  const name = app.localizedName ? app.localizedName.js : '';
115
159
  const bundleId = app.bundleIdentifier ? app.bundleIdentifier.js : '';
116
160
  const pid = String(app.processIdentifier);
117
- lines.push(name + '\\t' + bundleId + '\\t' + pid);
161
+ const isBg = app.activationPolicy !== Regular ? '1' : '0';
162
+ let isMusic = '0';
163
+ if (app.bundleURL) {
164
+ const bundle = $.NSBundle.bundleWithURL(app.bundleURL);
165
+ if (bundle && bundle.infoDictionary) {
166
+ const cat = bundle.infoDictionary.objectForKey('LSApplicationCategoryType');
167
+ if (cat && cat.js === 'public.app-category.music') {
168
+ isMusic = '1';
169
+ }
170
+ }
171
+ }
172
+ lines.push(name + '\\t' + bundleId + '\\t' + pid + '\\t' + isBg + '\\t' + isMusic);
118
173
  }
119
174
  return lines.join('\\n');
120
175
  }`.trim();
@@ -148,35 +203,104 @@ function parseAppListOutput(stdout) {
148
203
  const results = [];
149
204
  for (const line of lines) {
150
205
  const parts = line.split(" ");
151
- const rawName = parts[0]?.trim();
206
+ const rawName = parts[0]?.replace(/[\u200B-\u200D\u200E\u200F\uFEFF]/g, "").trim();
152
207
  if (!rawName) continue;
153
208
  const rawBundle = parts[1]?.trim();
154
209
  const bundleId = rawBundle && rawBundle !== "missing value" && rawBundle.length > 0 ? rawBundle : void 0;
155
210
  const rawPid = parts[2]?.trim();
156
211
  const pidNum = rawPid ? parseInt(rawPid, 10) : void 0;
157
212
  const pid = !Number.isNaN(pidNum) && pidNum !== void 0 && pidNum > 0 ? pidNum : void 0;
213
+ const rawIsBg = parts[3]?.trim();
214
+ const isBackground = rawIsBg === "1";
215
+ const rawIsMusic = parts[4]?.trim();
216
+ const isMusic = rawIsMusic === "1";
158
217
  let name = rawName;
159
218
  if (bundleId && rawName === bundleId) {
160
219
  name = formatDynamicName(bundleId);
161
220
  }
162
- results.push({ name, bundleId, pid });
221
+ results.push({
222
+ name,
223
+ bundleId,
224
+ pid,
225
+ ...isBackground ? { isBackground: true } : {},
226
+ ...isMusic ? { isMusic: true } : {}
227
+ });
163
228
  }
164
229
  return results;
165
230
  }
166
- return trimmed.split(",").map((name) => name.trim()).filter(Boolean).map((name) => ({ name }));
231
+ return trimmed.split(",").map(
232
+ (name) => name.replace(/[\u200B-\u200D\u200E\u200F\uFEFF]/g, "").trim()
233
+ ).filter(Boolean).map((name) => ({ name }));
167
234
  }
168
235
  function sortApps(apps) {
169
236
  return [...apps].sort(
170
237
  (a, b) => a.name.localeCompare(b.name, void 0, { sensitivity: "base" })
171
238
  );
172
239
  }
240
+ var KNOWN_MUSIC_BUNDLE_IDS = /* @__PURE__ */ new Set([
241
+ "com.apple.music",
242
+ "com.spotify.client",
243
+ "com.tidal.desktop",
244
+ "com.github.th-ch.youtube-music",
245
+ "app.ytmdesktop.ytmdesktop",
246
+ "com.amazon.music",
247
+ "com.deezer.deezer-desktop",
248
+ "com.coppertino.vox",
249
+ "com.audirvana.audirvana-plus",
250
+ "com.audirvana.audirvana",
251
+ "com.swinsian.swinsian",
252
+ "cider.sh",
253
+ "com.cider.cider"
254
+ ]);
255
+ var KNOWN_MUSIC_APP_NAMES = /* @__PURE__ */ new Set([
256
+ "music",
257
+ "apple music",
258
+ "spotify",
259
+ "tidal",
260
+ "youtube music",
261
+ "amazon music",
262
+ "deezer",
263
+ "qobuz",
264
+ "soundcloud",
265
+ "pandora",
266
+ "vox",
267
+ "audirvana",
268
+ "foobar2000",
269
+ "swinsian",
270
+ "cider"
271
+ ]);
272
+ function isMusicApp(app, customMusicApps = []) {
273
+ if (app.isMusic) {
274
+ return true;
275
+ }
276
+ const nameLower = app.name.toLowerCase().trim();
277
+ const bundleLower = app.bundleId?.toLowerCase().trim();
278
+ if (bundleLower && KNOWN_MUSIC_BUNDLE_IDS.has(bundleLower)) {
279
+ return true;
280
+ }
281
+ if (KNOWN_MUSIC_APP_NAMES.has(nameLower)) {
282
+ return true;
283
+ }
284
+ if (customMusicApps.length > 0) {
285
+ const customSet = new Set(
286
+ customMusicApps.map((a) => a.toLowerCase().trim())
287
+ );
288
+ if (customSet.has(nameLower) || bundleLower && customSet.has(bundleLower)) {
289
+ return true;
290
+ }
291
+ }
292
+ return false;
293
+ }
173
294
  function filterApps(apps, optionsOrExclude = {}) {
174
295
  const opts = Array.isArray(optionsOrExclude) ? { exclude: optionsOrExclude } : optionsOrExclude;
175
296
  const exclude = opts.exclude ?? [];
176
297
  const includeFinder = opts.includeFinder ?? false;
177
- const seen = /* @__PURE__ */ new Set();
298
+ const includeTrash = opts.includeTrash ?? false;
299
+ const groupBackground = opts.groupBackground ?? true;
300
+ const neverQuitMusic = opts.neverQuitMusic ?? false;
301
+ const customMusicApps = opts.musicApps ?? [];
178
302
  const excludeSet = new Set(exclude.map((e) => e.toLowerCase().trim()));
179
- const filtered = [];
303
+ const eligible = [];
180
304
  for (const app of apps) {
181
305
  const nameLower = app.name.toLowerCase().trim();
182
306
  const bundleLower = app.bundleId?.toLowerCase().trim();
@@ -188,23 +312,73 @@ function filterApps(apps, optionsOrExclude = {}) {
188
312
  continue;
189
313
  }
190
314
  }
191
- if (excludeSet.has(nameLower) || bundleLower && excludeSet.has(bundleLower)) {
315
+ if (!includeTrash) {
316
+ if (nameLower === "trash" || bundleLower === "com.apple.trash") {
317
+ continue;
318
+ }
319
+ }
320
+ if (neverQuitMusic && isMusicApp(app, customMusicApps)) {
192
321
  continue;
193
322
  }
194
- const key = bundleLower ?? nameLower;
195
- if (seen.has(key)) {
323
+ if (excludeSet.has(nameLower) || bundleLower && excludeSet.has(bundleLower)) {
196
324
  continue;
197
325
  }
198
- seen.add(key);
199
- filtered.push(app);
326
+ eligible.push(app);
327
+ }
328
+ const result = [];
329
+ const grouped = /* @__PURE__ */ new Map();
330
+ for (const app of eligible) {
331
+ const nameLower = app.name.toLowerCase().trim();
332
+ const bundleLower = app.bundleId?.toLowerCase().trim();
333
+ const isBg = app.isBackground === true;
334
+ if (isBg && !groupBackground) {
335
+ result.push({ ...app });
336
+ } else {
337
+ let key = bundleLower ?? nameLower;
338
+ let cleanName = app.name;
339
+ if (isBg) {
340
+ const base = app.name.replace(/\s*\([^)]*\)$/, "").trim();
341
+ if (base) {
342
+ key = base.toLowerCase();
343
+ cleanName = base;
344
+ }
345
+ }
346
+ const existing = grouped.get(key);
347
+ if (existing) {
348
+ if (app.pid) {
349
+ if (!existing.pids) {
350
+ existing.pids = existing.pid ? [existing.pid] : [];
351
+ }
352
+ if (!existing.pids.includes(app.pid)) {
353
+ existing.pids.push(app.pid);
354
+ }
355
+ existing.count = existing.pids.length;
356
+ } else {
357
+ existing.count = (existing.count ?? 1) + 1;
358
+ }
359
+ } else {
360
+ const item = { ...app, name: cleanName };
361
+ grouped.set(key, item);
362
+ result.push(item);
363
+ }
364
+ }
365
+ }
366
+ if (includeTrash && !excludeSet.has("trash") && !excludeSet.has("com.apple.trash")) {
367
+ const hasTrash = result.some(
368
+ (a) => a.name.toLowerCase() === "trash" || a.bundleId?.toLowerCase() === "com.apple.trash"
369
+ );
370
+ if (!hasTrash) {
371
+ result.push({ name: "Trash", bundleId: "com.apple.trash" });
372
+ }
200
373
  }
201
- return sortApps(filtered);
374
+ return sortApps(result);
202
375
  }
203
376
  async function getRunningApps(optionsOrExclude, executor) {
204
377
  let exec;
205
- let filterOpts = {};
378
+ let filterOpts;
206
379
  let includeBackground = false;
207
380
  if (typeof optionsOrExclude === "function") {
381
+ filterOpts = {};
208
382
  exec = optionsOrExclude;
209
383
  } else if (Array.isArray(optionsOrExclude)) {
210
384
  filterOpts = { exclude: optionsOrExclude };
@@ -213,11 +387,25 @@ async function getRunningApps(optionsOrExclude, executor) {
213
387
  const opts = optionsOrExclude;
214
388
  filterOpts = {
215
389
  exclude: opts.exclude ?? [],
216
- includeFinder: opts.includeFinder ?? false
390
+ includeFinder: opts.includeFinder ?? false,
391
+ includeTrash: opts.includeTrash ?? false,
392
+ groupBackground: opts.groupBackground ?? true,
393
+ neverQuitMusic: opts.neverQuitMusic ?? false,
394
+ musicApps: opts.musicApps ?? []
217
395
  };
218
396
  includeBackground = opts.includeBackground ?? false;
219
397
  exec = executor;
220
398
  } else {
399
+ const config = loadConfig();
400
+ filterOpts = {
401
+ exclude: config.exclude,
402
+ includeFinder: config.includeFinder,
403
+ includeTrash: config.includeTrash,
404
+ groupBackground: config.groupBackground,
405
+ neverQuitMusic: config.neverQuitMusic,
406
+ musicApps: config.musicApps
407
+ };
408
+ includeBackground = config.includeBackground;
221
409
  exec = executor;
222
410
  }
223
411
  const script = buildDiscoveryScript(includeBackground);
@@ -240,8 +428,19 @@ async function isAppRunning(target, executor) {
240
428
  if (typeof target === "number") {
241
429
  return isProcessAlive(target);
242
430
  }
243
- if (typeof target === "object" && target.pid !== void 0 && target.pid > 0) {
244
- return isProcessAlive(target.pid);
431
+ if (typeof target === "object") {
432
+ if (target.bundleId === "com.apple.trash" || target.name.toLowerCase() === "trash") {
433
+ return false;
434
+ }
435
+ if (target.pids && target.pids.length > 0) {
436
+ return target.pids.some((pid) => isProcessAlive(pid));
437
+ }
438
+ if (target.pid !== void 0 && target.pid > 0) {
439
+ return isProcessAlive(target.pid);
440
+ }
441
+ }
442
+ if (typeof target === "string" && (target.toLowerCase() === "trash" || target.toLowerCase() === "com.apple.trash")) {
443
+ return false;
245
444
  }
246
445
  const name = typeof target === "string" ? target : target.name;
247
446
  const bundleId = typeof target === "object" ? target.bundleId ?? "" : "";
@@ -353,13 +552,32 @@ function isCurrentTerminalApp(app, env = process.env) {
353
552
  }
354
553
 
355
554
  // src/macos/quit.ts
555
+ var EMPTY_TRASH_SCRIPT = `
556
+ ignoring application responses
557
+ tell application "Finder"
558
+ try
559
+ set warns before emptying to false
560
+ empty the trash
561
+ end try
562
+ end tell
563
+ end ignoring
564
+ `.trim();
356
565
  var QUIT_JXA_SCRIPT = buildAppMatcherScript(`
357
566
  const bundleId = argv[0] || '';
358
567
  const name = argv[1] || '';
568
+ const pidStr = argv[2] || '';
569
+ const targetPid = pidStr ? parseInt(pidStr, 10) : 0;
359
570
  const bid = app.bundleIdentifier ? app.bundleIdentifier.js : '';
360
571
  const nm = app.localizedName ? app.localizedName.js : '';
361
- if ((bundleId && bid === bundleId) || (name && nm === name)) {
362
- return String(app.terminate());
572
+ const appPid = app.processIdentifier;
573
+
574
+ const matches = targetPid
575
+ ? appPid === targetPid
576
+ : ((bundleId && bid === bundleId) || (name && nm.toLowerCase() === name.toLowerCase()));
577
+
578
+ if (matches) {
579
+ const res = typeof app.terminate === 'function' ? app.terminate() : app.terminate;
580
+ return String(res);
363
581
  }
364
582
  `);
365
583
  function shellQuote(value) {
@@ -368,7 +586,9 @@ function shellQuote(value) {
368
586
  function defaultDeferredQuitScheduler(app, force = false) {
369
587
  try {
370
588
  const bundleId = app.bundleId ?? "";
371
- const cmd = force && app.pid ? `sleep 0.4 && kill -9 ${app.pid}` : `sleep 0.4 && osascript -l JavaScript -e ${shellQuote(QUIT_JXA_SCRIPT)} ${shellQuote(bundleId)} ${shellQuote(app.name)}`;
589
+ const pids = app.pids && app.pids.length > 0 ? app.pids : app.pid ? [app.pid] : [];
590
+ const pidArg = app.pid ? ` ${shellQuote(String(app.pid))}` : "";
591
+ const cmd = force && pids.length > 0 ? `sleep 0.4 && kill -9 ${pids.join(" ")}` : `sleep 0.4 && osascript -l JavaScript -e ${shellQuote(QUIT_JXA_SCRIPT)} ${shellQuote(bundleId)} ${shellQuote(app.name)}${pidArg}`;
372
592
  const child = spawn("sh", ["-c", cmd], {
373
593
  detached: true,
374
594
  stdio: "ignore"
@@ -378,16 +598,48 @@ function defaultDeferredQuitScheduler(app, force = false) {
378
598
  }
379
599
  }
380
600
  async function sendQuitSignal(app, executor) {
381
- const res = await runJXA(
382
- QUIT_JXA_SCRIPT,
383
- [app.bundleId ?? "", app.name],
384
- executor
385
- );
601
+ if (app.bundleId === "com.apple.trash" || app.name.toLowerCase() === "trash") {
602
+ await runAppleScript(EMPTY_TRASH_SCRIPT, executor);
603
+ return;
604
+ }
605
+ if (app.pids && app.pids.length > 1) {
606
+ let anyQuit = false;
607
+ for (const pid of app.pids) {
608
+ const args2 = [app.bundleId ?? "", app.name, String(pid)];
609
+ try {
610
+ const res2 = await runJXA(QUIT_JXA_SCRIPT, args2, executor);
611
+ if (res2.trim().toLowerCase() === "true") {
612
+ anyQuit = true;
613
+ }
614
+ } catch {
615
+ }
616
+ }
617
+ if (!anyQuit) {
618
+ throw new Error(`Could not quit "${app.name}"`);
619
+ }
620
+ return;
621
+ }
622
+ const args = [app.bundleId ?? "", app.name];
623
+ if (app.pid && app.pid > 0) {
624
+ args.push(String(app.pid));
625
+ }
626
+ const res = await runJXA(QUIT_JXA_SCRIPT, args, executor);
386
627
  if (res.trim().toLowerCase() !== "true") {
387
628
  throw new Error(`Could not quit "${app.name}"`);
388
629
  }
389
630
  }
390
631
  function forceQuitApp(app) {
632
+ if (app.pids && app.pids.length > 0) {
633
+ let anyKilled = false;
634
+ for (const pid of app.pids) {
635
+ try {
636
+ process.kill(pid, "SIGKILL");
637
+ anyKilled = true;
638
+ } catch {
639
+ }
640
+ }
641
+ return anyKilled;
642
+ }
391
643
  if (app.pid && app.pid > 0) {
392
644
  try {
393
645
  process.kill(app.pid, "SIGKILL");
@@ -398,10 +650,18 @@ function forceQuitApp(app) {
398
650
  }
399
651
  return false;
400
652
  }
653
+ var DEFAULT_QUIT_TIMEOUT_MS = 8e3;
401
654
  async function quitApps(apps, options = {}, executor, deferredScheduler = defaultDeferredQuitScheduler) {
402
655
  if (apps.length === 0) {
403
656
  return [];
404
657
  }
658
+ if (options.dryRun) {
659
+ return apps.map((app) => ({
660
+ app,
661
+ success: true,
662
+ forced: options.force ?? false
663
+ }));
664
+ }
405
665
  const currentTerminalApps = [];
406
666
  const otherApps = [];
407
667
  for (const app of apps) {
@@ -413,21 +673,33 @@ async function quitApps(apps, options = {}, executor, deferredScheduler = defaul
413
673
  }
414
674
  const results = [];
415
675
  if (otherApps.length > 0) {
416
- const timeoutMs = options.timeoutMs ?? 700;
676
+ const maxTimeoutMs = options.timeoutMs ?? DEFAULT_QUIT_TIMEOUT_MS;
677
+ const pollIntervalMs = Math.min(150, maxTimeoutMs);
417
678
  await Promise.allSettled(
418
679
  otherApps.map((app) => sendQuitSignal(app, executor))
419
680
  );
420
- await sleep(timeoutMs);
421
- for (const app of otherApps) {
422
- const stillRunning = await isAppRunning(app, executor);
423
- if (!stillRunning) {
424
- results.push({ app, success: true, forced: false });
425
- continue;
681
+ const pending = /* @__PURE__ */ new Map();
682
+ for (let i = 0; i < otherApps.length; i++) {
683
+ const app = otherApps[i];
684
+ const key = app.pid ?? `idx_${i}_${app.name}`;
685
+ pending.set(key, app);
686
+ }
687
+ const start = Date.now();
688
+ while (pending.size > 0 && Date.now() - start <= maxTimeoutMs) {
689
+ await sleep(pollIntervalMs);
690
+ for (const [key, app] of Array.from(pending.entries())) {
691
+ const stillRunning = await isAppRunning(app, executor);
692
+ if (!stillRunning) {
693
+ results.push({ app, success: true, forced: false });
694
+ pending.delete(key);
695
+ }
426
696
  }
697
+ }
698
+ for (const app of pending.values()) {
427
699
  if (options.force) {
428
700
  const killed = forceQuitApp(app);
429
701
  if (killed) {
430
- await sleep(100);
702
+ await sleep(150);
431
703
  const aliveAfterForce = await isAppRunning(app, executor);
432
704
  if (!aliveAfterForce) {
433
705
  results.push({ app, success: true, forced: true });
@@ -469,14 +741,15 @@ function showOutro(message) {
469
741
  function showCancel(message = "Cancelled.") {
470
742
  cancel(message);
471
743
  }
472
- function renderResults(results) {
744
+ function renderResults(results, dryRun = false) {
473
745
  for (const res of results) {
474
746
  if (res.success) {
475
- const label = res.forced ? `Force quit ${res.app.name}` : res.app.name;
747
+ const label = dryRun ? res.forced ? `[dry-run] Would force quit ${res.app.name}` : `[dry-run] Would quit ${res.app.name}` : res.forced ? `Force quit ${res.app.name}` : res.app.name;
476
748
  log.success(label);
477
749
  } else {
750
+ const prefix = dryRun ? "[dry-run] " : "";
478
751
  const reason = res.error ? ` (${res.error})` : "";
479
- log.error(`Could not quit ${res.app.name}${reason}`);
752
+ log.error(`${prefix}Could not quit ${res.app.name}${reason}`);
480
753
  }
481
754
  }
482
755
  }
@@ -502,27 +775,37 @@ OPTIONS:
502
775
  -y, --yes Skip confirmation prompts
503
776
  -l, --list List running GUI apps and exit
504
777
  -f, --force Force quit apps (SIGKILL) if normal quit fails
778
+ --dry-run Simulate quitting without terminating apps
505
779
  -b, --background Include background processes in app list
506
780
  --include-finder Include Finder in the app list
781
+ --include-trash Include Trash in the app list (empties on quit)
507
782
  --exclude <apps> Add apps to persistent exclude list (comma-separated)
783
+ --check-update Check for package updates (60s rate limit)
784
+ --no-update-check Disable automatic update check for this run
508
785
  --config Open interactive config manager
509
786
  --json Output data in JSON format
510
787
  -h, --help Show help information
511
788
  -v, --version Show version number
512
789
 
513
790
  COMMANDS:
514
- config Manage configuration interactively
791
+ config [action] Manage configuration (show, get, set, reset)
515
792
  exclude Manage excluded applications interactively
793
+ check-update Check for package updates
516
794
 
517
795
  EXAMPLES:
518
796
  $ quitx Interactive app selector
519
797
  $ quitx --all Quit all running apps (with confirmation)
798
+ $ quitx --all --dry-run Preview quitting all apps without killing them
520
799
  $ quitx --all --yes Quit all running apps immediately
521
800
  $ quitx --list List currently running GUI apps
522
801
  $ quitx --list --background List all apps including background processes
523
802
  $ quitx --list --include-finder List apps including Finder
524
803
  $ quitx config Open interactive config manager
804
+ $ quitx config show Print current configuration
805
+ $ quitx config set force true Set default quit mode to force
806
+ $ quitx config get exclude Print excluded apps list
525
807
  $ quitx exclude Open interactive menu to manage excluded apps
808
+ $ quitx check-update Check for package updates
526
809
  $ quitx --exclude Spotify,Slack Add Spotify and Slack to exclude list
527
810
  $ quitx Slack,Discord Quit specific apps directly
528
811
  `.trim()
@@ -531,7 +814,7 @@ EXAMPLES:
531
814
  function renderVersion(version) {
532
815
  console.log(`quitx v${version}`);
533
816
  }
534
- var THANKS_MESSAGE = "\nThanks for using quitx..!";
817
+ var THANKS_MESSAGE = "\nThanks for using quitx..!\nFor more visit - quitx.js.org";
535
818
  function printThanks(options) {
536
819
  if (options?.json || options?.quiet) return;
537
820
  console.log(THANKS_MESSAGE);
@@ -541,125 +824,40 @@ var BACK_VALUE = "back";
541
824
  function backOption() {
542
825
  return { value: BACK_VALUE, label: "Back to main menu" };
543
826
  }
544
- function handleQuitAllToggle(currentValue, selectedValues) {
545
- if (currentValue === SELECT_ALL_VALUE) {
546
- if (selectedValues.includes(SELECT_ALL_VALUE)) {
547
- return [];
548
- }
549
- return [SELECT_ALL_VALUE];
550
- }
551
- if (selectedValues.includes(SELECT_ALL_VALUE)) {
552
- return [...selectedValues];
553
- }
554
- const isSelected = selectedValues.includes(currentValue);
555
- return isSelected ? selectedValues.filter((v) => v !== currentValue) : [...selectedValues, currentValue];
556
- }
557
- function attachQuitAllBehavior(prompt) {
558
- let _cursor = prompt.cursor ?? 0;
559
- let _value = prompt.value ? [...prompt.value] : [];
560
- Object.defineProperty(prompt, "cursor", {
561
- get() {
562
- return _cursor;
563
- },
564
- set(val) {
565
- if (_value.includes(SELECT_ALL_VALUE)) {
566
- _cursor = 0;
567
- } else {
568
- _cursor = val;
569
- }
570
- },
571
- configurable: true
572
- });
573
- Object.defineProperty(prompt, "value", {
574
- get() {
575
- return _value;
576
- },
577
- set(val) {
578
- if (!Array.isArray(val)) {
579
- _value = [];
580
- return;
581
- }
582
- if (val.includes(SELECT_ALL_VALUE)) {
583
- _value = [SELECT_ALL_VALUE];
584
- _cursor = 0;
585
- } else {
586
- _value = val.filter((v) => v !== SELECT_ALL_VALUE);
587
- }
588
- },
589
- configurable: true
590
- });
591
- prompt.toggleValue = function() {
592
- const cur = prompt.options[prompt.cursor ?? 0]?.value;
593
- if (!cur) return;
594
- prompt.value = handleQuitAllToggle(cur, prompt.value ?? []);
595
- };
596
- prompt.toggleAll = function() {
597
- if (prompt.value?.includes(SELECT_ALL_VALUE)) {
598
- prompt.value = [];
599
- } else {
600
- prompt.value = [SELECT_ALL_VALUE];
601
- }
602
- };
603
- prompt.toggleInvert = function() {
604
- if (prompt.value?.includes(SELECT_ALL_VALUE)) {
605
- prompt.value = [];
606
- } else {
607
- prompt.value = [SELECT_ALL_VALUE];
608
- }
609
- };
610
- }
611
- async function selectApps(apps) {
827
+ async function selectApps(apps, options = {}) {
612
828
  if (apps.length === 0) {
613
829
  return [];
614
830
  }
615
- const appOptions = apps.map((app) => {
616
- const hint = app.bundleId ?? (app.pid ? `PID: ${app.pid}` : void 0);
831
+ const bundleCounts = /* @__PURE__ */ new Map();
832
+ for (const app of apps) {
833
+ const k = app.bundleId ?? app.name;
834
+ bundleCounts.set(k, (bundleCounts.get(k) ?? 0) + 1);
835
+ }
836
+ const appOptions = apps.map((app, index) => {
837
+ let hint = app.bundleId ?? (app.pid ? `PID: ${app.pid}` : void 0);
838
+ if (app.count && app.count > 1) {
839
+ hint = app.bundleId ? `${app.bundleId} (${app.count} instances)` : `${app.count} instances`;
840
+ } else if (app.pid && app.isBackground) {
841
+ hint = app.bundleId ? `${app.bundleId} (PID: ${app.pid})` : `PID: ${app.pid}`;
842
+ }
843
+ const base = app.bundleId ?? app.name;
844
+ const isDup = (bundleCounts.get(base) ?? 0) > 1;
845
+ const value = isDup && app.pid ? `${base}:${app.pid}` : app.bundleId ?? `${app.name}-${app.pid ?? index}`;
846
+ const label = app.count && app.count > 1 ? `${app.name} (${app.count})` : app.name;
617
847
  return {
618
- value: app.bundleId ?? `${app.name}-${app.pid ?? 0}`,
619
- label: app.name,
848
+ value,
849
+ label,
620
850
  ...hint ? { hint } : {}
621
851
  };
622
852
  });
623
- const options = [
624
- {
625
- value: SELECT_ALL_VALUE,
626
- label: "Quit all apps",
627
- hint: `${apps.length} eligible apps`
628
- },
629
- ...appOptions
630
- ];
631
- const initialValues = [SELECT_ALL_VALUE];
632
- const proto = MultiSelectPrompt?.prototype;
633
- const origPrompt = proto?.prompt;
634
- const origToggleValue = proto?.toggleValue;
635
- const origToggleAll = proto?.toggleAll;
636
- const origToggleInvert = proto?.toggleInvert;
637
- let selected;
638
- try {
639
- if (proto && origPrompt) {
640
- proto.prompt = function() {
641
- if (this.options?.some((o) => o.value === SELECT_ALL_VALUE)) {
642
- attachQuitAllBehavior(
643
- this
644
- );
645
- }
646
- return origPrompt.call(this);
647
- };
648
- }
649
- selected = await multiselect({
650
- message: "Select apps to quit",
651
- options,
652
- required: false,
653
- initialValues
654
- });
655
- } finally {
656
- if (proto && origPrompt) proto.prompt = origPrompt;
657
- if (proto && origToggleValue) proto.toggleValue = origToggleValue;
658
- if (proto && origToggleAll) proto.toggleAll = origToggleAll;
659
- if (proto && origToggleInvert) {
660
- proto.toggleInvert = origToggleInvert;
661
- }
662
- }
853
+ const defaultSelectAll = options.defaultSelectAll ?? true;
854
+ const initialValues = defaultSelectAll ? appOptions.map((opt) => opt.value) : [];
855
+ const selected = await multiselect({
856
+ message: "Select apps to quit",
857
+ options: appOptions,
858
+ required: false,
859
+ initialValues
860
+ });
663
861
  if (isCancel(selected) || !Array.isArray(selected)) {
664
862
  return selected;
665
863
  }
@@ -671,9 +869,10 @@ async function selectApps(apps) {
671
869
  return [...apps];
672
870
  }
673
871
  const selectedSet = new Set(selectedArray);
674
- return apps.filter((app) => {
675
- const key = app.bundleId ?? `${app.name}-${app.pid ?? 0}`;
676
- return selectedSet.has(key);
872
+ return apps.filter((app, index) => {
873
+ const optValue = appOptions[index]?.value;
874
+ const key = app.bundleId ?? `${app.name}-${app.pid ?? index}`;
875
+ return optValue && selectedSet.has(optValue) || app.bundleId && selectedSet.has(app.bundleId) || selectedSet.has(key);
677
876
  });
678
877
  }
679
878
  async function shouldConfirmQuit(count, isAll = false, forceYes = false) {
@@ -699,11 +898,17 @@ async function allCommand(options = {}) {
699
898
  const config = loadConfig();
700
899
  const useForce = options.force ?? config.force === "force";
701
900
  const includeFinder = options.includeFinder ?? config.includeFinder;
901
+ const includeTrash = options.includeTrash ?? config.includeTrash;
702
902
  const includeBackground = options.includeBackground ?? config.includeBackground;
903
+ const neverQuitMusic = options.neverQuitMusic ?? config.neverQuitMusic;
703
904
  const apps = await getRunningApps({
704
905
  exclude: config.exclude,
705
906
  includeFinder,
706
- includeBackground
907
+ includeTrash,
908
+ includeBackground,
909
+ groupBackground: config.groupBackground,
910
+ neverQuitMusic,
911
+ musicApps: options.musicApps ?? config.musicApps
707
912
  });
708
913
  if (apps.length === 0) {
709
914
  if (options.json) {
@@ -724,14 +929,20 @@ async function allCommand(options = {}) {
724
929
  }
725
930
  const s = spinner();
726
931
  if (!options.json) {
727
- s.start(`Quitting ${apps.length} apps...`);
932
+ s.start(
933
+ options.dryRun ? `Simulating quit for ${apps.length} apps...` : `Quitting ${apps.length} apps...`
934
+ );
728
935
  }
729
- const results = await quitApps(apps, { force: useForce });
936
+ const results = await quitApps(apps, {
937
+ force: useForce,
938
+ dryRun: options.dryRun
939
+ });
730
940
  if (!options.json) {
731
- s.stop("Quitting complete");
732
- renderResults(results);
941
+ s.stop(options.dryRun ? "Dry run complete" : "Quitting complete");
942
+ renderResults(results, options.dryRun);
733
943
  const successCount = results.filter((r) => r.success).length;
734
- showOutro(`Quit ${successCount} of ${apps.length} apps.`);
944
+ const actionVerb = options.dryRun ? "Would quit" : "Quit";
945
+ showOutro(`${actionVerb} ${successCount} of ${apps.length} apps.`);
735
946
  printThanks(options);
736
947
  } else {
737
948
  console.log(
@@ -798,6 +1009,37 @@ async function toggleIncludeFinder() {
798
1009
  `Include Finder: ${config.includeFinder ? "enabled" : "disabled"}`
799
1010
  );
800
1011
  }
1012
+ async function toggleIncludeTrash() {
1013
+ const config = loadConfig();
1014
+ const choice = await select({
1015
+ message: `Include Empty Trash in list (current: ${config.includeTrash ? "enabled" : "disabled"})`,
1016
+ options: [
1017
+ {
1018
+ value: "disabled",
1019
+ label: "Disabled",
1020
+ hint: "Empty Trash is not shown in the app list."
1021
+ },
1022
+ {
1023
+ value: "enabled",
1024
+ label: "Enabled",
1025
+ hint: "Shows 'Empty Trash' in list. Quitting it PERMANENTLY deletes all items in Trash (cannot be undone)."
1026
+ },
1027
+ backOption()
1028
+ ],
1029
+ initialValue: config.includeTrash ? "enabled" : "disabled"
1030
+ });
1031
+ if (isCancel(choice) || choice === BACK_VALUE) return;
1032
+ config.includeTrash = choice === "enabled";
1033
+ saveConfig(config);
1034
+ log.success(
1035
+ `Include Empty Trash in list: ${config.includeTrash ? "enabled" : "disabled"}`
1036
+ );
1037
+ if (config.includeTrash) {
1038
+ log.warn(
1039
+ "Note: Quitting 'Empty Trash' permanently and irreversibly deletes all files in macOS Trash."
1040
+ );
1041
+ }
1042
+ }
801
1043
  async function toggleIncludeBackground() {
802
1044
  const config = loadConfig();
803
1045
  const choice = await select({
@@ -824,6 +1066,111 @@ async function toggleIncludeBackground() {
824
1066
  `Background apps: ${config.includeBackground ? "enabled" : "disabled"}`
825
1067
  );
826
1068
  }
1069
+ async function toggleDefaultSelection() {
1070
+ const config = loadConfig();
1071
+ const currentVal = config.defaultSelectAll ? "select-all" : "deselect-all";
1072
+ const choice = await select({
1073
+ message: `Default App Selection (current: ${currentVal})`,
1074
+ options: [
1075
+ {
1076
+ value: "select-all",
1077
+ label: "Select all apps",
1078
+ hint: "All apps are pre-selected by default when launching quitx."
1079
+ },
1080
+ {
1081
+ value: "deselect-all",
1082
+ label: "Deselect all apps",
1083
+ hint: "No apps are selected by default. Pick specific apps to quit."
1084
+ },
1085
+ backOption()
1086
+ ],
1087
+ initialValue: currentVal
1088
+ });
1089
+ if (isCancel(choice) || choice === BACK_VALUE) return;
1090
+ config.defaultSelectAll = choice === "select-all";
1091
+ saveConfig(config);
1092
+ log.success(
1093
+ `Default selection: ${config.defaultSelectAll ? "select-all" : "deselect-all"}`
1094
+ );
1095
+ }
1096
+ async function toggleGroupBackground() {
1097
+ const config = loadConfig();
1098
+ const choice = await select({
1099
+ message: `Group Background Instances (current: ${config.groupBackground ? "enabled" : "disabled"})`,
1100
+ options: [
1101
+ {
1102
+ value: "enabled",
1103
+ label: "Enabled",
1104
+ hint: "Combines multiple instances of the same background app into a single item with instance count."
1105
+ },
1106
+ {
1107
+ value: "disabled",
1108
+ label: "Disabled",
1109
+ hint: "Lists each background instance separately with its process ID (PID)."
1110
+ },
1111
+ backOption()
1112
+ ],
1113
+ initialValue: config.groupBackground ? "enabled" : "disabled"
1114
+ });
1115
+ if (isCancel(choice) || choice === BACK_VALUE) return;
1116
+ config.groupBackground = choice === "enabled";
1117
+ saveConfig(config);
1118
+ log.success(
1119
+ `Group background instances: ${config.groupBackground ? "enabled" : "disabled"}`
1120
+ );
1121
+ }
1122
+ async function toggleNeverQuitMusic() {
1123
+ const config = loadConfig();
1124
+ const choice = await select({
1125
+ message: `Never Quit Music Apps (current: ${config.neverQuitMusic ? "enabled" : "disabled"})`,
1126
+ options: [
1127
+ {
1128
+ value: "enabled",
1129
+ label: "Enabled",
1130
+ hint: "Never show or quit music players (Spotify, Apple Music, Tidal, etc.) to keep your audio playing."
1131
+ },
1132
+ {
1133
+ value: "disabled",
1134
+ label: "Disabled",
1135
+ hint: "Include music apps in quit list normally."
1136
+ },
1137
+ backOption()
1138
+ ],
1139
+ initialValue: config.neverQuitMusic ? "enabled" : "disabled"
1140
+ });
1141
+ if (isCancel(choice) || choice === BACK_VALUE) return;
1142
+ config.neverQuitMusic = choice === "enabled";
1143
+ saveConfig(config);
1144
+ log.success(
1145
+ `Never quit music apps: ${config.neverQuitMusic ? "enabled" : "disabled"}`
1146
+ );
1147
+ }
1148
+ async function toggleAutoUpdate() {
1149
+ const config = loadConfig();
1150
+ const choice = await select({
1151
+ message: `Automatic Update Checks (current: ${config.autoUpdate ? "enabled" : "disabled"})`,
1152
+ options: [
1153
+ {
1154
+ value: "enabled",
1155
+ label: "Enabled",
1156
+ hint: "Check npm registry in background every 6 hours for updates."
1157
+ },
1158
+ {
1159
+ value: "disabled",
1160
+ label: "Disabled",
1161
+ hint: "Never perform automatic background update checks."
1162
+ },
1163
+ backOption()
1164
+ ],
1165
+ initialValue: config.autoUpdate ? "enabled" : "disabled"
1166
+ });
1167
+ if (isCancel(choice) || choice === BACK_VALUE) return;
1168
+ config.autoUpdate = choice === "enabled";
1169
+ saveConfig(config);
1170
+ log.success(
1171
+ `Automatic update checks: ${config.autoUpdate ? "enabled" : "disabled"}`
1172
+ );
1173
+ }
827
1174
  async function manageExcludedApps() {
828
1175
  const choice = await select({
829
1176
  message: "Manage excluded apps",
@@ -848,13 +1195,12 @@ ${config.exclude.map((e) => ` - ${e}`).join("\n")}`
848
1195
  return;
849
1196
  }
850
1197
  if (choice === "add") {
851
- const apps = await getRunningApps();
852
- const currentSet = new Set(
853
- config.exclude.map((e) => e.toLowerCase().trim())
854
- );
855
- const available = apps.filter(
856
- (a) => !currentSet.has(a.name.toLowerCase().trim()) && !(a.bundleId && currentSet.has(a.bundleId.toLowerCase().trim()))
857
- );
1198
+ const available = await getRunningApps({
1199
+ exclude: config.exclude,
1200
+ includeFinder: config.includeFinder,
1201
+ includeBackground: config.includeBackground,
1202
+ groupBackground: config.groupBackground
1203
+ });
858
1204
  if (available.length === 0) {
859
1205
  log.info("All running apps are already excluded.");
860
1206
  return;
@@ -899,8 +1245,88 @@ ${config.exclude.map((e) => ` - ${e}`).join("\n")}`
899
1245
  log.success(`Removed ${selected.length} apps from exclude list.`);
900
1246
  }
901
1247
  }
1248
+ async function manageCustomMusicApps() {
1249
+ const choice = await select({
1250
+ message: "Manage custom music apps",
1251
+ options: [
1252
+ { value: "add", label: "Add running apps to custom music list" },
1253
+ { value: "remove", label: "Remove apps from custom music list" },
1254
+ { value: "view", label: "View current custom music list" },
1255
+ backOption()
1256
+ ]
1257
+ });
1258
+ if (isCancel(choice) || choice === BACK_VALUE) return;
1259
+ const config = loadConfig();
1260
+ if (choice === "view") {
1261
+ if (config.musicApps.length === 0) {
1262
+ log.info("No custom music apps registered.");
1263
+ } else {
1264
+ log.info(
1265
+ `Custom music apps:
1266
+ ${config.musicApps.map((e) => ` - ${e}`).join("\n")}`
1267
+ );
1268
+ }
1269
+ return;
1270
+ }
1271
+ if (choice === "add") {
1272
+ const available = await getRunningApps({
1273
+ exclude: config.exclude,
1274
+ includeFinder: config.includeFinder,
1275
+ includeBackground: config.includeBackground,
1276
+ groupBackground: config.groupBackground,
1277
+ neverQuitMusic: false
1278
+ });
1279
+ const unadded = available.filter(
1280
+ (a) => !config.musicApps.some(
1281
+ (m) => m.toLowerCase() === a.name.toLowerCase() || a.bundleId && m.toLowerCase() === a.bundleId.toLowerCase()
1282
+ )
1283
+ );
1284
+ if (unadded.length === 0) {
1285
+ log.info("All running apps are already in custom music list.");
1286
+ return;
1287
+ }
1288
+ const selected = await multiselect({
1289
+ message: "Select apps to mark as music players",
1290
+ options: unadded.map((a) => ({
1291
+ value: a.name,
1292
+ label: a.name,
1293
+ ...a.bundleId ? { hint: a.bundleId } : {}
1294
+ })),
1295
+ required: false
1296
+ });
1297
+ if (isCancel(selected)) return;
1298
+ if (!Array.isArray(selected) || selected.length === 0) {
1299
+ log.info("No apps selected.");
1300
+ return;
1301
+ }
1302
+ addMusicApps(selected);
1303
+ log.success(`Added ${selected.length} apps to custom music list.`);
1304
+ return;
1305
+ }
1306
+ if (choice === "remove") {
1307
+ if (config.musicApps.length === 0) {
1308
+ log.info("No apps currently in custom music list.");
1309
+ return;
1310
+ }
1311
+ const selected = await multiselect({
1312
+ message: "Select apps to remove from custom music list",
1313
+ options: config.musicApps.map((name) => ({
1314
+ value: name,
1315
+ label: name
1316
+ })),
1317
+ required: false
1318
+ });
1319
+ if (isCancel(selected)) return;
1320
+ if (!Array.isArray(selected) || selected.length === 0) {
1321
+ log.info("No apps selected.");
1322
+ return;
1323
+ }
1324
+ removeMusicApps(selected);
1325
+ log.success(`Removed ${selected.length} apps from custom music list.`);
1326
+ }
1327
+ }
902
1328
  function resetConfig() {
903
- saveConfig({ ...DEFAULT_CONFIG, exclude: [] });
1329
+ saveConfig({ ...DEFAULT_CONFIG, exclude: [], musicApps: [] });
904
1330
  log.success("Config reset to defaults.");
905
1331
  }
906
1332
  async function configCommand() {
@@ -913,28 +1339,58 @@ async function configCommand() {
913
1339
  options: [
914
1340
  {
915
1341
  value: "quit-mode",
916
- label: `Toggle Quit Mode`,
917
- hint: `current: ${config.force}`
1342
+ label: "Quit Mode",
1343
+ hint: `current: ${config.force === "normal" ? "Normal quit" : "Force quit"}`
1344
+ },
1345
+ {
1346
+ value: "background",
1347
+ label: "View background apps",
1348
+ hint: `current: ${config.includeBackground ? "enabled" : "disabled"}`
1349
+ },
1350
+ {
1351
+ value: "group-background",
1352
+ label: "Group background instances",
1353
+ hint: `current: ${config.groupBackground ? "enabled" : "disabled"}`
1354
+ },
1355
+ {
1356
+ value: "never-quit-music",
1357
+ label: "Never quit music apps",
1358
+ hint: `current: ${config.neverQuitMusic ? "enabled" : "disabled"}`
1359
+ },
1360
+ {
1361
+ value: "auto-update",
1362
+ label: "Automatic update checks",
1363
+ hint: `current: ${config.autoUpdate ? "enabled" : "disabled"}`
1364
+ },
1365
+ {
1366
+ value: "default-select",
1367
+ label: "Deselect apps by default",
1368
+ hint: `current: ${!config.defaultSelectAll ? "enabled" : "disabled"}`
918
1369
  },
919
1370
  {
920
1371
  value: "finder",
921
- label: `Toggle Include Finder`,
1372
+ label: "Include Finder Windows in list",
922
1373
  hint: `current: ${config.includeFinder ? "enabled" : "disabled"}`
923
1374
  },
924
1375
  {
925
- value: "background",
926
- label: `Toggle Background Apps`,
927
- hint: `current: ${config.includeBackground ? "enabled" : "disabled"}`
1376
+ value: "trash",
1377
+ label: "Include Empty Trash in list",
1378
+ hint: `current: ${config.includeTrash ? "enabled (empties trash)" : "disabled"}`
928
1379
  },
929
1380
  {
930
1381
  value: "exclude",
931
- label: "Manage Excluded Apps",
1382
+ label: "Manage excluded apps",
932
1383
  hint: `${config.exclude.length} excluded`
933
1384
  },
1385
+ {
1386
+ value: "custom-music",
1387
+ label: "Manage custom music apps",
1388
+ hint: `${config.musicApps.length} custom apps`
1389
+ },
934
1390
  {
935
1391
  value: "reset",
936
- label: "Reset to Defaults",
937
- hint: "restore all settings"
1392
+ label: "Reset all",
1393
+ hint: "restore all settings to defaults"
938
1394
  },
939
1395
  { value: "exit", label: "Exit" }
940
1396
  ]
@@ -944,15 +1400,155 @@ async function configCommand() {
944
1400
  continue;
945
1401
  }
946
1402
  if (choice === "quit-mode") await toggleQuitMode();
947
- if (choice === "finder") await toggleIncludeFinder();
948
1403
  if (choice === "background") await toggleIncludeBackground();
1404
+ if (choice === "group-background") await toggleGroupBackground();
1405
+ if (choice === "never-quit-music") await toggleNeverQuitMusic();
1406
+ if (choice === "auto-update") await toggleAutoUpdate();
1407
+ if (choice === "default-select") await toggleDefaultSelection();
1408
+ if (choice === "finder") await toggleIncludeFinder();
1409
+ if (choice === "trash") await toggleIncludeTrash();
949
1410
  if (choice === "exclude") await manageExcludedApps();
1411
+ if (choice === "custom-music") await manageCustomMusicApps();
950
1412
  if (choice === "reset") resetConfig();
951
1413
  }
952
1414
  showOutro("Config saved.");
953
1415
  printThanks();
954
1416
  return 0;
955
1417
  }
1418
+ var VALID_CONFIG_KEYS = [
1419
+ "exclude",
1420
+ "force",
1421
+ "includeFinder",
1422
+ "includeTrash",
1423
+ "includeBackground",
1424
+ "groupBackground",
1425
+ "defaultSelectAll",
1426
+ "neverQuitMusic",
1427
+ "musicApps",
1428
+ "autoUpdate"
1429
+ ];
1430
+ async function handleConfigCli(options = {}) {
1431
+ const action = options.configAction;
1432
+ if (!action) {
1433
+ return await configCommand();
1434
+ }
1435
+ const config = loadConfig();
1436
+ if (action === "show") {
1437
+ if (options.json) {
1438
+ console.log(JSON.stringify(config, null, 2));
1439
+ return 0;
1440
+ }
1441
+ console.log("quitx configuration:");
1442
+ for (const [key, value] of Object.entries(config)) {
1443
+ const displayVal = Array.isArray(value) ? value.length === 0 ? "[]" : `[${value.join(", ")}]` : String(value);
1444
+ console.log(` ${key}: ${displayVal}`);
1445
+ }
1446
+ return 0;
1447
+ }
1448
+ if (action === "get") {
1449
+ if (!options.configKey) {
1450
+ console.error("\u2716 Missing config key. Usage: quitx config get <key>");
1451
+ return 1;
1452
+ }
1453
+ const key = options.configKey;
1454
+ if (!VALID_CONFIG_KEYS.includes(key)) {
1455
+ console.error(
1456
+ `\u2716 Unknown config key: "${options.configKey}". Valid keys: ${VALID_CONFIG_KEYS.join(", ")}`
1457
+ );
1458
+ return 1;
1459
+ }
1460
+ const val = config[key];
1461
+ if (options.json) {
1462
+ console.log(JSON.stringify({ [key]: val }, null, 2));
1463
+ } else if (Array.isArray(val)) {
1464
+ console.log(val.join(", "));
1465
+ } else {
1466
+ console.log(String(val));
1467
+ }
1468
+ return 0;
1469
+ }
1470
+ if (action === "set") {
1471
+ if (!options.configKey) {
1472
+ console.error(
1473
+ "\u2716 Missing config key. Usage: quitx config set <key> <value>"
1474
+ );
1475
+ return 1;
1476
+ }
1477
+ if (options.configValue === void 0) {
1478
+ console.error(
1479
+ `\u2716 Missing value for "${options.configKey}". Usage: quitx config set <key> <value>`
1480
+ );
1481
+ return 1;
1482
+ }
1483
+ const key = options.configKey;
1484
+ if (!VALID_CONFIG_KEYS.includes(key)) {
1485
+ console.error(
1486
+ `\u2716 Unknown config key: "${options.configKey}". Valid keys: ${VALID_CONFIG_KEYS.join(", ")}`
1487
+ );
1488
+ return 1;
1489
+ }
1490
+ const raw = options.configValue.trim();
1491
+ if (key === "force") {
1492
+ const lower = raw.toLowerCase();
1493
+ if (lower === "force" || lower === "true" || lower === "1") {
1494
+ config.force = "force";
1495
+ } else if (lower === "normal" || lower === "false" || lower === "0") {
1496
+ config.force = "normal";
1497
+ } else {
1498
+ console.error(
1499
+ `\u2716 Invalid value for force: "${raw}". Must be "normal" or "force".`
1500
+ );
1501
+ return 1;
1502
+ }
1503
+ } else if (key === "includeFinder" || key === "includeTrash" || key === "includeBackground" || key === "groupBackground" || key === "defaultSelectAll" || key === "neverQuitMusic" || key === "autoUpdate") {
1504
+ const lower = raw.toLowerCase();
1505
+ if (["true", "1", "yes", "on", "enable", "enabled"].includes(lower)) {
1506
+ config[key] = true;
1507
+ } else if (["false", "0", "no", "off", "disable", "disabled"].includes(lower)) {
1508
+ config[key] = false;
1509
+ } else {
1510
+ console.error(
1511
+ `\u2716 Invalid boolean for ${key}: "${raw}". Use true or false.`
1512
+ );
1513
+ return 1;
1514
+ }
1515
+ } else if (key === "exclude" || key === "musicApps") {
1516
+ const items = raw.split(",").map((s) => s.trim()).filter(Boolean);
1517
+ config[key] = items;
1518
+ }
1519
+ saveConfig(config);
1520
+ if (options.json) {
1521
+ console.log(JSON.stringify({ [key]: config[key] }, null, 2));
1522
+ } else {
1523
+ const displayVal = Array.isArray(config[key]) ? `[${config[key].join(", ")}]` : String(config[key]);
1524
+ console.log(`Set ${key} = ${displayVal}`);
1525
+ }
1526
+ return 0;
1527
+ }
1528
+ if (action === "reset") {
1529
+ if (!options.yes) {
1530
+ const confirm2 = await select({
1531
+ message: "Reset all settings to defaults?",
1532
+ options: [
1533
+ { value: "yes", label: "Yes, reset all" },
1534
+ { value: "no", label: "No, keep current settings" }
1535
+ ],
1536
+ initialValue: "no"
1537
+ });
1538
+ if (isCancel(confirm2) || confirm2 !== "yes") {
1539
+ return 0;
1540
+ }
1541
+ }
1542
+ saveConfig({ ...DEFAULT_CONFIG, exclude: [], musicApps: [] });
1543
+ if (options.json) {
1544
+ console.log(JSON.stringify({ reset: true }, null, 2));
1545
+ } else {
1546
+ log.success("Config reset to defaults.");
1547
+ }
1548
+ return 0;
1549
+ }
1550
+ return 0;
1551
+ }
956
1552
  async function manageExcludeInteractive() {
957
1553
  showIntro();
958
1554
  const choice = await select({
@@ -982,13 +1578,12 @@ ${currentConfig.exclude.map((e) => ` - ${e}`).join("\n")}`
982
1578
  return 0;
983
1579
  }
984
1580
  if (choice === "add") {
985
- const apps = await getRunningApps();
986
- const currentSet = new Set(
987
- currentConfig.exclude.map((e) => e.toLowerCase().trim())
988
- );
989
- const available = apps.filter(
990
- (a) => !currentSet.has(a.name.toLowerCase().trim()) && !(a.bundleId && currentSet.has(a.bundleId.toLowerCase().trim()))
991
- );
1581
+ const available = await getRunningApps({
1582
+ exclude: currentConfig.exclude,
1583
+ includeFinder: currentConfig.includeFinder,
1584
+ includeBackground: currentConfig.includeBackground,
1585
+ groupBackground: currentConfig.groupBackground
1586
+ });
992
1587
  if (available.length === 0) {
993
1588
  showOutro("All running apps are already excluded.");
994
1589
  printThanks();
@@ -1077,11 +1672,17 @@ async function interactiveCommand(options = {}) {
1077
1672
  const config = loadConfig();
1078
1673
  const useForce = options.force ?? config.force === "force";
1079
1674
  const includeFinder = options.includeFinder ?? config.includeFinder;
1675
+ const includeTrash = options.includeTrash ?? config.includeTrash;
1080
1676
  const includeBackground = options.includeBackground ?? config.includeBackground;
1677
+ const neverQuitMusic = options.neverQuitMusic ?? config.neverQuitMusic;
1081
1678
  const apps = await getRunningApps({
1082
1679
  exclude: config.exclude,
1083
1680
  includeFinder,
1084
- includeBackground
1681
+ includeTrash,
1682
+ includeBackground,
1683
+ groupBackground: config.groupBackground,
1684
+ neverQuitMusic,
1685
+ musicApps: options.musicApps ?? config.musicApps
1085
1686
  });
1086
1687
  if (!options.json) {
1087
1688
  s.stop(
@@ -1120,7 +1721,9 @@ async function interactiveCommand(options = {}) {
1120
1721
  return 0;
1121
1722
  }
1122
1723
  } else {
1123
- const selected = await selectApps(apps);
1724
+ const selected = await selectApps(apps, {
1725
+ defaultSelectAll: config.defaultSelectAll
1726
+ });
1124
1727
  if (typeof selected === "symbol" || isCancel(selected)) {
1125
1728
  showCancel("Cancelled.");
1126
1729
  printThanks(options);
@@ -1134,11 +1737,7 @@ async function interactiveCommand(options = {}) {
1134
1737
  targetApps = selected;
1135
1738
  }
1136
1739
  if (!options.yes) {
1137
- const confirmation = await shouldConfirmQuit(
1138
- targetApps.length,
1139
- targetApps.length === apps.length,
1140
- false
1141
- );
1740
+ const confirmation = await shouldConfirmQuit(targetApps.length, false);
1142
1741
  if (isCancel(confirmation) || confirmation !== true) {
1143
1742
  showCancel("Cancelled.");
1144
1743
  printThanks(options);
@@ -1147,15 +1746,21 @@ async function interactiveCommand(options = {}) {
1147
1746
  }
1148
1747
  if (!options.json) {
1149
1748
  s.start(
1150
- `Quitting ${targetApps.length} ${targetApps.length === 1 ? "app" : "apps"}...`
1749
+ options.dryRun ? `Simulating quit for ${targetApps.length} ${targetApps.length === 1 ? "app" : "apps"}...` : `Quitting ${targetApps.length} ${targetApps.length === 1 ? "app" : "apps"}...`
1151
1750
  );
1152
1751
  }
1153
- const results = await quitApps(targetApps, { force: useForce });
1752
+ const results = await quitApps(targetApps, {
1753
+ force: useForce,
1754
+ dryRun: options.dryRun
1755
+ });
1154
1756
  if (!options.json) {
1155
- s.stop("Quitting complete");
1156
- renderResults(results);
1757
+ s.stop(options.dryRun ? "Dry run complete" : "Quitting complete");
1758
+ renderResults(results, options.dryRun);
1157
1759
  const successCount = results.filter((r) => r.success).length;
1158
- showOutro(`Done. Quit ${successCount} of ${targetApps.length} apps.`);
1760
+ const actionVerb = options.dryRun ? "Would quit" : "Quit";
1761
+ showOutro(
1762
+ `Done. ${actionVerb} ${successCount} of ${targetApps.length} apps.`
1763
+ );
1159
1764
  printThanks(options);
1160
1765
  } else {
1161
1766
  console.log(
@@ -1177,15 +1782,214 @@ async function interactiveCommand(options = {}) {
1177
1782
  async function listCommand(options = {}) {
1178
1783
  const config = loadConfig();
1179
1784
  const includeFinder = options.includeFinder ?? config.includeFinder;
1785
+ const includeTrash = options.includeTrash ?? config.includeTrash;
1180
1786
  const includeBackground = options.includeBackground ?? config.includeBackground;
1787
+ const neverQuitMusic = options.neverQuitMusic ?? config.neverQuitMusic;
1181
1788
  const apps = await getRunningApps({
1182
1789
  exclude: config.exclude,
1183
1790
  includeFinder,
1184
- includeBackground
1791
+ includeTrash,
1792
+ includeBackground,
1793
+ groupBackground: config.groupBackground,
1794
+ neverQuitMusic,
1795
+ musicApps: options.musicApps ?? config.musicApps
1185
1796
  });
1186
1797
  renderList(apps, options.json);
1187
1798
  return 0;
1188
1799
  }
1800
+ var CHECK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
1801
+ var MANUAL_RATE_LIMIT_MS = 60 * 1e3;
1802
+ var REQUEST_TIMEOUT_MS = 1500;
1803
+ var REGISTRY_URL = "https://registry.npmjs.org/@coreify%2Fquitx/latest";
1804
+ var FALLBACK_REGISTRY_URL = "https://registry.npmjs.org/quitx/latest";
1805
+ var RELEASE_URL = "https://github.com/coreify/quitx/releases/latest";
1806
+ function updateCacheDirectory() {
1807
+ const configured = process.env.XDG_CACHE_HOME;
1808
+ if (configured) return join(configured, "quitx");
1809
+ return join(homedir(), ".quitx");
1810
+ }
1811
+ function parseVersion(version) {
1812
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([\dA-Za-z.-]+))?(?:\+[\dA-Za-z.-]+)?$/u.exec(
1813
+ version.trim()
1814
+ );
1815
+ if (!match) return void 0;
1816
+ const numbers = [
1817
+ Number(match[1]),
1818
+ Number(match[2]),
1819
+ Number(match[3])
1820
+ ];
1821
+ return match[4] ? { numbers, prerelease: match[4].split(".") } : { numbers };
1822
+ }
1823
+ function compareVersions(left, right) {
1824
+ const leftVersion = parseVersion(left);
1825
+ const rightVersion = parseVersion(right);
1826
+ if (!leftVersion || !rightVersion) return 0;
1827
+ for (let index = 0; index < 3; index++) {
1828
+ const difference = leftVersion.numbers[index] - rightVersion.numbers[index];
1829
+ if (difference !== 0) return Math.sign(difference);
1830
+ }
1831
+ if (!leftVersion.prerelease && !rightVersion.prerelease) return 0;
1832
+ if (!leftVersion.prerelease) return 1;
1833
+ if (!rightVersion.prerelease) return -1;
1834
+ const length = Math.max(
1835
+ leftVersion.prerelease.length,
1836
+ rightVersion.prerelease.length
1837
+ );
1838
+ for (let index = 0; index < length; index++) {
1839
+ const leftPart = leftVersion.prerelease[index];
1840
+ const rightPart = rightVersion.prerelease[index];
1841
+ if (leftPart === void 0) return -1;
1842
+ if (rightPart === void 0) return 1;
1843
+ if (leftPart === rightPart) continue;
1844
+ const leftNumber = /^\d+$/u.test(leftPart) ? Number(leftPart) : void 0;
1845
+ const rightNumber = /^\d+$/u.test(rightPart) ? Number(rightPart) : void 0;
1846
+ if (leftNumber !== void 0 && rightNumber !== void 0) {
1847
+ return Math.sign(leftNumber - rightNumber);
1848
+ }
1849
+ if (leftNumber !== void 0) return -1;
1850
+ if (rightNumber !== void 0) return 1;
1851
+ return leftPart.localeCompare(rightPart);
1852
+ }
1853
+ return 0;
1854
+ }
1855
+ async function readCache(path) {
1856
+ try {
1857
+ const value = JSON.parse(await readFile(path, "utf8"));
1858
+ if (typeof value !== "object" || value === null) return {};
1859
+ const record = value;
1860
+ return {
1861
+ ...typeof record.checkedAt === "number" && Number.isFinite(record.checkedAt) ? { checkedAt: record.checkedAt } : {},
1862
+ ...typeof record.ignoredVersion === "string" && parseVersion(record.ignoredVersion) ? { ignoredVersion: record.ignoredVersion } : {},
1863
+ ...typeof record.latestVersion === "string" && parseVersion(record.latestVersion) ? { latestVersion: record.latestVersion } : {}
1864
+ };
1865
+ } catch {
1866
+ return {};
1867
+ }
1868
+ }
1869
+ async function writeCache(path, value) {
1870
+ try {
1871
+ await mkdir(dirname(path), { recursive: true });
1872
+ await writeFile(path, JSON.stringify(value), { mode: 384 });
1873
+ } catch {
1874
+ }
1875
+ }
1876
+ async function fetchLatestVersion(fetcher = fetch) {
1877
+ const urls = [REGISTRY_URL, FALLBACK_REGISTRY_URL];
1878
+ for (const url of urls) {
1879
+ const controller = new AbortController();
1880
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
1881
+ timer.unref();
1882
+ try {
1883
+ const response = await fetcher(url, {
1884
+ headers: { accept: "application/json" },
1885
+ signal: controller.signal
1886
+ });
1887
+ if (!response.ok) continue;
1888
+ const value = await response.json();
1889
+ if (typeof value === "object" && value !== null && "version" in value && typeof value.version === "string" && parseVersion(value.version)) {
1890
+ return value.version;
1891
+ }
1892
+ } catch {
1893
+ } finally {
1894
+ clearTimeout(timer);
1895
+ }
1896
+ }
1897
+ throw new Error("Unable to fetch latest version from npm registry");
1898
+ }
1899
+ async function checkForUpdate(currentVersion, options = {}) {
1900
+ if (!parseVersion(currentVersion)) return void 0;
1901
+ const now = options.now ?? Date.now();
1902
+ const cachePath = join(
1903
+ options.cacheDirectory ?? updateCacheDirectory(),
1904
+ "update.json"
1905
+ );
1906
+ const cache = await readCache(cachePath);
1907
+ let latestVersion = cache.latestVersion;
1908
+ if (options.force || !cache.checkedAt || now - cache.checkedAt >= CHECK_INTERVAL_MS) {
1909
+ try {
1910
+ latestVersion = await fetchLatestVersion(options.fetcher ?? fetch);
1911
+ await writeCache(cachePath, {
1912
+ ...cache,
1913
+ checkedAt: now,
1914
+ latestVersion
1915
+ });
1916
+ } catch {
1917
+ }
1918
+ }
1919
+ if (!latestVersion || latestVersion === cache.ignoredVersion || compareVersions(latestVersion, currentVersion) <= 0) {
1920
+ return void 0;
1921
+ }
1922
+ return {
1923
+ currentVersion,
1924
+ latestVersion,
1925
+ releaseUrl: RELEASE_URL
1926
+ };
1927
+ }
1928
+ async function checkUpdateManually(currentVersion, options = {}) {
1929
+ const now = options.now ?? Date.now();
1930
+ const cachePath = join(
1931
+ options.cacheDirectory ?? updateCacheDirectory(),
1932
+ "update.json"
1933
+ );
1934
+ const cache = await readCache(cachePath);
1935
+ let latestVersion = cache.latestVersion;
1936
+ const isRateLimited = Boolean(
1937
+ !options.force && cache.checkedAt && now - cache.checkedAt < MANUAL_RATE_LIMIT_MS
1938
+ );
1939
+ if (!isRateLimited) {
1940
+ try {
1941
+ latestVersion = await fetchLatestVersion(options.fetcher ?? fetch);
1942
+ await writeCache(cachePath, {
1943
+ ...cache,
1944
+ checkedAt: now,
1945
+ latestVersion
1946
+ });
1947
+ } catch {
1948
+ }
1949
+ }
1950
+ const effectiveLatest = latestVersion ?? currentVersion;
1951
+ const updateAvailable = Boolean(
1952
+ latestVersion && compareVersions(latestVersion, currentVersion) > 0
1953
+ );
1954
+ return {
1955
+ currentVersion,
1956
+ latestVersion: effectiveLatest,
1957
+ rateLimited: isRateLimited,
1958
+ releaseUrl: RELEASE_URL,
1959
+ updateAvailable
1960
+ };
1961
+ }
1962
+ async function ignoreUpdateVersion(version, cacheDir = updateCacheDirectory()) {
1963
+ const cachePath = join(cacheDir, "update.json");
1964
+ const cache = await readCache(cachePath);
1965
+ await writeCache(cachePath, { ...cache, ignoredVersion: version });
1966
+ }
1967
+ function installUpdate(version, spawner = spawn) {
1968
+ if (!parseVersion(version)) {
1969
+ return Promise.reject(new Error(`Invalid update version: ${version}`));
1970
+ }
1971
+ return new Promise((resolve, reject) => {
1972
+ const pkg = `@coreify/quitx@${version}`;
1973
+ const child = spawner("npm", ["install", "--global", pkg], {
1974
+ stdio: "inherit",
1975
+ windowsHide: true
1976
+ });
1977
+ child.once("error", reject);
1978
+ child.once("exit", (code, signal) => {
1979
+ if (code === 0) resolve();
1980
+ else {
1981
+ reject(
1982
+ new Error(
1983
+ signal ? `Update stopped by ${signal}` : `npm install exited with code ${code ?? "unknown"}`
1984
+ )
1985
+ );
1986
+ }
1987
+ });
1988
+ });
1989
+ }
1990
+ function updateNotice(update) {
1991
+ return `Update available: ${update.currentVersion} \u2192 ${update.latestVersion}. Run: npm install --global @coreify/quitx@latest`;
1992
+ }
1189
1993
 
1190
1994
  // src/utils/platform.ts
1191
1995
  function isMacOS(platform = process.platform) {
@@ -1223,10 +2027,42 @@ function parseCliArgs(args) {
1223
2027
  for (let i = 0; i < args.length; i++) {
1224
2028
  const arg = args[i];
1225
2029
  if (!arg) continue;
1226
- if (arg === "config") {
2030
+ if (arg === "--dry-run") {
2031
+ options.dryRun = true;
2032
+ } else if (arg === "config") {
1227
2033
  options.manageConfig = true;
2034
+ const next = args[i + 1];
2035
+ if (next && !next.startsWith("-") && ["show", "get", "set", "reset"].includes(next.toLowerCase())) {
2036
+ options.configAction = next.toLowerCase();
2037
+ i++;
2038
+ if (options.configAction === "get") {
2039
+ const key = args[++i];
2040
+ if (key && !key.startsWith("-")) {
2041
+ options.configKey = key;
2042
+ } else if (key) {
2043
+ i--;
2044
+ }
2045
+ } else if (options.configAction === "set") {
2046
+ const key = args[++i];
2047
+ if (key && !key.startsWith("-")) {
2048
+ options.configKey = key;
2049
+ const val = args[++i];
2050
+ if (val && !val.startsWith("-")) {
2051
+ options.configValue = val;
2052
+ } else if (val) {
2053
+ i--;
2054
+ }
2055
+ } else if (key) {
2056
+ i--;
2057
+ }
2058
+ }
2059
+ }
1228
2060
  } else if (arg === "exclude") {
1229
2061
  options.manageExclude = true;
2062
+ } else if (arg === "check-update" || arg === "--check-update") {
2063
+ options.checkUpdate = true;
2064
+ } else if (arg === "--no-update-check") {
2065
+ options.noUpdateCheck = true;
1230
2066
  } else if (arg === "-a" || arg === "--all") {
1231
2067
  options.all = true;
1232
2068
  } else if (arg === "-y" || arg === "--yes") {
@@ -1243,8 +2079,12 @@ function parseCliArgs(args) {
1243
2079
  options.json = true;
1244
2080
  } else if (arg === "--include-finder") {
1245
2081
  options.includeFinder = true;
2082
+ } else if (arg === "--include-trash") {
2083
+ options.includeTrash = true;
1246
2084
  } else if (arg === "-b" || arg === "--background") {
1247
2085
  options.includeBackground = true;
2086
+ } else if (arg === "--never-quit-music") {
2087
+ options.neverQuitMusic = true;
1248
2088
  } else if (arg === "--config") {
1249
2089
  options.manageConfig = true;
1250
2090
  } else if (arg === "--exclude") {
@@ -1266,7 +2106,13 @@ function parseCliArgs(args) {
1266
2106
  }
1267
2107
  }
1268
2108
  if (rawPositional.length > 0) {
1269
- if (options.manageExclude) {
2109
+ if (options.manageConfig) {
2110
+ if (!options.configAction) {
2111
+ throw new Error(
2112
+ `Unknown config command: "${rawPositional[0]}". Usage: quitx config [show|get|set|reset]`
2113
+ );
2114
+ }
2115
+ } else if (options.manageExclude) {
1270
2116
  if (rawPositional.length > 1 && !rawPositional.some((a) => a.includes(","))) {
1271
2117
  throw new Error(
1272
2118
  "Multiple exclude applications must be comma-separated, not space-separated. Example: quitx exclude Spotify,Discord"
@@ -1298,9 +2144,135 @@ function parseCliArgs(args) {
1298
2144
  }
1299
2145
  return options;
1300
2146
  }
2147
+ async function handleManualUpdateCheck(version, options) {
2148
+ const isInteractive = !options.json && Boolean(process.stdout.isTTY && process.stdin.isTTY);
2149
+ if (options.json) {
2150
+ const result2 = await checkUpdateManually(version, { force: options.force });
2151
+ console.log(JSON.stringify(result2, null, 2));
2152
+ return 0;
2153
+ }
2154
+ if (isInteractive) {
2155
+ showIntro();
2156
+ const checkSpinner = spinner();
2157
+ checkSpinner.start("Checking npm registry for updates");
2158
+ const result2 = await checkUpdateManually(version, { force: options.force });
2159
+ if (result2.rateLimited) {
2160
+ checkSpinner.stop(
2161
+ `Checked recently (rate limited, 60s cooldown). Latest: v${result2.latestVersion}`
2162
+ );
2163
+ } else if (result2.updateAvailable) {
2164
+ checkSpinner.stop(
2165
+ `Update available: ${result2.currentVersion} \u2192 ${result2.latestVersion}`
2166
+ );
2167
+ } else {
2168
+ checkSpinner.stop(`quitx is up to date (v${result2.currentVersion})`);
2169
+ }
2170
+ if (result2.updateAvailable) {
2171
+ console.log(
2172
+ "\n Upgrade Available: Run npm install --global @coreify/quitx@latest\n"
2173
+ );
2174
+ const answer = await select({
2175
+ message: `Install v${result2.latestVersion} now?`,
2176
+ options: [
2177
+ { value: "install", label: "Install update now" },
2178
+ { value: "skip", label: "Skip for now" }
2179
+ ],
2180
+ initialValue: "install"
2181
+ });
2182
+ if (isCancel(answer) || answer === "skip") {
2183
+ printThanks(options);
2184
+ return 0;
2185
+ }
2186
+ if (answer === "install") {
2187
+ console.log(`Updating to ${result2.latestVersion}...`);
2188
+ try {
2189
+ await installUpdate(result2.latestVersion);
2190
+ showOutro(
2191
+ `Updated to ${result2.latestVersion}. Restart quitx to use it.`
2192
+ );
2193
+ } catch (err) {
2194
+ const msg = err instanceof Error ? err.message : String(err);
2195
+ console.error(`Failed to install update: ${msg}`);
2196
+ }
2197
+ }
2198
+ }
2199
+ printThanks(options);
2200
+ return 0;
2201
+ }
2202
+ const result = await checkUpdateManually(version, { force: options.force });
2203
+ if (result.updateAvailable) {
2204
+ console.log(
2205
+ `Update available: ${result.currentVersion} \u2192 ${result.latestVersion}`
2206
+ );
2207
+ console.log("Run: npm install --global @coreify/quitx@latest");
2208
+ } else {
2209
+ console.log(`quitx is up to date (${result.currentVersion})`);
2210
+ }
2211
+ if (result.rateLimited) {
2212
+ console.log(
2213
+ "(Checked recently with 60s rate limit; use --force to bypass)"
2214
+ );
2215
+ }
2216
+ printThanks(options);
2217
+ return 0;
2218
+ }
2219
+ async function handleAutoUpdateCheck(currentVersion, options) {
2220
+ if (options.noUpdateCheck || options.checkUpdate || options.help || options.version || options.dryRun) {
2221
+ return;
2222
+ }
2223
+ const config = loadConfig();
2224
+ if (config.autoUpdate === false) {
2225
+ return;
2226
+ }
2227
+ try {
2228
+ const update = await checkForUpdate(currentVersion);
2229
+ if (!update) return;
2230
+ if (options.json || options.yes || !process.stdin.isTTY || !process.stdout.isTTY) {
2231
+ console.error(updateNotice(update));
2232
+ return;
2233
+ }
2234
+ console.log(
2235
+ `
2236
+ Update available \xB7 ${update.currentVersion} \u2192 ${update.latestVersion}
2237
+ Release notes: ${update.releaseUrl}`
2238
+ );
2239
+ const action = await select({
2240
+ message: "Update quitx?",
2241
+ options: [
2242
+ { value: "update", label: "Update now", hint: "npm install --global" },
2243
+ { value: "skip", label: "Skip" },
2244
+ {
2245
+ value: "ignore",
2246
+ label: "Skip this version",
2247
+ hint: `hide ${update.latestVersion}`
2248
+ }
2249
+ ],
2250
+ initialValue: "update"
2251
+ });
2252
+ if (isCancel(action) || action === "skip") {
2253
+ return;
2254
+ }
2255
+ if (action === "ignore") {
2256
+ await ignoreUpdateVersion(update.latestVersion);
2257
+ return;
2258
+ }
2259
+ console.log(`Updating to ${update.latestVersion}...`);
2260
+ try {
2261
+ await installUpdate(update.latestVersion);
2262
+ showOutro(`Updated to ${update.latestVersion}. Restart quitx to use it.`);
2263
+ process.exit(0);
2264
+ } catch (err) {
2265
+ const msg = err instanceof Error ? err.message : String(err);
2266
+ console.error(`Failed to install update: ${msg}`);
2267
+ }
2268
+ } catch {
2269
+ }
2270
+ }
1301
2271
  async function main(argv = process.argv.slice(2)) {
1302
2272
  if (!isMacOS()) {
1303
- console.error("\u2716 quitx only works on macOS.");
2273
+ console.error(
2274
+ "\u2716 quitx only works on macOS. Windows and Linux are not supported."
2275
+ );
1304
2276
  return 1;
1305
2277
  }
1306
2278
  ensureMacOS();
@@ -1323,15 +2295,21 @@ async function main(argv = process.argv.slice(2)) {
1323
2295
  printThanks(options);
1324
2296
  return 0;
1325
2297
  }
2298
+ const version = await getPackageVersion();
1326
2299
  if (options.version) {
1327
- const version = await getPackageVersion();
1328
2300
  renderVersion(version);
1329
2301
  printThanks(options);
1330
2302
  return 0;
1331
2303
  }
2304
+ if (options.checkUpdate) {
2305
+ return await handleManualUpdateCheck(version, options);
2306
+ }
2307
+ if (!options.noUpdateCheck && !options.json) {
2308
+ await handleAutoUpdateCheck(version, options);
2309
+ }
1332
2310
  try {
1333
2311
  if (options.manageConfig) {
1334
- return await configCommand();
2312
+ return await handleConfigCli(options);
1335
2313
  }
1336
2314
  if (options.manageExclude || options.exclude && options.exclude.length > 0) {
1337
2315
  return await excludeCommand(options);
@@ -1352,7 +2330,9 @@ async function main(argv = process.argv.slice(2)) {
1352
2330
  return 1;
1353
2331
  }
1354
2332
  }
1355
- if (process.argv[1] && (process.argv[1].endsWith("/cli.ts") || process.argv[1].endsWith("/cli.js") || process.argv[1].endsWith("/quitx"))) {
2333
+ var scriptName = process.argv[1] ? process.argv[1].replace(/\\/g, "/").split("/").pop()?.toLowerCase() : "";
2334
+ var isMain = scriptName === "cli.ts" || scriptName === "cli.js" || scriptName === "quitx" || scriptName === "quitx.cmd" || scriptName === "quitx.ps1" || scriptName === "quitx.exe";
2335
+ if (isMain) {
1356
2336
  main().then((code) => {
1357
2337
  if (code !== 0) {
1358
2338
  process.exit(code);
@@ -1363,4 +2343,4 @@ if (process.argv[1] && (process.argv[1].endsWith("/cli.ts") || process.argv[1].e
1363
2343
  });
1364
2344
  }
1365
2345
 
1366
- export { getPackageVersion, main, parseCliArgs };
2346
+ export { getPackageVersion, handleAutoUpdateCheck, handleManualUpdateCheck, main, parseCliArgs };