git-watchtower 2.1.17 → 2.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.
@@ -76,6 +76,7 @@ const { openInBrowser: openUrl } = require('../src/utils/browser');
76
76
  const { playSound: playSoundEffect } = require('../src/utils/sound');
77
77
  const { parseArgs: parseCliArgs, applyCliArgsToConfig: mergeCliArgs, getHelpText, PACKAGE_VERSION } = require('../src/cli/args');
78
78
  const { checkForUpdate, startPeriodicUpdateCheck } = require('../src/utils/version-check');
79
+ const { detectInstallSource, getUpdateCommand } = require('../src/utils/install-source');
79
80
  const { parseRemoteUrl, buildBranchUrl, detectPlatform, buildWebUrl, extractSessionUrl } = require('../src/git/remote');
80
81
  const { parseGitHubPr, parseGitLabMr, parseGitHubPrList, parseGitLabMrList, isBaseBranch } = require('../src/git/pr');
81
82
 
@@ -846,7 +847,7 @@ async function restartServerProcess() {
846
847
  // new process tried to bind a port the old one still held.
847
848
  try {
848
849
  await stopServerProcess();
849
- } catch (_) { /* stopServerProcess never rejects in practice; best-effort */ }
850
+ } catch (_) {}
850
851
  if (isShuttingDown) return;
851
852
  startServerProcess();
852
853
  render();
@@ -2828,12 +2829,24 @@ function setupKeyboardInput() {
2828
2829
  }
2829
2830
  if (key === '\r' || key === '\n') {
2830
2831
  const selectedIdx = store.get('updateModalSelectedIndex') || 0;
2832
+ const installSource = detectInstallSource();
2833
+ const updateCmd = getUpdateCommand(installSource);
2831
2834
  if (selectedIdx === 0) {
2832
- // Update & restart — run npm i -g git-watchtower, then re-exec
2835
+ // Update & restart — for npm/homebrew we shell out to the matching
2836
+ // package manager, then re-exec. For source/unknown installs there
2837
+ // is no automated upgrade, so degrade to surfacing the command.
2838
+ if (installSource !== 'npm' && installSource !== 'homebrew') {
2839
+ store.setState({ updateModalVisible: false, updateModalSelectedIndex: 0 });
2840
+ showFlash(`Run: ${updateCmd}`);
2841
+ return;
2842
+ }
2843
+ const [cmdBin, ...cmdArgs] = installSource === 'homebrew'
2844
+ ? ['brew', 'upgrade', 'git-watchtower']
2845
+ : ['npm', 'i', '-g', 'git-watchtower'];
2833
2846
  store.setState({ updateInProgress: true });
2834
2847
  render();
2835
2848
  const { spawn } = require('child_process');
2836
- const child = spawn('npm', ['i', '-g', 'git-watchtower'], {
2849
+ const child = spawn(cmdBin, cmdArgs, {
2837
2850
  stdio: 'ignore',
2838
2851
  detached: false,
2839
2852
  shell: process.platform === 'win32',
@@ -2845,21 +2858,21 @@ function setupKeyboardInput() {
2845
2858
  addLog('Successfully updated git-watchtower! Restarting...', 'update');
2846
2859
  restartProcess();
2847
2860
  } else {
2848
- addLog(`Update failed (exit code ${code}). Run manually: npm i -g git-watchtower`, 'error');
2849
- showFlash('Update failed. Try manually: npm i -g git-watchtower');
2861
+ addLog(`Update failed (exit code ${code}). Run manually: ${updateCmd}`, 'error');
2862
+ showFlash(`Update failed. Try manually: ${updateCmd}`);
2850
2863
  }
2851
2864
  render();
2852
2865
  });
2853
2866
  child.on('error', (err) => {
2854
2867
  store.setState({ updateInProgress: false, updateModalVisible: false, updateModalSelectedIndex: 0 });
2855
- addLog(`Update failed: ${err.message}. Run manually: npm i -g git-watchtower`, 'error');
2856
- showFlash('Update failed. Try manually: npm i -g git-watchtower');
2868
+ addLog(`Update failed: ${err.message}. Run manually: ${updateCmd}`, 'error');
2869
+ showFlash(`Update failed. Try manually: ${updateCmd}`);
2857
2870
  render();
2858
2871
  });
2859
2872
  } else {
2860
2873
  // Show update command — dismiss modal with flash showing the command
2861
2874
  store.setState({ updateModalVisible: false, updateModalSelectedIndex: 0 });
2862
- showFlash('Run: npm i -g git-watchtower');
2875
+ showFlash(`Run: ${updateCmd}`);
2863
2876
  }
2864
2877
  return;
2865
2878
  }
@@ -3289,10 +3302,20 @@ async function handleWebAction(action, payload) {
3289
3302
  break;
3290
3303
  case 'checkUpdate':
3291
3304
  if (payload && payload.install) {
3305
+ const installSrc = detectInstallSource();
3306
+ const updateCmdStr = getUpdateCommand(installSrc);
3307
+ if (installSrc !== 'npm' && installSrc !== 'homebrew') {
3308
+ sendResult(false, `Auto-update unavailable. Run: ${updateCmdStr}`);
3309
+ addLog(`Auto-update unavailable for ${installSrc} install. Run manually: ${updateCmdStr}`, 'update');
3310
+ break;
3311
+ }
3292
3312
  store.setState({ updateInProgress: true });
3293
3313
  render();
3294
3314
  const { spawn: spawnUpdate } = require('child_process');
3295
- const updateChild = spawnUpdate('npm', ['i', '-g', 'git-watchtower'], {
3315
+ const [updBin, ...updArgs] = installSrc === 'homebrew'
3316
+ ? ['brew', 'upgrade', 'git-watchtower']
3317
+ : ['npm', 'i', '-g', 'git-watchtower'];
3318
+ const updateChild = spawnUpdate(updBin, updArgs, {
3296
3319
  stdio: 'ignore',
3297
3320
  detached: false,
3298
3321
  });
@@ -3305,7 +3328,7 @@ async function handleWebAction(action, payload) {
3305
3328
  restartProcess();
3306
3329
  } else {
3307
3330
  sendResult(false, `Update failed (exit code ${code})`);
3308
- addLog(`Update failed (exit code ${code}). Run manually: npm i -g git-watchtower`, 'error');
3331
+ addLog(`Update failed (exit code ${code}). Run manually: ${updateCmdStr}`, 'error');
3309
3332
  render();
3310
3333
  }
3311
3334
  });
@@ -3517,10 +3540,10 @@ async function startWebDashboard(openBrowser) {
3517
3540
  webStateInterval = null;
3518
3541
  }
3519
3542
  if (webDashboard) {
3520
- try { webDashboard.stop(); } catch (_) { /* web server may not have bound yet — nothing to stop */ }
3543
+ try { webDashboard.stop(); } catch (_) {}
3521
3544
  }
3522
3545
  if (coordinator) {
3523
- try { coordinator.stop(); } catch (_) { /* coordinator may not have started its IPC server */ }
3546
+ try { coordinator.stop(); } catch (_) {}
3524
3547
  }
3525
3548
  removeLock();
3526
3549
  removeSocket();
@@ -3578,25 +3601,25 @@ function restartProcess() {
3578
3601
  // stdio is inherited — so any stray listener here will race with the child
3579
3602
  // (keystrokes consumed twice, render() drawing frames on top of the child's
3580
3603
  // UI, Ctrl+C intercepted by both, etc.).
3581
- try { process.stdin.removeAllListeners('data'); } catch (_) { /* stdin may be detached */ }
3582
- try { process.stdin.pause(); } catch (_) { /* stdin may already be paused */ }
3583
- try { process.stdout.removeAllListeners('resize'); } catch (_) { /* stdout may be detached */ }
3584
- try { process.removeAllListeners('SIGWINCH'); } catch (_) { /* no SIGWINCH handler registered */ }
3585
- try { process.removeAllListeners('SIGINT'); } catch (_) { /* no SIGINT handler registered */ }
3586
- try { process.removeAllListeners('SIGTERM'); } catch (_) { /* no SIGTERM handler registered */ }
3604
+ try { process.stdin.removeAllListeners('data'); } catch (_) {}
3605
+ try { process.stdin.pause(); } catch (_) {}
3606
+ try { process.stdout.removeAllListeners('resize'); } catch (_) {}
3607
+ try { process.removeAllListeners('SIGWINCH'); } catch (_) {}
3608
+ try { process.removeAllListeners('SIGINT'); } catch (_) {}
3609
+ try { process.removeAllListeners('SIGTERM'); } catch (_) {}
3587
3610
 
3588
3611
  // Stop every scheduler that can trigger a render while we're waiting on the
3589
3612
  // child. periodicUpdateCheck in particular will fire render() on completion
3590
3613
  // and would draw over the replacement's frames.
3591
3614
  if (pollIntervalId) {
3592
- try { clearTimeout(pollIntervalId); } catch (_) { /* defensive */ }
3615
+ try { clearTimeout(pollIntervalId); } catch (_) {}
3593
3616
  pollIntervalId = null;
3594
3617
  }
3595
3618
  if (periodicUpdateCheck) {
3596
- try { periodicUpdateCheck.stop(); } catch (_) { /* interval may already be cleared */ }
3619
+ try { periodicUpdateCheck.stop(); } catch (_) {}
3597
3620
  }
3598
3621
  if (fileWatcher) {
3599
- try { fileWatcher.close(); } catch (_) { /* watcher may already be closed */ }
3622
+ try { fileWatcher.close(); } catch (_) {}
3600
3623
  fileWatcher = null;
3601
3624
  }
3602
3625
 
@@ -3612,7 +3635,7 @@ function restartProcess() {
3612
3635
  // child can acquire it. The parent stays alive waiting on child.on('close'),
3613
3636
  // so without this the child sees the parent as an active owner and refuses.
3614
3637
  if (monitorLockFile) {
3615
- try { monitorLock.release(monitorLockFile); } catch (_) { /* lock file may have already been unlinked */ }
3638
+ try { monitorLock.release(monitorLockFile); } catch (_) {}
3616
3639
  monitorLockFile = null;
3617
3640
  }
3618
3641
 
@@ -3669,23 +3692,23 @@ function cleanupResources() {
3669
3692
 
3670
3693
  // Restore terminal first so the user sees a clean prompt even if a
3671
3694
  // later step throws.
3672
- try { write(ansi.showCursor); } catch (_) { /* stdout may be closed during crash cleanup */ }
3673
- try { write(ansi.restoreScreen); } catch (_) { /* stdout may be closed during crash cleanup */ }
3674
- try { restoreTerminalTitle(); } catch (_) { /* stdout may be closed during crash cleanup */ }
3675
- try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch (_) { /* stdin may already be unraw or detached */ }
3676
- try { process.stdin.pause(); } catch (_) { /* stdin may already be paused or destroyed */ }
3695
+ try { write(ansi.showCursor); } catch (_) {}
3696
+ try { write(ansi.restoreScreen); } catch (_) {}
3697
+ try { restoreTerminalTitle(); } catch (_) {}
3698
+ try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch (_) {}
3699
+ try { process.stdin.pause(); } catch (_) {}
3677
3700
 
3678
3701
  if (pollIntervalId) {
3679
- try { clearTimeout(pollIntervalId); } catch (_) { /* defensive — clearTimeout normally won't throw */ }
3702
+ try { clearTimeout(pollIntervalId); } catch (_) {}
3680
3703
  pollIntervalId = null;
3681
3704
  }
3682
3705
 
3683
3706
  if (periodicUpdateCheck) {
3684
- try { periodicUpdateCheck.stop(); } catch (_) { /* interval handle may already be cleared */ }
3707
+ try { periodicUpdateCheck.stop(); } catch (_) {}
3685
3708
  }
3686
3709
 
3687
3710
  if (fileWatcher) {
3688
- try { fileWatcher.close(); } catch (_) { /* watcher may already be closed by OS or previous cleanup */ }
3711
+ try { fileWatcher.close(); } catch (_) {}
3689
3712
  fileWatcher = null;
3690
3713
  }
3691
3714
 
@@ -3693,10 +3716,10 @@ function cleanupResources() {
3693
3716
  if (SERVER_MODE === 'static') {
3694
3717
  try {
3695
3718
  clients.forEach((client) => {
3696
- try { client.end(); } catch (_) { /* SSE client socket already closed */ }
3719
+ try { client.end(); } catch (_) {}
3697
3720
  });
3698
3721
  clients.clear();
3699
- } catch (_) { /* clients set may have mutated mid-iteration during shutdown */ }
3722
+ } catch (_) {}
3700
3723
  }
3701
3724
 
3702
3725
  // User's dev-server process (command mode). Capture the close promise so
@@ -3706,16 +3729,16 @@ function cleanupResources() {
3706
3729
  // orphan (it was spawned in its own process group on Unix).
3707
3730
  let serverStopPromise = Promise.resolve();
3708
3731
  if (SERVER_MODE === 'command') {
3709
- try { serverStopPromise = stopServerProcess(); } catch (_) { /* dev-server child may already be gone */ }
3732
+ try { serverStopPromise = stopServerProcess(); } catch (_) {}
3710
3733
  }
3711
3734
 
3712
3735
  // Web dashboard + worker/coordinator (unlinks lock file + IPC socket)
3713
- try { stopWebDashboard(); } catch (_) { /* web dashboard may never have been started */ }
3736
+ try { stopWebDashboard(); } catch (_) {}
3714
3737
 
3715
3738
  // Per-repo monitor lock — release last so the slot stays reserved for the
3716
3739
  // entire lifetime of this process, including any errors in the steps above.
3717
3740
  if (monitorLockFile) {
3718
- try { monitorLock.release(monitorLockFile); } catch (_) { /* lock file may have been unlinked externally */ }
3741
+ try { monitorLock.release(monitorLockFile); } catch (_) {}
3719
3742
  monitorLockFile = null;
3720
3743
  }
3721
3744
 
@@ -3742,7 +3765,7 @@ async function shutdown() {
3742
3765
  // FORCE_KILL_GRACE_MS SIGKILL escalation inside stopServerProcess).
3743
3766
  // Without this the escalation timer gets dropped by process.exit() below
3744
3767
  // and a dev server that ignored SIGTERM survives as a detached orphan.
3745
- try { await serverStopPromise; } catch (_) { /* best-effort */ }
3768
+ try { await serverStopPromise; } catch (_) {}
3746
3769
 
3747
3770
  // Flush telemetry
3748
3771
  telemetry.capture('session_ended', {
@@ -3774,7 +3797,7 @@ process.on('exit', () => {
3774
3797
  const stdioPipeErrorHandler = createPipeErrorHandler({
3775
3798
  onEpipe: () => {
3776
3799
  isShuttingDown = true;
3777
- try { cleanupResources(); } catch (_) { /* best-effort during pipe-close */ }
3800
+ try { cleanupResources(); } catch (_) {}
3778
3801
  process.exit(0);
3779
3802
  },
3780
3803
  onOther: (err) => {
@@ -3793,12 +3816,12 @@ process.on('uncaughtException', async (err) => {
3793
3816
  // if telemetry shutdown hangs or throws.
3794
3817
  const serverStopPromise = cleanupResources();
3795
3818
 
3796
- try { telemetry.captureError(err); } catch (_) { /* telemetry must never prevent crash cleanup */ }
3819
+ try { telemetry.captureError(err); } catch (_) {}
3797
3820
  console.error('Uncaught exception:', err);
3798
- try { await telemetry.shutdown(); } catch (_) { /* telemetry must never prevent crash cleanup */ }
3821
+ try { await telemetry.shutdown(); } catch (_) {}
3799
3822
  // Wait for the dev-server SIGKILL escalation before exiting — otherwise
3800
3823
  // process.exit() drops the pending timer and a stuck server orphans.
3801
- try { await serverStopPromise; } catch (_) { /* best-effort */ }
3824
+ try { await serverStopPromise; } catch (_) {}
3802
3825
  process.exit(1);
3803
3826
  });
3804
3827
 
@@ -3813,12 +3836,12 @@ process.on('unhandledRejection', async (reason) => {
3813
3836
  const serverStopPromise = cleanupResources();
3814
3837
 
3815
3838
  const err = reason instanceof Error ? reason : new Error(String(reason));
3816
- try { telemetry.captureError(err); } catch (_) { /* telemetry must never prevent crash cleanup */ }
3839
+ try { telemetry.captureError(err); } catch (_) {}
3817
3840
  console.error('Unhandled rejection:', reason);
3818
- try { await telemetry.shutdown(); } catch (_) { /* telemetry must never prevent crash cleanup */ }
3841
+ try { await telemetry.shutdown(); } catch (_) {}
3819
3842
  // Wait for the dev-server SIGKILL escalation before exiting — otherwise
3820
3843
  // process.exit() drops the pending timer and a stuck server orphans.
3821
- try { await serverStopPromise; } catch (_) { /* best-effort */ }
3844
+ try { await serverStopPromise; } catch (_) {}
3822
3845
  process.exit(1);
3823
3846
  });
3824
3847
 
@@ -3892,7 +3915,10 @@ async function start() {
3892
3915
  const config = await ensureConfig(cliArgs);
3893
3916
  applyConfig(config);
3894
3917
 
3895
- // Telemetry: set version early so consent events include $lib_version
3918
+ // Telemetry: set version early so consent events include $lib_version.
3919
+ // Warm the install-source detector so the result is cached before any
3920
+ // events fire — every subsequent queueEvent() call reuses it.
3921
+ const installSource = detectInstallSource();
3896
3922
  telemetry.setVersion(PACKAGE_VERSION);
3897
3923
  await telemetry.promptIfNeeded(promptYesNo);
3898
3924
  telemetry.init({ version: PACKAGE_VERSION });
@@ -3904,6 +3930,7 @@ async function start() {
3904
3930
  server_mode: SERVER_MODE,
3905
3931
  has_config: !!loadConfig(),
3906
3932
  casino_mode: config.casinoMode || false,
3933
+ install_source: installSource,
3907
3934
  });
3908
3935
 
3909
3936
  // Set up casino mode render callback for animations
@@ -4040,8 +4067,14 @@ async function start() {
4040
4067
  // Check for newer version on npm (non-blocking, silent on failure)
4041
4068
  checkForUpdate().then((latestVersion) => {
4042
4069
  if (latestVersion) {
4043
- store.setState({ updateAvailable: latestVersion, updateModalVisible: true });
4044
- addLog(`New version available: ${latestVersion} \u2192 npm i -g git-watchtower`, 'update');
4070
+ const lastSeen = telemetry.getLastSeenUpdateVersion();
4071
+ const shouldAutoPop = lastSeen !== latestVersion;
4072
+ const upgradeCmd = getUpdateCommand(detectInstallSource());
4073
+ store.setState({ updateAvailable: latestVersion, updateModalVisible: shouldAutoPop });
4074
+ if (shouldAutoPop) {
4075
+ try { telemetry.setLastSeenUpdateVersion(latestVersion); } catch (_) { /* persistence is best-effort */ }
4076
+ }
4077
+ addLog(`New version available: ${latestVersion} \u2192 ${upgradeCmd}`, 'update');
4045
4078
  render();
4046
4079
  }
4047
4080
  }).catch(() => { /* npm registry unreachable — periodic check will try again in 4h */ });
@@ -4053,8 +4086,10 @@ async function start() {
4053
4086
  store.setState({ updateAvailable: latestVersion });
4054
4087
  if (!alreadyKnown) {
4055
4088
  // First time discovering an update during this session — show modal
4089
+ const upgradeCmd = getUpdateCommand(detectInstallSource());
4056
4090
  store.setState({ updateModalVisible: true });
4057
- addLog(`New version available: ${latestVersion} \u2192 npm i -g git-watchtower`, 'update');
4091
+ try { telemetry.setLastSeenUpdateVersion(latestVersion); } catch (_) { /* persistence is best-effort */ }
4092
+ addLog(`New version available: ${latestVersion} \u2192 ${upgradeCmd}`, 'update');
4058
4093
  }
4059
4094
  render();
4060
4095
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "2.1.17",
3
+ "version": "2.2.0",
4
4
  "description": "Terminal-based Git branch monitor with activity sparklines and optional dev server with live reload",
5
5
  "main": "bin/git-watchtower.js",
6
6
  "bin": {
@@ -100,18 +100,14 @@ async function execGit(args, options = {}) {
100
100
  }
101
101
 
102
102
  /**
103
- * Execute a git command, collapsing any failure into a null result.
104
- *
105
- * Callers use this when they want "the output, or nothing": the fallback
106
- * pattern `execGitOptional(A) || execGitOptional(B)` relies on this, as does
107
- * every caller that treats a missing result as "no data to show." This does
108
- * conflate "branch has no commits" (empty stdout, non-null result) with
109
- * "git errored" (null result) — if you need to distinguish those, use
110
- * execGit() and handle the throw yourself.
103
+ * Like execGit but returns null instead of throwing on failure. Use for
104
+ * the `execGitOptional(A) || execGitOptional(B)` fallback pattern. Note
105
+ * this conflates "command succeeded with empty stdout" (non-null result)
106
+ * with "command errored" (null) use execGit if you need to distinguish.
111
107
  *
112
108
  * @param {string[]} command - Git arguments
113
109
  * @param {Object} [options] - Execution options
114
- * @returns {Promise<{stdout: string, stderr: string}|null>} Result, or null if git failed
110
+ * @returns {Promise<{stdout: string, stderr: string}|null>}
115
111
  */
116
112
  async function execGitOptional(command, options = {}) {
117
113
  try {
@@ -7,6 +7,7 @@
7
7
 
8
8
  const https = require('https');
9
9
  const { isTelemetryEnabled, getOrCreateDistinctId } = require('./config');
10
+ const { detectInstallSource } = require('../utils/install-source');
10
11
 
11
12
  const POSTHOG_API_KEY = 'phc_fdGL8TVN5aFPXmQ4f1hI8y6sqnscD7dy9j5SM5gTylG';
12
13
  const POSTHOG_HOST = 'us.i.posthog.com';
@@ -78,6 +79,7 @@ function queueEvent(event, properties, overrideDistinctId) {
78
79
  ...properties,
79
80
  $lib: 'git-watchtower',
80
81
  $lib_version: appVersion,
82
+ install_source: detectInstallSource(),
81
83
  },
82
84
  timestamp: new Date().toISOString(),
83
85
  });
@@ -179,6 +181,7 @@ function captureAlways(event, userDistinctId, properties = {}) {
179
181
  ...properties,
180
182
  $lib: 'git-watchtower',
181
183
  $lib_version: appVersion,
184
+ install_source: detectInstallSource(),
182
185
  },
183
186
  timestamp: new Date().toISOString(),
184
187
  }],
@@ -30,8 +30,18 @@ function getConfigPath() {
30
30
  }
31
31
 
32
32
  /**
33
- * Load telemetry config from disk
34
- * @returns {{ telemetryEnabled: boolean, distinctId?: string, promptedAt: string } | null}
33
+ * @typedef {object} UserConfig
34
+ * @property {boolean} [telemetryEnabled]
35
+ * @property {string} [distinctId]
36
+ * @property {string} [promptedAt]
37
+ * @property {string} [lastSeenUpdateVersion]
38
+ */
39
+
40
+ /**
41
+ * Load user config from disk. The same file persists telemetry preferences
42
+ * and update-modal state — fields are individually optional because the
43
+ * file may exist before the telemetry consent prompt has run.
44
+ * @returns {UserConfig | null}
35
45
  */
36
46
  function loadTelemetryConfig() {
37
47
  try {
@@ -55,7 +65,7 @@ function loadTelemetryConfig() {
55
65
  * handles the missing case by minting a fresh UUID if the user later
56
66
  * opts in.
57
67
  *
58
- * @param {{ telemetryEnabled: boolean, distinctId?: string, promptedAt: string }} config
68
+ * @param {UserConfig} config
59
69
  */
60
70
  function saveTelemetryConfig(config) {
61
71
  const dir = getConfigDir();
@@ -102,11 +112,15 @@ function isTelemetryEnabled() {
102
112
  }
103
113
 
104
114
  /**
105
- * Check if the user has already been prompted for telemetry
115
+ * Check if the user has already been prompted for telemetry.
116
+ * Looks specifically at `promptedAt` rather than config existence — the
117
+ * same file also stores non-telemetry preferences (e.g. lastSeenUpdateVersion),
118
+ * so a present file does not on its own mean the consent prompt has run.
106
119
  * @returns {boolean}
107
120
  */
108
121
  function hasBeenPrompted() {
109
- return loadTelemetryConfig() !== null;
122
+ const config = loadTelemetryConfig();
123
+ return !!(config && typeof config.promptedAt === 'string');
110
124
  }
111
125
 
112
126
  /**
@@ -118,6 +132,33 @@ function isEnvDisabled() {
118
132
  return envVar !== undefined && envVar.toLowerCase() === 'false';
119
133
  }
120
134
 
135
+ /**
136
+ * Get the last update version the user has been notified about.
137
+ * Used to suppress re-popping the update modal on subsequent launches
138
+ * for a version they've already seen.
139
+ * @returns {string | null}
140
+ */
141
+ function getLastSeenUpdateVersion() {
142
+ const config = loadTelemetryConfig();
143
+ if (config && typeof config.lastSeenUpdateVersion === 'string') {
144
+ return config.lastSeenUpdateVersion;
145
+ }
146
+ return null;
147
+ }
148
+
149
+ /**
150
+ * Persist the version the user has just been notified about. Merges into
151
+ * the existing user-config file so telemetry preferences are preserved.
152
+ * @param {string} version
153
+ */
154
+ function setLastSeenUpdateVersion(version) {
155
+ const existing = loadTelemetryConfig() || {};
156
+ saveTelemetryConfig({
157
+ ...existing,
158
+ lastSeenUpdateVersion: version,
159
+ });
160
+ }
161
+
121
162
  module.exports = {
122
163
  getConfigDir,
123
164
  getConfigPath,
@@ -127,4 +168,6 @@ module.exports = {
127
168
  isTelemetryEnabled,
128
169
  hasBeenPrompted,
129
170
  isEnvDisabled,
171
+ getLastSeenUpdateVersion,
172
+ setLastSeenUpdateVersion,
130
173
  };
@@ -104,4 +104,6 @@ module.exports = {
104
104
  hasBeenPrompted: config.hasBeenPrompted,
105
105
  loadTelemetryConfig: config.loadTelemetryConfig,
106
106
  saveTelemetryConfig: config.saveTelemetryConfig,
107
+ getLastSeenUpdateVersion: config.getLastSeenUpdateVersion,
108
+ setLastSeenUpdateVersion: config.setLastSeenUpdateVersion,
107
109
  };
@@ -23,6 +23,7 @@ const {
23
23
  } = require('../ui/ansi');
24
24
  const { formatTimeAgo, formatTimeCompact } = require('../utils/time');
25
25
  const { isBaseBranch } = require('../git/pr');
26
+ const { detectInstallSource, getUpdateCommand } = require('../utils/install-source');
26
27
  const { version: PACKAGE_VERSION } = require('../../package.json');
27
28
 
28
29
  // ---------------------------------------------------------------------------
@@ -1482,7 +1483,7 @@ function renderUpdateModal(state, write) {
1482
1483
 
1483
1484
  const latestVersion = state.updateAvailable;
1484
1485
  const currentVer = PACKAGE_VERSION;
1485
- const updateCmd = 'npm i -g git-watchtower';
1486
+ const updateCmd = getUpdateCommand(detectInstallSource());
1486
1487
 
1487
1488
  const options = [
1488
1489
  'Update & restart',
@@ -1500,12 +1501,13 @@ function renderUpdateModal(state, write) {
1500
1501
  lines.push(`Current version: v${currentVer}`);
1501
1502
  lines.push(`Latest version: v${latestVersion}`);
1502
1503
  lines.push('');
1504
+ // Command on its own plaintext line so a triple-click selects it cleanly.
1505
+ lines.push(updateCmd);
1506
+ lines.push('');
1503
1507
 
1504
1508
  if (state.updateInProgress) {
1505
1509
  lines.push('Updating...');
1506
1510
  lines.push('');
1507
- lines.push(` ${updateCmd}`);
1508
- lines.push('');
1509
1511
  lines.push('Please wait...');
1510
1512
  } else {
1511
1513
  // Option lines
@@ -1517,7 +1519,7 @@ function renderUpdateModal(state, write) {
1517
1519
  lines.push('[Enter] Select [Esc] Dismiss');
1518
1520
  }
1519
1521
 
1520
- const optionStartIdx = state.updateInProgress ? -1 : 5;
1522
+ const optionStartIdx = state.updateInProgress ? -1 : 7;
1521
1523
  const height = lines.length + 2;
1522
1524
 
1523
1525
  // Draw magenta double-border box
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Install source detection — figures out how the running CLI was installed
3
+ * (homebrew, npm global, source checkout) so the update flow can show the
4
+ * correct upgrade command instead of hardcoding `npm i -g git-watchtower`.
5
+ *
6
+ * Result is memoized for the life of the process — install source can't
7
+ * change between calls in the same run.
8
+ *
9
+ * @module utils/install-source
10
+ */
11
+
12
+ const fs = require('fs');
13
+
14
+ /** @typedef {'homebrew' | 'npm' | 'source' | 'unknown'} InstallSource */
15
+
16
+ /** @type {InstallSource | null} */
17
+ let _cached = null;
18
+ let _detected = false;
19
+
20
+ /**
21
+ * Classify a resolved entry-script path. Exposed for tests so they can
22
+ * exercise every branch with a synthetic path string.
23
+ *
24
+ * Order matters: homebrew installs ship via npm under the hood, so the
25
+ * resolved path contains both `Cellar` AND `node_modules`. Check Cellar
26
+ * first or homebrew users get classified as npm.
27
+ *
28
+ * @param {string} resolvedPath - Result of fs.realpathSync on the entry script
29
+ * @returns {InstallSource}
30
+ */
31
+ function classifyPath(resolvedPath) {
32
+ const segments = resolvedPath.split(/[\\/]+/).map((s) => s.toLowerCase());
33
+ if (segments.includes('cellar') || segments.includes('homebrew')) return 'homebrew';
34
+ if (segments.includes('node_modules')) return 'npm';
35
+ return 'source';
36
+ }
37
+
38
+ /**
39
+ * Detect how the currently-running CLI was installed.
40
+ * Memoized — subsequent calls return the cached result.
41
+ * @returns {InstallSource}
42
+ */
43
+ function detectInstallSource() {
44
+ if (_detected) return /** @type {InstallSource} */ (_cached);
45
+ _detected = true;
46
+ try {
47
+ const entry = process.argv[1] || (require.main && require.main.filename);
48
+ if (!entry) {
49
+ _cached = 'unknown';
50
+ return _cached;
51
+ }
52
+ _cached = classifyPath(fs.realpathSync(entry));
53
+ } catch {
54
+ _cached = 'unknown';
55
+ }
56
+ return _cached;
57
+ }
58
+
59
+ /**
60
+ * Get the user-facing upgrade command for a given install source.
61
+ * @param {InstallSource} source
62
+ * @returns {string}
63
+ */
64
+ function getUpdateCommand(source) {
65
+ switch (source) {
66
+ case 'homebrew': return 'brew upgrade git-watchtower';
67
+ case 'npm': return 'npm i -g git-watchtower';
68
+ case 'source': return 'git pull && npm install';
69
+ default: return 'npm i -g git-watchtower';
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Reset the memoized result. Tests only — not part of the public API.
75
+ * @private
76
+ */
77
+ function _resetForTests() {
78
+ _cached = null;
79
+ _detected = false;
80
+ }
81
+
82
+ module.exports = {
83
+ detectInstallSource,
84
+ getUpdateCommand,
85
+ classifyPath,
86
+ _resetForTests,
87
+ };