git-watchtower 2.1.16 → 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
 
@@ -576,6 +577,18 @@ async function checkCliAuth(cmd) {
576
577
  }
577
578
 
578
579
  // Bulk-fetch PR statuses for all branches (parsing delegated to src/git/pr.js)
580
+ //
581
+ // PR_LIST_LIMIT is the cap passed to `gh pr list` / `glab mr list`. The
582
+ // previous values (200 for gh, glab's default of 30) silently truncated
583
+ // on any active OSS repo — branches whose PR was outside the most recent
584
+ // N rendered with no inline indicator and never sorted as merged.
585
+ // 1000 covers virtually any real-world repo while still keeping the
586
+ // single API call fast (gh streams JSON, no pagination overhead). If
587
+ // your repo somehow exceeds 1000 active PRs, the truncation is back —
588
+ // but at that scale the inline indicator is the smallest of your UX
589
+ // concerns.
590
+ const PR_LIST_LIMIT = '1000';
591
+
579
592
  async function fetchAllPrStatuses() {
580
593
  if (!cachedEnv) return null;
581
594
  const { platform, hasGh, ghAuthed, hasGlab, glabAuthed } = cachedEnv;
@@ -584,7 +597,7 @@ async function fetchAllPrStatuses() {
584
597
  try {
585
598
  const { stdout } = await execCli('gh', [
586
599
  'pr', 'list', '--state', 'all',
587
- '--json', 'headRefName,number,title,state', '--limit', '200',
600
+ '--json', 'headRefName,number,title,state', '--limit', PR_LIST_LIMIT,
588
601
  ]);
589
602
  return parseGitHubPrList(JSON.parse(stdout));
590
603
  } catch (e) { /* gh error */ }
@@ -594,6 +607,7 @@ async function fetchAllPrStatuses() {
594
607
  try {
595
608
  const { stdout } = await execCli('glab', [
596
609
  'mr', 'list', '--state', 'all', '--output', 'json',
610
+ '--per-page', PR_LIST_LIMIT,
597
611
  ]);
598
612
  return parseGitLabMrList(JSON.parse(stdout));
599
613
  } catch (e) { /* glab error */ }
@@ -833,7 +847,7 @@ async function restartServerProcess() {
833
847
  // new process tried to bind a port the old one still held.
834
848
  try {
835
849
  await stopServerProcess();
836
- } catch (_) { /* stopServerProcess never rejects in practice; best-effort */ }
850
+ } catch (_) {}
837
851
  if (isShuttingDown) return;
838
852
  startServerProcess();
839
853
  render();
@@ -2815,12 +2829,24 @@ function setupKeyboardInput() {
2815
2829
  }
2816
2830
  if (key === '\r' || key === '\n') {
2817
2831
  const selectedIdx = store.get('updateModalSelectedIndex') || 0;
2832
+ const installSource = detectInstallSource();
2833
+ const updateCmd = getUpdateCommand(installSource);
2818
2834
  if (selectedIdx === 0) {
2819
- // 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'];
2820
2846
  store.setState({ updateInProgress: true });
2821
2847
  render();
2822
2848
  const { spawn } = require('child_process');
2823
- const child = spawn('npm', ['i', '-g', 'git-watchtower'], {
2849
+ const child = spawn(cmdBin, cmdArgs, {
2824
2850
  stdio: 'ignore',
2825
2851
  detached: false,
2826
2852
  shell: process.platform === 'win32',
@@ -2832,21 +2858,21 @@ function setupKeyboardInput() {
2832
2858
  addLog('Successfully updated git-watchtower! Restarting...', 'update');
2833
2859
  restartProcess();
2834
2860
  } else {
2835
- addLog(`Update failed (exit code ${code}). Run manually: npm i -g git-watchtower`, 'error');
2836
- 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}`);
2837
2863
  }
2838
2864
  render();
2839
2865
  });
2840
2866
  child.on('error', (err) => {
2841
2867
  store.setState({ updateInProgress: false, updateModalVisible: false, updateModalSelectedIndex: 0 });
2842
- addLog(`Update failed: ${err.message}. Run manually: npm i -g git-watchtower`, 'error');
2843
- 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}`);
2844
2870
  render();
2845
2871
  });
2846
2872
  } else {
2847
2873
  // Show update command — dismiss modal with flash showing the command
2848
2874
  store.setState({ updateModalVisible: false, updateModalSelectedIndex: 0 });
2849
- showFlash('Run: npm i -g git-watchtower');
2875
+ showFlash(`Run: ${updateCmd}`);
2850
2876
  }
2851
2877
  return;
2852
2878
  }
@@ -3276,10 +3302,20 @@ async function handleWebAction(action, payload) {
3276
3302
  break;
3277
3303
  case 'checkUpdate':
3278
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
+ }
3279
3312
  store.setState({ updateInProgress: true });
3280
3313
  render();
3281
3314
  const { spawn: spawnUpdate } = require('child_process');
3282
- 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, {
3283
3319
  stdio: 'ignore',
3284
3320
  detached: false,
3285
3321
  });
@@ -3292,7 +3328,7 @@ async function handleWebAction(action, payload) {
3292
3328
  restartProcess();
3293
3329
  } else {
3294
3330
  sendResult(false, `Update failed (exit code ${code})`);
3295
- 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');
3296
3332
  render();
3297
3333
  }
3298
3334
  });
@@ -3504,10 +3540,10 @@ async function startWebDashboard(openBrowser) {
3504
3540
  webStateInterval = null;
3505
3541
  }
3506
3542
  if (webDashboard) {
3507
- try { webDashboard.stop(); } catch (_) { /* web server may not have bound yet — nothing to stop */ }
3543
+ try { webDashboard.stop(); } catch (_) {}
3508
3544
  }
3509
3545
  if (coordinator) {
3510
- try { coordinator.stop(); } catch (_) { /* coordinator may not have started its IPC server */ }
3546
+ try { coordinator.stop(); } catch (_) {}
3511
3547
  }
3512
3548
  removeLock();
3513
3549
  removeSocket();
@@ -3565,25 +3601,25 @@ function restartProcess() {
3565
3601
  // stdio is inherited — so any stray listener here will race with the child
3566
3602
  // (keystrokes consumed twice, render() drawing frames on top of the child's
3567
3603
  // UI, Ctrl+C intercepted by both, etc.).
3568
- try { process.stdin.removeAllListeners('data'); } catch (_) { /* stdin may be detached */ }
3569
- try { process.stdin.pause(); } catch (_) { /* stdin may already be paused */ }
3570
- try { process.stdout.removeAllListeners('resize'); } catch (_) { /* stdout may be detached */ }
3571
- try { process.removeAllListeners('SIGWINCH'); } catch (_) { /* no SIGWINCH handler registered */ }
3572
- try { process.removeAllListeners('SIGINT'); } catch (_) { /* no SIGINT handler registered */ }
3573
- 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 (_) {}
3574
3610
 
3575
3611
  // Stop every scheduler that can trigger a render while we're waiting on the
3576
3612
  // child. periodicUpdateCheck in particular will fire render() on completion
3577
3613
  // and would draw over the replacement's frames.
3578
3614
  if (pollIntervalId) {
3579
- try { clearTimeout(pollIntervalId); } catch (_) { /* defensive */ }
3615
+ try { clearTimeout(pollIntervalId); } catch (_) {}
3580
3616
  pollIntervalId = null;
3581
3617
  }
3582
3618
  if (periodicUpdateCheck) {
3583
- try { periodicUpdateCheck.stop(); } catch (_) { /* interval may already be cleared */ }
3619
+ try { periodicUpdateCheck.stop(); } catch (_) {}
3584
3620
  }
3585
3621
  if (fileWatcher) {
3586
- try { fileWatcher.close(); } catch (_) { /* watcher may already be closed */ }
3622
+ try { fileWatcher.close(); } catch (_) {}
3587
3623
  fileWatcher = null;
3588
3624
  }
3589
3625
 
@@ -3599,7 +3635,7 @@ function restartProcess() {
3599
3635
  // child can acquire it. The parent stays alive waiting on child.on('close'),
3600
3636
  // so without this the child sees the parent as an active owner and refuses.
3601
3637
  if (monitorLockFile) {
3602
- try { monitorLock.release(monitorLockFile); } catch (_) { /* lock file may have already been unlinked */ }
3638
+ try { monitorLock.release(monitorLockFile); } catch (_) {}
3603
3639
  monitorLockFile = null;
3604
3640
  }
3605
3641
 
@@ -3656,23 +3692,23 @@ function cleanupResources() {
3656
3692
 
3657
3693
  // Restore terminal first so the user sees a clean prompt even if a
3658
3694
  // later step throws.
3659
- try { write(ansi.showCursor); } catch (_) { /* stdout may be closed during crash cleanup */ }
3660
- try { write(ansi.restoreScreen); } catch (_) { /* stdout may be closed during crash cleanup */ }
3661
- try { restoreTerminalTitle(); } catch (_) { /* stdout may be closed during crash cleanup */ }
3662
- try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch (_) { /* stdin may already be unraw or detached */ }
3663
- 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 (_) {}
3664
3700
 
3665
3701
  if (pollIntervalId) {
3666
- try { clearTimeout(pollIntervalId); } catch (_) { /* defensive — clearTimeout normally won't throw */ }
3702
+ try { clearTimeout(pollIntervalId); } catch (_) {}
3667
3703
  pollIntervalId = null;
3668
3704
  }
3669
3705
 
3670
3706
  if (periodicUpdateCheck) {
3671
- try { periodicUpdateCheck.stop(); } catch (_) { /* interval handle may already be cleared */ }
3707
+ try { periodicUpdateCheck.stop(); } catch (_) {}
3672
3708
  }
3673
3709
 
3674
3710
  if (fileWatcher) {
3675
- try { fileWatcher.close(); } catch (_) { /* watcher may already be closed by OS or previous cleanup */ }
3711
+ try { fileWatcher.close(); } catch (_) {}
3676
3712
  fileWatcher = null;
3677
3713
  }
3678
3714
 
@@ -3680,10 +3716,10 @@ function cleanupResources() {
3680
3716
  if (SERVER_MODE === 'static') {
3681
3717
  try {
3682
3718
  clients.forEach((client) => {
3683
- try { client.end(); } catch (_) { /* SSE client socket already closed */ }
3719
+ try { client.end(); } catch (_) {}
3684
3720
  });
3685
3721
  clients.clear();
3686
- } catch (_) { /* clients set may have mutated mid-iteration during shutdown */ }
3722
+ } catch (_) {}
3687
3723
  }
3688
3724
 
3689
3725
  // User's dev-server process (command mode). Capture the close promise so
@@ -3693,16 +3729,16 @@ function cleanupResources() {
3693
3729
  // orphan (it was spawned in its own process group on Unix).
3694
3730
  let serverStopPromise = Promise.resolve();
3695
3731
  if (SERVER_MODE === 'command') {
3696
- try { serverStopPromise = stopServerProcess(); } catch (_) { /* dev-server child may already be gone */ }
3732
+ try { serverStopPromise = stopServerProcess(); } catch (_) {}
3697
3733
  }
3698
3734
 
3699
3735
  // Web dashboard + worker/coordinator (unlinks lock file + IPC socket)
3700
- try { stopWebDashboard(); } catch (_) { /* web dashboard may never have been started */ }
3736
+ try { stopWebDashboard(); } catch (_) {}
3701
3737
 
3702
3738
  // Per-repo monitor lock — release last so the slot stays reserved for the
3703
3739
  // entire lifetime of this process, including any errors in the steps above.
3704
3740
  if (monitorLockFile) {
3705
- try { monitorLock.release(monitorLockFile); } catch (_) { /* lock file may have been unlinked externally */ }
3741
+ try { monitorLock.release(monitorLockFile); } catch (_) {}
3706
3742
  monitorLockFile = null;
3707
3743
  }
3708
3744
 
@@ -3729,7 +3765,7 @@ async function shutdown() {
3729
3765
  // FORCE_KILL_GRACE_MS SIGKILL escalation inside stopServerProcess).
3730
3766
  // Without this the escalation timer gets dropped by process.exit() below
3731
3767
  // and a dev server that ignored SIGTERM survives as a detached orphan.
3732
- try { await serverStopPromise; } catch (_) { /* best-effort */ }
3768
+ try { await serverStopPromise; } catch (_) {}
3733
3769
 
3734
3770
  // Flush telemetry
3735
3771
  telemetry.capture('session_ended', {
@@ -3761,7 +3797,7 @@ process.on('exit', () => {
3761
3797
  const stdioPipeErrorHandler = createPipeErrorHandler({
3762
3798
  onEpipe: () => {
3763
3799
  isShuttingDown = true;
3764
- try { cleanupResources(); } catch (_) { /* best-effort during pipe-close */ }
3800
+ try { cleanupResources(); } catch (_) {}
3765
3801
  process.exit(0);
3766
3802
  },
3767
3803
  onOther: (err) => {
@@ -3780,12 +3816,12 @@ process.on('uncaughtException', async (err) => {
3780
3816
  // if telemetry shutdown hangs or throws.
3781
3817
  const serverStopPromise = cleanupResources();
3782
3818
 
3783
- try { telemetry.captureError(err); } catch (_) { /* telemetry must never prevent crash cleanup */ }
3819
+ try { telemetry.captureError(err); } catch (_) {}
3784
3820
  console.error('Uncaught exception:', err);
3785
- try { await telemetry.shutdown(); } catch (_) { /* telemetry must never prevent crash cleanup */ }
3821
+ try { await telemetry.shutdown(); } catch (_) {}
3786
3822
  // Wait for the dev-server SIGKILL escalation before exiting — otherwise
3787
3823
  // process.exit() drops the pending timer and a stuck server orphans.
3788
- try { await serverStopPromise; } catch (_) { /* best-effort */ }
3824
+ try { await serverStopPromise; } catch (_) {}
3789
3825
  process.exit(1);
3790
3826
  });
3791
3827
 
@@ -3800,12 +3836,12 @@ process.on('unhandledRejection', async (reason) => {
3800
3836
  const serverStopPromise = cleanupResources();
3801
3837
 
3802
3838
  const err = reason instanceof Error ? reason : new Error(String(reason));
3803
- try { telemetry.captureError(err); } catch (_) { /* telemetry must never prevent crash cleanup */ }
3839
+ try { telemetry.captureError(err); } catch (_) {}
3804
3840
  console.error('Unhandled rejection:', reason);
3805
- try { await telemetry.shutdown(); } catch (_) { /* telemetry must never prevent crash cleanup */ }
3841
+ try { await telemetry.shutdown(); } catch (_) {}
3806
3842
  // Wait for the dev-server SIGKILL escalation before exiting — otherwise
3807
3843
  // process.exit() drops the pending timer and a stuck server orphans.
3808
- try { await serverStopPromise; } catch (_) { /* best-effort */ }
3844
+ try { await serverStopPromise; } catch (_) {}
3809
3845
  process.exit(1);
3810
3846
  });
3811
3847
 
@@ -3879,7 +3915,10 @@ async function start() {
3879
3915
  const config = await ensureConfig(cliArgs);
3880
3916
  applyConfig(config);
3881
3917
 
3882
- // 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();
3883
3922
  telemetry.setVersion(PACKAGE_VERSION);
3884
3923
  await telemetry.promptIfNeeded(promptYesNo);
3885
3924
  telemetry.init({ version: PACKAGE_VERSION });
@@ -3891,6 +3930,7 @@ async function start() {
3891
3930
  server_mode: SERVER_MODE,
3892
3931
  has_config: !!loadConfig(),
3893
3932
  casino_mode: config.casinoMode || false,
3933
+ install_source: installSource,
3894
3934
  });
3895
3935
 
3896
3936
  // Set up casino mode render callback for animations
@@ -4027,8 +4067,14 @@ async function start() {
4027
4067
  // Check for newer version on npm (non-blocking, silent on failure)
4028
4068
  checkForUpdate().then((latestVersion) => {
4029
4069
  if (latestVersion) {
4030
- store.setState({ updateAvailable: latestVersion, updateModalVisible: true });
4031
- 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');
4032
4078
  render();
4033
4079
  }
4034
4080
  }).catch(() => { /* npm registry unreachable — periodic check will try again in 4h */ });
@@ -4040,8 +4086,10 @@ async function start() {
4040
4086
  store.setState({ updateAvailable: latestVersion });
4041
4087
  if (!alreadyKnown) {
4042
4088
  // First time discovering an update during this session — show modal
4089
+ const upgradeCmd = getUpdateCommand(detectInstallSource());
4043
4090
  store.setState({ updateModalVisible: true });
4044
- 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');
4045
4093
  }
4046
4094
  render();
4047
4095
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "2.1.16",
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
+ };