git-watchtower 1.6.0 → 1.7.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 +89 -9
- package/package.json +6 -1
- package/sounds/README.md +34 -0
- package/src/casino/index.js +721 -0
- package/src/casino/sounds.js +245 -0
- package/src/cli/args.js +239 -0
- package/src/config/loader.js +329 -0
- package/src/config/schema.js +305 -0
- package/src/git/branch.js +428 -0
- package/src/git/commands.js +416 -0
- package/src/git/pr.js +111 -0
- package/src/git/remote.js +127 -0
- package/src/index.js +179 -0
- package/src/polling/engine.js +157 -0
- package/src/server/process.js +329 -0
- package/src/server/static.js +95 -0
- package/src/state/store.js +527 -0
- package/src/telemetry/analytics.js +142 -0
- package/src/telemetry/config.js +123 -0
- package/src/telemetry/index.js +93 -0
- package/src/ui/actions.js +425 -0
- package/src/ui/ansi.js +498 -0
- package/src/ui/keybindings.js +198 -0
- package/src/ui/renderer.js +1326 -0
- package/src/utils/async.js +219 -0
- package/src/utils/browser.js +40 -0
- package/src/utils/errors.js +490 -0
- package/src/utils/gitignore.js +174 -0
- package/src/utils/sound.js +33 -0
- package/src/utils/time.js +27 -0
package/bin/git-watchtower.js
CHANGED
|
@@ -66,6 +66,9 @@ const casinoSounds = require('../src/casino/sounds');
|
|
|
66
66
|
// Gitignore utilities for file watcher
|
|
67
67
|
const { loadGitignorePatterns, shouldIgnoreFile } = require('../src/utils/gitignore');
|
|
68
68
|
|
|
69
|
+
// Telemetry (opt-in PostHog analytics)
|
|
70
|
+
const telemetry = require('../src/telemetry');
|
|
71
|
+
|
|
69
72
|
// Extracted modules
|
|
70
73
|
const { formatTimeAgo } = require('../src/utils/time');
|
|
71
74
|
const { openInBrowser: openUrl } = require('../src/utils/browser');
|
|
@@ -195,6 +198,7 @@ async function runConfigurationWizard() {
|
|
|
195
198
|
|
|
196
199
|
// Save configuration
|
|
197
200
|
saveConfig(config);
|
|
201
|
+
telemetry.capture('config_wizard_completed', { server_mode: config.server.mode });
|
|
198
202
|
|
|
199
203
|
console.log('\n✓ Configuration saved to ' + CONFIG_FILE_NAME);
|
|
200
204
|
console.log(' You can edit this file manually or delete it to reconfigure.\n');
|
|
@@ -367,6 +371,10 @@ let AUTO_PULL = true;
|
|
|
367
371
|
const MAX_LOG_ENTRIES = 10;
|
|
368
372
|
const MAX_SERVER_LOG_LINES = 500;
|
|
369
373
|
|
|
374
|
+
// Telemetry session tracking
|
|
375
|
+
let branchSwitchCount = 0;
|
|
376
|
+
let sessionStartTime = null;
|
|
377
|
+
|
|
370
378
|
// Server process management (for command mode)
|
|
371
379
|
let serverProcess = null;
|
|
372
380
|
|
|
@@ -676,17 +684,27 @@ function stopServerProcess() {
|
|
|
676
684
|
|
|
677
685
|
addLog('Stopping server...', 'update');
|
|
678
686
|
|
|
687
|
+
// Capture reference before nulling — needed for deferred SIGKILL
|
|
688
|
+
const proc = serverProcess;
|
|
689
|
+
|
|
679
690
|
// Try graceful shutdown first
|
|
680
691
|
if (process.platform === 'win32') {
|
|
681
|
-
spawn('taskkill', ['/pid',
|
|
692
|
+
spawn('taskkill', ['/pid', proc.pid.toString(), '/f', '/t']);
|
|
682
693
|
} else {
|
|
683
|
-
|
|
684
|
-
// Force kill after
|
|
685
|
-
setTimeout(() => {
|
|
686
|
-
|
|
687
|
-
|
|
694
|
+
proc.kill('SIGTERM');
|
|
695
|
+
// Force kill after grace period if process hasn't exited
|
|
696
|
+
const forceKillTimeout = setTimeout(() => {
|
|
697
|
+
try {
|
|
698
|
+
proc.kill('SIGKILL');
|
|
699
|
+
} catch (e) {
|
|
700
|
+
// Process may already be dead
|
|
688
701
|
}
|
|
689
702
|
}, 3000);
|
|
703
|
+
|
|
704
|
+
// Clear the force-kill timer if the process exits cleanly
|
|
705
|
+
proc.once('close', () => {
|
|
706
|
+
clearTimeout(forceKillTimeout);
|
|
707
|
+
});
|
|
690
708
|
}
|
|
691
709
|
|
|
692
710
|
serverProcess = null;
|
|
@@ -796,15 +814,28 @@ const LIVE_RELOAD_SCRIPT = `
|
|
|
796
814
|
// Utility Functions
|
|
797
815
|
// ============================================================================
|
|
798
816
|
|
|
817
|
+
// Default timeout for execAsync (30 seconds) — prevents hung git/CLI commands
|
|
818
|
+
// from permanently blocking the polling loop
|
|
819
|
+
const EXEC_ASYNC_TIMEOUT = 30000;
|
|
820
|
+
|
|
799
821
|
function execAsync(command, options = {}) {
|
|
822
|
+
const { timeout = EXEC_ASYNC_TIMEOUT, ...restOptions } = options;
|
|
800
823
|
return new Promise((resolve, reject) => {
|
|
801
|
-
exec(command, { cwd: PROJECT_ROOT, ...
|
|
824
|
+
const child = exec(command, { cwd: PROJECT_ROOT, timeout, ...restOptions }, (error, stdout, stderr) => {
|
|
802
825
|
if (error) {
|
|
803
826
|
reject({ error, stdout, stderr });
|
|
804
827
|
} else {
|
|
805
828
|
resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
|
|
806
829
|
}
|
|
807
830
|
});
|
|
831
|
+
// Also kill the child if the timeout fires (exec timeout sends SIGTERM
|
|
832
|
+
// but doesn't guarantee cleanup of process trees)
|
|
833
|
+
if (timeout > 0) {
|
|
834
|
+
const killTimer = setTimeout(() => {
|
|
835
|
+
try { child.kill('SIGKILL'); } catch (e) { /* already dead */ }
|
|
836
|
+
}, timeout + 5000);
|
|
837
|
+
child.on('close', () => clearTimeout(killTimer));
|
|
838
|
+
}
|
|
808
839
|
});
|
|
809
840
|
}
|
|
810
841
|
|
|
@@ -1396,6 +1427,7 @@ async function switchToBranch(branchName, recordHistory = true) {
|
|
|
1396
1427
|
addLog(`Cannot switch: uncommitted changes in working directory`, 'error');
|
|
1397
1428
|
pendingDirtyOperation = { type: 'switch', branch: branchName };
|
|
1398
1429
|
showStashConfirm(`switch to ${branchName}`);
|
|
1430
|
+
telemetry.capture('dirty_repo_encountered');
|
|
1399
1431
|
return { success: false, reason: 'dirty' };
|
|
1400
1432
|
}
|
|
1401
1433
|
|
|
@@ -1428,6 +1460,8 @@ async function switchToBranch(branchName, recordHistory = true) {
|
|
|
1428
1460
|
}
|
|
1429
1461
|
|
|
1430
1462
|
addLog(`Switched to ${safeBranchName}`, 'success');
|
|
1463
|
+
telemetry.capture('branch_switched');
|
|
1464
|
+
branchSwitchCount++;
|
|
1431
1465
|
pendingDirtyOperation = null;
|
|
1432
1466
|
|
|
1433
1467
|
// Restart server if configured (command mode)
|
|
@@ -1457,6 +1491,7 @@ async function switchToBranch(branchName, recordHistory = true) {
|
|
|
1457
1491
|
truncate(errMsg, 100),
|
|
1458
1492
|
'Check the activity log for details'
|
|
1459
1493
|
);
|
|
1494
|
+
telemetry.captureError(e);
|
|
1460
1495
|
}
|
|
1461
1496
|
return { success: false };
|
|
1462
1497
|
}
|
|
@@ -1565,6 +1600,7 @@ async function stashAndRetry() {
|
|
|
1565
1600
|
}
|
|
1566
1601
|
|
|
1567
1602
|
addLog('Changes stashed successfully', 'success');
|
|
1603
|
+
telemetry.capture('stash_performed');
|
|
1568
1604
|
|
|
1569
1605
|
if (operation.type === 'switch') {
|
|
1570
1606
|
const switchResult = await switchToBranch(operation.branch);
|
|
@@ -2010,6 +2046,16 @@ const server = http.createServer((req, res) => {
|
|
|
2010
2046
|
pathname = path.normalize(pathname).replace(/^(\.\.[\/\\])+/, '');
|
|
2011
2047
|
let filePath = path.join(STATIC_DIR, pathname);
|
|
2012
2048
|
|
|
2049
|
+
// Security: ensure resolved path stays within STATIC_DIR to prevent path traversal
|
|
2050
|
+
const resolvedPath = path.resolve(filePath);
|
|
2051
|
+
const resolvedStaticDir = path.resolve(STATIC_DIR);
|
|
2052
|
+
if (!resolvedPath.startsWith(resolvedStaticDir + path.sep) && resolvedPath !== resolvedStaticDir) {
|
|
2053
|
+
res.writeHead(403, { 'Content-Type': 'text/html' });
|
|
2054
|
+
res.end('<h1>403 Forbidden</h1>');
|
|
2055
|
+
addServerLog(`GET ${logPath} → 403 (path traversal blocked)`, true);
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2013
2059
|
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
|
|
2014
2060
|
filePath = path.join(filePath, 'index.html');
|
|
2015
2061
|
}
|
|
@@ -2215,6 +2261,7 @@ function setupKeyboardInput() {
|
|
|
2215
2261
|
openInBrowser(prUrl);
|
|
2216
2262
|
} else if (!prInfo && prLoaded && cliReady) {
|
|
2217
2263
|
// Create PR — only if we've confirmed no PR exists (prLoaded=true)
|
|
2264
|
+
telemetry.capture('pr_action', { action: 'create' });
|
|
2218
2265
|
addLog(`Creating ${prLabel} for ${aBranch.name}...`, 'update');
|
|
2219
2266
|
render();
|
|
2220
2267
|
try {
|
|
@@ -2256,6 +2303,7 @@ function setupKeyboardInput() {
|
|
|
2256
2303
|
return;
|
|
2257
2304
|
}
|
|
2258
2305
|
if (key === 'a' && prInfo && cliReady) { // Approve PR
|
|
2306
|
+
telemetry.capture('pr_action', { action: 'approve' });
|
|
2259
2307
|
addLog(`Approving ${prLabel} #${prInfo.number}...`, 'update');
|
|
2260
2308
|
render();
|
|
2261
2309
|
try {
|
|
@@ -2275,6 +2323,7 @@ function setupKeyboardInput() {
|
|
|
2275
2323
|
return;
|
|
2276
2324
|
}
|
|
2277
2325
|
if (key === 'm' && prInfo && cliReady) { // Merge PR
|
|
2326
|
+
telemetry.capture('pr_action', { action: 'merge' });
|
|
2278
2327
|
addLog(`Merging ${prLabel} #${prInfo.number}...`, 'update');
|
|
2279
2328
|
render();
|
|
2280
2329
|
try {
|
|
@@ -2373,6 +2422,7 @@ function setupKeyboardInput() {
|
|
|
2373
2422
|
addLog(`Failed to delete ${f.name}: ${f.error}`, 'error');
|
|
2374
2423
|
}
|
|
2375
2424
|
if (result.deleted.length > 0) {
|
|
2425
|
+
telemetry.capture('cleanup_branches_deleted', { count: result.deleted.length });
|
|
2376
2426
|
addLog(`Cleaned up ${result.deleted.length} branch${result.deleted.length === 1 ? '' : 'es'}`, 'success');
|
|
2377
2427
|
await pollGitChanges();
|
|
2378
2428
|
}
|
|
@@ -2495,12 +2545,14 @@ function setupKeyboardInput() {
|
|
|
2495
2545
|
render();
|
|
2496
2546
|
const pvData = await getPreviewData(branch.name);
|
|
2497
2547
|
store.setState({ previewData: pvData, previewMode: true });
|
|
2548
|
+
telemetry.capture('preview_opened');
|
|
2498
2549
|
render();
|
|
2499
2550
|
}
|
|
2500
2551
|
break;
|
|
2501
2552
|
|
|
2502
2553
|
case '/': // Search mode
|
|
2503
2554
|
applyUpdates(actions.enterSearchMode(actionState));
|
|
2555
|
+
telemetry.capture('search_used');
|
|
2504
2556
|
render();
|
|
2505
2557
|
break;
|
|
2506
2558
|
|
|
@@ -2515,11 +2567,13 @@ function setupKeyboardInput() {
|
|
|
2515
2567
|
break;
|
|
2516
2568
|
|
|
2517
2569
|
case 'u': // Undo last switch
|
|
2570
|
+
telemetry.capture('undo_branch_switch');
|
|
2518
2571
|
await undoLastSwitch();
|
|
2519
2572
|
await pollGitChanges();
|
|
2520
2573
|
break;
|
|
2521
2574
|
|
|
2522
2575
|
case 'p':
|
|
2576
|
+
telemetry.capture('pull_forced');
|
|
2523
2577
|
await pullCurrentBranch();
|
|
2524
2578
|
await pollGitChanges();
|
|
2525
2579
|
break;
|
|
@@ -2558,6 +2612,7 @@ function setupKeyboardInput() {
|
|
|
2558
2612
|
const branch = displayBranches.length > 0 && curSelIdx < displayBranches.length
|
|
2559
2613
|
? displayBranches[curSelIdx] : null;
|
|
2560
2614
|
if (branch) {
|
|
2615
|
+
telemetry.capture('branch_actions_opened');
|
|
2561
2616
|
// Phase 1: Open modal instantly with local/cached data
|
|
2562
2617
|
const localData = gatherLocalActionData(branch);
|
|
2563
2618
|
store.setState({ actionData: localData, actionMode: true, actionLoading: !localData.prLoaded });
|
|
@@ -2592,8 +2647,10 @@ function setupKeyboardInput() {
|
|
|
2592
2647
|
|
|
2593
2648
|
case 's': {
|
|
2594
2649
|
applyUpdates(actions.toggleSound(actionState));
|
|
2595
|
-
|
|
2596
|
-
|
|
2650
|
+
const soundNowEnabled = store.get('soundEnabled');
|
|
2651
|
+
addLog(`Sound notifications ${soundNowEnabled ? 'enabled' : 'disabled'}`, 'info');
|
|
2652
|
+
telemetry.capture('sound_toggled', { enabled: soundNowEnabled });
|
|
2653
|
+
if (soundNowEnabled) playSound();
|
|
2597
2654
|
render();
|
|
2598
2655
|
break;
|
|
2599
2656
|
}
|
|
@@ -2612,6 +2669,7 @@ function setupKeyboardInput() {
|
|
|
2612
2669
|
case 'c': { // Toggle casino mode
|
|
2613
2670
|
const newCasinoState = casino.toggle();
|
|
2614
2671
|
store.setState({ casinoModeEnabled: newCasinoState });
|
|
2672
|
+
telemetry.capture('casino_mode_toggled', { enabled: newCasinoState });
|
|
2615
2673
|
addLog(`Casino mode ${newCasinoState ? '🎰 ENABLED' : 'disabled'}`, newCasinoState ? 'success' : 'info');
|
|
2616
2674
|
if (newCasinoState) {
|
|
2617
2675
|
addLog(`Have you noticed this game has that 'variable rewards' thing going on? 🤔😉`, 'info');
|
|
@@ -2721,6 +2779,14 @@ async function shutdown() {
|
|
|
2721
2779
|
await Promise.race([serverClosePromise, timeoutPromise]);
|
|
2722
2780
|
}
|
|
2723
2781
|
|
|
2782
|
+
// Flush telemetry
|
|
2783
|
+
telemetry.capture('session_ended', {
|
|
2784
|
+
duration_seconds: sessionStartTime ? Math.round((Date.now() - sessionStartTime) / 1000) : 0,
|
|
2785
|
+
branch_switches: branchSwitchCount,
|
|
2786
|
+
branches_count: store.get('branches').length,
|
|
2787
|
+
});
|
|
2788
|
+
await telemetry.shutdown();
|
|
2789
|
+
|
|
2724
2790
|
console.log('\n✓ Git Watchtower stopped\n');
|
|
2725
2791
|
process.exit(0);
|
|
2726
2792
|
}
|
|
@@ -2728,6 +2794,7 @@ async function shutdown() {
|
|
|
2728
2794
|
process.on('SIGINT', shutdown);
|
|
2729
2795
|
process.on('SIGTERM', shutdown);
|
|
2730
2796
|
process.on('uncaughtException', (err) => {
|
|
2797
|
+
telemetry.captureError(err);
|
|
2731
2798
|
write(ansi.showCursor);
|
|
2732
2799
|
write(ansi.restoreScreen);
|
|
2733
2800
|
restoreTerminalTitle();
|
|
@@ -2754,6 +2821,19 @@ async function start() {
|
|
|
2754
2821
|
const config = await ensureConfig(cliArgs);
|
|
2755
2822
|
applyConfig(config);
|
|
2756
2823
|
|
|
2824
|
+
// Telemetry: opt-in prompt (first run only) and initialization
|
|
2825
|
+
await telemetry.promptIfNeeded(promptYesNo);
|
|
2826
|
+
telemetry.init({ version: PACKAGE_VERSION });
|
|
2827
|
+
sessionStartTime = Date.now();
|
|
2828
|
+
telemetry.capture('tool_launched', {
|
|
2829
|
+
version: PACKAGE_VERSION,
|
|
2830
|
+
node_version: process.version,
|
|
2831
|
+
os: process.platform,
|
|
2832
|
+
server_mode: SERVER_MODE,
|
|
2833
|
+
has_config: !!loadConfig(),
|
|
2834
|
+
casino_mode: config.casinoMode || false,
|
|
2835
|
+
});
|
|
2836
|
+
|
|
2757
2837
|
// Set up casino mode render callback for animations
|
|
2758
2838
|
casino.setRenderCallback(render);
|
|
2759
2839
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-watchtower",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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": {
|
|
@@ -52,6 +52,8 @@
|
|
|
52
52
|
},
|
|
53
53
|
"files": [
|
|
54
54
|
"bin/git-watchtower.js",
|
|
55
|
+
"src/",
|
|
56
|
+
"sounds/",
|
|
55
57
|
"README.md",
|
|
56
58
|
"LICENSE"
|
|
57
59
|
],
|
|
@@ -85,5 +87,8 @@
|
|
|
85
87
|
}
|
|
86
88
|
]
|
|
87
89
|
]
|
|
90
|
+
},
|
|
91
|
+
"dependencies": {
|
|
92
|
+
"posthog-node": "^5.28.0"
|
|
88
93
|
}
|
|
89
94
|
}
|
package/sounds/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Casino Mode Sound Effects
|
|
2
|
+
|
|
3
|
+
Casino mode uses system sounds by default. For custom sounds, add `.wav` files to this directory:
|
|
4
|
+
|
|
5
|
+
## Sound Files
|
|
6
|
+
|
|
7
|
+
| File | Description |
|
|
8
|
+
|------|-------------|
|
|
9
|
+
| `win.wav` | Short victory sound (small/medium wins) |
|
|
10
|
+
| `jackpot.wav` | Exciting fanfare (big wins, jackpots) |
|
|
11
|
+
| `spin.wav` | Slot machine spinning sound |
|
|
12
|
+
| `loss.wav` | Sad trombone / failure sound |
|
|
13
|
+
|
|
14
|
+
## Free Sound Sources
|
|
15
|
+
|
|
16
|
+
- [Freesound.org](https://freesound.org/) - Search for "slot machine", "casino win", "coin"
|
|
17
|
+
- [Mixkit](https://mixkit.co/free-sound-effects/) - Game sounds category
|
|
18
|
+
- [Zapsplat](https://www.zapsplat.com/) - Royalty-free effects
|
|
19
|
+
|
|
20
|
+
## Recommended Search Terms
|
|
21
|
+
|
|
22
|
+
- "slot machine win"
|
|
23
|
+
- "casino jackpot"
|
|
24
|
+
- "coin drop"
|
|
25
|
+
- "cha-ching"
|
|
26
|
+
- "sad trombone"
|
|
27
|
+
- "wah wah"
|
|
28
|
+
- "game over"
|
|
29
|
+
|
|
30
|
+
## Format Notes
|
|
31
|
+
|
|
32
|
+
- WAV format recommended for best compatibility
|
|
33
|
+
- Keep files short (1-3 seconds)
|
|
34
|
+
- Mono or stereo, any sample rate works
|