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.
- package/bin/git-watchtower.js +96 -48
- package/package.json +1 -1
- package/src/git/commands.js +5 -9
- package/src/telemetry/analytics.js +3 -0
- package/src/telemetry/config.js +48 -5
- package/src/telemetry/index.js +2 -0
- package/src/ui/renderer.js +6 -4
- package/src/utils/install-source.js +87 -0
package/bin/git-watchtower.js
CHANGED
|
@@ -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',
|
|
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 (_) {
|
|
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 —
|
|
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(
|
|
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:
|
|
2836
|
-
showFlash(
|
|
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:
|
|
2843
|
-
showFlash(
|
|
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(
|
|
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
|
|
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:
|
|
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 (_) {
|
|
3543
|
+
try { webDashboard.stop(); } catch (_) {}
|
|
3508
3544
|
}
|
|
3509
3545
|
if (coordinator) {
|
|
3510
|
-
try { coordinator.stop(); } catch (_) {
|
|
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 (_) {
|
|
3569
|
-
try { process.stdin.pause(); } catch (_) {
|
|
3570
|
-
try { process.stdout.removeAllListeners('resize'); } catch (_) {
|
|
3571
|
-
try { process.removeAllListeners('SIGWINCH'); } catch (_) {
|
|
3572
|
-
try { process.removeAllListeners('SIGINT'); } catch (_) {
|
|
3573
|
-
try { process.removeAllListeners('SIGTERM'); } catch (_) {
|
|
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 (_) {
|
|
3615
|
+
try { clearTimeout(pollIntervalId); } catch (_) {}
|
|
3580
3616
|
pollIntervalId = null;
|
|
3581
3617
|
}
|
|
3582
3618
|
if (periodicUpdateCheck) {
|
|
3583
|
-
try { periodicUpdateCheck.stop(); } catch (_) {
|
|
3619
|
+
try { periodicUpdateCheck.stop(); } catch (_) {}
|
|
3584
3620
|
}
|
|
3585
3621
|
if (fileWatcher) {
|
|
3586
|
-
try { fileWatcher.close(); } catch (_) {
|
|
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 (_) {
|
|
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 (_) {
|
|
3660
|
-
try { write(ansi.restoreScreen); } catch (_) {
|
|
3661
|
-
try { restoreTerminalTitle(); } catch (_) {
|
|
3662
|
-
try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch (_) {
|
|
3663
|
-
try { process.stdin.pause(); } catch (_) {
|
|
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 (_) {
|
|
3702
|
+
try { clearTimeout(pollIntervalId); } catch (_) {}
|
|
3667
3703
|
pollIntervalId = null;
|
|
3668
3704
|
}
|
|
3669
3705
|
|
|
3670
3706
|
if (periodicUpdateCheck) {
|
|
3671
|
-
try { periodicUpdateCheck.stop(); } catch (_) {
|
|
3707
|
+
try { periodicUpdateCheck.stop(); } catch (_) {}
|
|
3672
3708
|
}
|
|
3673
3709
|
|
|
3674
3710
|
if (fileWatcher) {
|
|
3675
|
-
try { fileWatcher.close(); } catch (_) {
|
|
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 (_) {
|
|
3719
|
+
try { client.end(); } catch (_) {}
|
|
3684
3720
|
});
|
|
3685
3721
|
clients.clear();
|
|
3686
|
-
} catch (_) {
|
|
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 (_) {
|
|
3732
|
+
try { serverStopPromise = stopServerProcess(); } catch (_) {}
|
|
3697
3733
|
}
|
|
3698
3734
|
|
|
3699
3735
|
// Web dashboard + worker/coordinator (unlinks lock file + IPC socket)
|
|
3700
|
-
try { stopWebDashboard(); } catch (_) {
|
|
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 (_) {
|
|
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 (_) {
|
|
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 (_) {
|
|
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 (_) {
|
|
3819
|
+
try { telemetry.captureError(err); } catch (_) {}
|
|
3784
3820
|
console.error('Uncaught exception:', err);
|
|
3785
|
-
try { await telemetry.shutdown(); } catch (_) {
|
|
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 (_) {
|
|
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 (_) {
|
|
3839
|
+
try { telemetry.captureError(err); } catch (_) {}
|
|
3804
3840
|
console.error('Unhandled rejection:', reason);
|
|
3805
|
-
try { await telemetry.shutdown(); } catch (_) {
|
|
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 (_) {
|
|
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
|
-
|
|
4031
|
-
|
|
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
|
-
|
|
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
package/src/git/commands.js
CHANGED
|
@@ -100,18 +100,14 @@ async function execGit(args, options = {}) {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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>}
|
|
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
|
}],
|
package/src/telemetry/config.js
CHANGED
|
@@ -30,8 +30,18 @@ function getConfigPath() {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
* @
|
|
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 {
|
|
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
|
-
|
|
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
|
};
|
package/src/telemetry/index.js
CHANGED
|
@@ -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
|
};
|
package/src/ui/renderer.js
CHANGED
|
@@ -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 =
|
|
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 :
|
|
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
|
+
};
|