git-watchtower 2.1.17 → 2.2.1
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 +82 -47
- 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
|
|
|
@@ -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 (_) {
|
|
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 —
|
|
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(
|
|
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:
|
|
2849
|
-
showFlash(
|
|
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:
|
|
2856
|
-
showFlash(
|
|
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(
|
|
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
|
|
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:
|
|
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 (_) {
|
|
3543
|
+
try { webDashboard.stop(); } catch (_) {}
|
|
3521
3544
|
}
|
|
3522
3545
|
if (coordinator) {
|
|
3523
|
-
try { coordinator.stop(); } catch (_) {
|
|
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 (_) {
|
|
3582
|
-
try { process.stdin.pause(); } catch (_) {
|
|
3583
|
-
try { process.stdout.removeAllListeners('resize'); } catch (_) {
|
|
3584
|
-
try { process.removeAllListeners('SIGWINCH'); } catch (_) {
|
|
3585
|
-
try { process.removeAllListeners('SIGINT'); } catch (_) {
|
|
3586
|
-
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 (_) {}
|
|
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 (_) {
|
|
3615
|
+
try { clearTimeout(pollIntervalId); } catch (_) {}
|
|
3593
3616
|
pollIntervalId = null;
|
|
3594
3617
|
}
|
|
3595
3618
|
if (periodicUpdateCheck) {
|
|
3596
|
-
try { periodicUpdateCheck.stop(); } catch (_) {
|
|
3619
|
+
try { periodicUpdateCheck.stop(); } catch (_) {}
|
|
3597
3620
|
}
|
|
3598
3621
|
if (fileWatcher) {
|
|
3599
|
-
try { fileWatcher.close(); } catch (_) {
|
|
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 (_) {
|
|
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 (_) {
|
|
3673
|
-
try { write(ansi.restoreScreen); } catch (_) {
|
|
3674
|
-
try { restoreTerminalTitle(); } catch (_) {
|
|
3675
|
-
try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch (_) {
|
|
3676
|
-
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 (_) {}
|
|
3677
3700
|
|
|
3678
3701
|
if (pollIntervalId) {
|
|
3679
|
-
try { clearTimeout(pollIntervalId); } catch (_) {
|
|
3702
|
+
try { clearTimeout(pollIntervalId); } catch (_) {}
|
|
3680
3703
|
pollIntervalId = null;
|
|
3681
3704
|
}
|
|
3682
3705
|
|
|
3683
3706
|
if (periodicUpdateCheck) {
|
|
3684
|
-
try { periodicUpdateCheck.stop(); } catch (_) {
|
|
3707
|
+
try { periodicUpdateCheck.stop(); } catch (_) {}
|
|
3685
3708
|
}
|
|
3686
3709
|
|
|
3687
3710
|
if (fileWatcher) {
|
|
3688
|
-
try { fileWatcher.close(); } catch (_) {
|
|
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 (_) {
|
|
3719
|
+
try { client.end(); } catch (_) {}
|
|
3697
3720
|
});
|
|
3698
3721
|
clients.clear();
|
|
3699
|
-
} catch (_) {
|
|
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 (_) {
|
|
3732
|
+
try { serverStopPromise = stopServerProcess(); } catch (_) {}
|
|
3710
3733
|
}
|
|
3711
3734
|
|
|
3712
3735
|
// Web dashboard + worker/coordinator (unlinks lock file + IPC socket)
|
|
3713
|
-
try { stopWebDashboard(); } catch (_) {
|
|
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 (_) {
|
|
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 (_) {
|
|
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 (_) {
|
|
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 (_) {
|
|
3819
|
+
try { telemetry.captureError(err); } catch (_) {}
|
|
3797
3820
|
console.error('Uncaught exception:', err);
|
|
3798
|
-
try { await telemetry.shutdown(); } catch (_) {
|
|
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 (_) {
|
|
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 (_) {
|
|
3839
|
+
try { telemetry.captureError(err); } catch (_) {}
|
|
3817
3840
|
console.error('Unhandled rejection:', reason);
|
|
3818
|
-
try { await telemetry.shutdown(); } catch (_) {
|
|
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 (_) {
|
|
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
|
-
|
|
4044
|
-
|
|
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
|
-
|
|
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
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 update && 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
|
+
};
|