@coreify/quitx 1.1.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 +25 -0
  2. package/dist/cli.js +583 -24
  3. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## 1.2.0
9
+
10
+ ### Added
11
+
12
+ - `--dry-run` simulation mode:
13
+ - Simulate quitting applications without sending termination signals or killing processes.
14
+ - Formatted preview output (`[dry-run] Would quit <app>` / `[dry-run] Would force quit <app>`).
15
+ - Supported across interactive selector, targeted app list, and `--all` flag.
16
+ - Non-interactive configuration CLI:
17
+ - `quitx config show [--json]` to view current configuration as formatted list or JSON object.
18
+ - `quitx config get <key> [--json]` to retrieve individual configuration value.
19
+ - `quitx config set <key> <value> [--json]` with typed validation for booleans, force mode, and arrays.
20
+ - `quitx config reset [-y|--yes] [--json]` to restore default configuration non-interactively.
21
+ - Automatic update toggle:
22
+ - Added `autoUpdate` configuration property (default `true`).
23
+ - Configurable via interactive menu and `quitx config set autoUpdate <bool>`.
24
+ - Automatic background 6-hour update checks honor this setting.
25
+ - Package update checking system:
26
+ - Automatic 6-hour interval background check against npm registry (`CHECK_INTERVAL_MS`).
27
+ - Interactive manual update check command `quitx check-update` and `--check-update` flag with 60-second rate-limiting cooldown.
28
+ - `--no-update-check` flag to bypass update checks.
29
+ - Direct global package upgrade via `installUpdate` (`npm install --global @coreify/quitx@latest`).
30
+ - Persistent update cache in `~/.quitx/update.json` storing check timestamp and ignored versions.
31
+ - Full JSON output support for update queries (`quitx check-update --json`).
32
+
8
33
  ## 1.1.0
9
34
 
10
35
  ### Added
package/dist/cli.js CHANGED
@@ -1,9 +1,9 @@
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
9
 
@@ -18,7 +18,8 @@ var DEFAULT_CONFIG = {
18
18
  groupBackground: true,
19
19
  defaultSelectAll: true,
20
20
  neverQuitMusic: false,
21
- musicApps: []
21
+ musicApps: [],
22
+ autoUpdate: true
22
23
  };
23
24
  function loadConfig(filePath = CONFIG_FILE) {
24
25
  try {
@@ -34,6 +35,7 @@ function loadConfig(filePath = CONFIG_FILE) {
34
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;
35
36
  const groupBgVal = typeof obj.groupBackground === "boolean" ? obj.groupBackground : typeof obj.groupBackgroundInstances === "boolean" ? obj.groupBackgroundInstances : DEFAULT_CONFIG.groupBackground;
36
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;
37
39
  return {
38
40
  exclude: Array.isArray(obj.exclude) ? obj.exclude : [],
39
41
  force: obj.force === "force" || obj.force === "normal" ? obj.force : DEFAULT_CONFIG.force,
@@ -43,7 +45,8 @@ function loadConfig(filePath = CONFIG_FILE) {
43
45
  groupBackground: groupBgVal,
44
46
  defaultSelectAll: defaultSelectVal,
45
47
  neverQuitMusic: neverQuitMusicVal,
46
- musicApps: Array.isArray(obj.musicApps) ? obj.musicApps : DEFAULT_CONFIG.musicApps
48
+ musicApps: Array.isArray(obj.musicApps) ? obj.musicApps : DEFAULT_CONFIG.musicApps,
49
+ autoUpdate: autoUpdateVal
47
50
  };
48
51
  } catch {
49
52
  return { ...DEFAULT_CONFIG, exclude: [], musicApps: [] };
@@ -652,6 +655,13 @@ async function quitApps(apps, options = {}, executor, deferredScheduler = defaul
652
655
  if (apps.length === 0) {
653
656
  return [];
654
657
  }
658
+ if (options.dryRun) {
659
+ return apps.map((app) => ({
660
+ app,
661
+ success: true,
662
+ forced: options.force ?? false
663
+ }));
664
+ }
655
665
  const currentTerminalApps = [];
656
666
  const otherApps = [];
657
667
  for (const app of apps) {
@@ -731,14 +741,15 @@ function showOutro(message) {
731
741
  function showCancel(message = "Cancelled.") {
732
742
  cancel(message);
733
743
  }
734
- function renderResults(results) {
744
+ function renderResults(results, dryRun = false) {
735
745
  for (const res of results) {
736
746
  if (res.success) {
737
- 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;
738
748
  log.success(label);
739
749
  } else {
750
+ const prefix = dryRun ? "[dry-run] " : "";
740
751
  const reason = res.error ? ` (${res.error})` : "";
741
- log.error(`Could not quit ${res.app.name}${reason}`);
752
+ log.error(`${prefix}Could not quit ${res.app.name}${reason}`);
742
753
  }
743
754
  }
744
755
  }
@@ -764,28 +775,37 @@ OPTIONS:
764
775
  -y, --yes Skip confirmation prompts
765
776
  -l, --list List running GUI apps and exit
766
777
  -f, --force Force quit apps (SIGKILL) if normal quit fails
778
+ --dry-run Simulate quitting without terminating apps
767
779
  -b, --background Include background processes in app list
768
780
  --include-finder Include Finder in the app list
769
781
  --include-trash Include Trash in the app list (empties on quit)
770
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
771
785
  --config Open interactive config manager
772
786
  --json Output data in JSON format
773
787
  -h, --help Show help information
774
788
  -v, --version Show version number
775
789
 
776
790
  COMMANDS:
777
- config Manage configuration interactively
791
+ config [action] Manage configuration (show, get, set, reset)
778
792
  exclude Manage excluded applications interactively
793
+ check-update Check for package updates
779
794
 
780
795
  EXAMPLES:
781
796
  $ quitx Interactive app selector
782
797
  $ quitx --all Quit all running apps (with confirmation)
798
+ $ quitx --all --dry-run Preview quitting all apps without killing them
783
799
  $ quitx --all --yes Quit all running apps immediately
784
800
  $ quitx --list List currently running GUI apps
785
801
  $ quitx --list --background List all apps including background processes
786
802
  $ quitx --list --include-finder List apps including Finder
787
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
788
807
  $ quitx exclude Open interactive menu to manage excluded apps
808
+ $ quitx check-update Check for package updates
789
809
  $ quitx --exclude Spotify,Slack Add Spotify and Slack to exclude list
790
810
  $ quitx Slack,Discord Quit specific apps directly
791
811
  `.trim()
@@ -909,14 +929,20 @@ async function allCommand(options = {}) {
909
929
  }
910
930
  const s = spinner();
911
931
  if (!options.json) {
912
- s.start(`Quitting ${apps.length} apps...`);
932
+ s.start(
933
+ options.dryRun ? `Simulating quit for ${apps.length} apps...` : `Quitting ${apps.length} apps...`
934
+ );
913
935
  }
914
- const results = await quitApps(apps, { force: useForce });
936
+ const results = await quitApps(apps, {
937
+ force: useForce,
938
+ dryRun: options.dryRun
939
+ });
915
940
  if (!options.json) {
916
- s.stop("Quitting complete");
917
- renderResults(results);
941
+ s.stop(options.dryRun ? "Dry run complete" : "Quitting complete");
942
+ renderResults(results, options.dryRun);
918
943
  const successCount = results.filter((r) => r.success).length;
919
- showOutro(`Quit ${successCount} of ${apps.length} apps.`);
944
+ const actionVerb = options.dryRun ? "Would quit" : "Quit";
945
+ showOutro(`${actionVerb} ${successCount} of ${apps.length} apps.`);
920
946
  printThanks(options);
921
947
  } else {
922
948
  console.log(
@@ -1119,6 +1145,32 @@ async function toggleNeverQuitMusic() {
1119
1145
  `Never quit music apps: ${config.neverQuitMusic ? "enabled" : "disabled"}`
1120
1146
  );
1121
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
+ }
1122
1174
  async function manageExcludedApps() {
1123
1175
  const choice = await select({
1124
1176
  message: "Manage excluded apps",
@@ -1305,6 +1357,11 @@ async function configCommand() {
1305
1357
  label: "Never quit music apps",
1306
1358
  hint: `current: ${config.neverQuitMusic ? "enabled" : "disabled"}`
1307
1359
  },
1360
+ {
1361
+ value: "auto-update",
1362
+ label: "Automatic update checks",
1363
+ hint: `current: ${config.autoUpdate ? "enabled" : "disabled"}`
1364
+ },
1308
1365
  {
1309
1366
  value: "default-select",
1310
1367
  label: "Deselect apps by default",
@@ -1346,6 +1403,7 @@ async function configCommand() {
1346
1403
  if (choice === "background") await toggleIncludeBackground();
1347
1404
  if (choice === "group-background") await toggleGroupBackground();
1348
1405
  if (choice === "never-quit-music") await toggleNeverQuitMusic();
1406
+ if (choice === "auto-update") await toggleAutoUpdate();
1349
1407
  if (choice === "default-select") await toggleDefaultSelection();
1350
1408
  if (choice === "finder") await toggleIncludeFinder();
1351
1409
  if (choice === "trash") await toggleIncludeTrash();
@@ -1357,6 +1415,140 @@ async function configCommand() {
1357
1415
  printThanks();
1358
1416
  return 0;
1359
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
+ }
1360
1552
  async function manageExcludeInteractive() {
1361
1553
  showIntro();
1362
1554
  const choice = await select({
@@ -1554,15 +1746,21 @@ async function interactiveCommand(options = {}) {
1554
1746
  }
1555
1747
  if (!options.json) {
1556
1748
  s.start(
1557
- `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"}...`
1558
1750
  );
1559
1751
  }
1560
- const results = await quitApps(targetApps, { force: useForce });
1752
+ const results = await quitApps(targetApps, {
1753
+ force: useForce,
1754
+ dryRun: options.dryRun
1755
+ });
1561
1756
  if (!options.json) {
1562
- s.stop("Quitting complete");
1563
- renderResults(results);
1757
+ s.stop(options.dryRun ? "Dry run complete" : "Quitting complete");
1758
+ renderResults(results, options.dryRun);
1564
1759
  const successCount = results.filter((r) => r.success).length;
1565
- 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
+ );
1566
1764
  printThanks(options);
1567
1765
  } else {
1568
1766
  console.log(
@@ -1599,6 +1797,199 @@ async function listCommand(options = {}) {
1599
1797
  renderList(apps, options.json);
1600
1798
  return 0;
1601
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
+ }
1602
1993
 
1603
1994
  // src/utils/platform.ts
1604
1995
  function isMacOS(platform = process.platform) {
@@ -1636,10 +2027,42 @@ function parseCliArgs(args) {
1636
2027
  for (let i = 0; i < args.length; i++) {
1637
2028
  const arg = args[i];
1638
2029
  if (!arg) continue;
1639
- if (arg === "config") {
2030
+ if (arg === "--dry-run") {
2031
+ options.dryRun = true;
2032
+ } else if (arg === "config") {
1640
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
+ }
1641
2060
  } else if (arg === "exclude") {
1642
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;
1643
2066
  } else if (arg === "-a" || arg === "--all") {
1644
2067
  options.all = true;
1645
2068
  } else if (arg === "-y" || arg === "--yes") {
@@ -1683,7 +2106,13 @@ function parseCliArgs(args) {
1683
2106
  }
1684
2107
  }
1685
2108
  if (rawPositional.length > 0) {
1686
- 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) {
1687
2116
  if (rawPositional.length > 1 && !rawPositional.some((a) => a.includes(","))) {
1688
2117
  throw new Error(
1689
2118
  "Multiple exclude applications must be comma-separated, not space-separated. Example: quitx exclude Spotify,Discord"
@@ -1715,6 +2144,130 @@ function parseCliArgs(args) {
1715
2144
  }
1716
2145
  return options;
1717
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
+ }
1718
2271
  async function main(argv = process.argv.slice(2)) {
1719
2272
  if (!isMacOS()) {
1720
2273
  console.error(
@@ -1742,15 +2295,21 @@ async function main(argv = process.argv.slice(2)) {
1742
2295
  printThanks(options);
1743
2296
  return 0;
1744
2297
  }
2298
+ const version = await getPackageVersion();
1745
2299
  if (options.version) {
1746
- const version = await getPackageVersion();
1747
2300
  renderVersion(version);
1748
2301
  printThanks(options);
1749
2302
  return 0;
1750
2303
  }
2304
+ if (options.checkUpdate) {
2305
+ return await handleManualUpdateCheck(version, options);
2306
+ }
2307
+ if (!options.noUpdateCheck && !options.json) {
2308
+ await handleAutoUpdateCheck(version, options);
2309
+ }
1751
2310
  try {
1752
2311
  if (options.manageConfig) {
1753
- return await configCommand();
2312
+ return await handleConfigCli(options);
1754
2313
  }
1755
2314
  if (options.manageExclude || options.exclude && options.exclude.length > 0) {
1756
2315
  return await excludeCommand(options);
@@ -1784,4 +2343,4 @@ if (isMain) {
1784
2343
  });
1785
2344
  }
1786
2345
 
1787
- export { getPackageVersion, main, parseCliArgs };
2346
+ export { getPackageVersion, handleAutoUpdateCheck, handleManualUpdateCheck, main, parseCliArgs };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coreify/quitx",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "A macOS CLI for viewing running GUI applications and quitting them properly.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,7 +35,7 @@
35
35
  "scripts": {
36
36
  "build": "tsup",
37
37
  "build:watch": "tsup --watch",
38
- "check": "npm run format:check && npm run lint && npm run typecheck && npm run test:coverage && npm run build",
38
+ "check": "npm run format && npm run format:check && npm run lint && npm run typecheck && npm run test:coverage && npm run build",
39
39
  "format": "prettier --write .",
40
40
  "format:check": "prettier --check .",
41
41
  "lint": "eslint .",