git-watchtower 1.8.2 → 1.8.4

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.
@@ -56,7 +56,7 @@
56
56
  const http = require('http');
57
57
  const fs = require('fs');
58
58
  const path = require('path');
59
- const { exec, execSync, spawn } = require('child_process');
59
+ const { execFile, execSync, spawn } = require('child_process');
60
60
  const readline = require('readline');
61
61
 
62
62
  // Casino mode - Vegas-style feedback effects
@@ -82,7 +82,7 @@ const { parseGitHubPr, parseGitLabMr, parseGitHubPrList, parseGitLabMrList, isBa
82
82
  // Security & Validation (imported from src/git/branch.js and src/git/commands.js)
83
83
  // ============================================================================
84
84
  const { isValidBranchName, sanitizeBranchName, getGoneBranches, deleteGoneBranches } = require('../src/git/branch');
85
- const { isGitAvailable: checkGitAvailable } = require('../src/git/commands');
85
+ const { isGitAvailable: checkGitAvailable, execGit, execGitSilent, getDiffStats: getDiffStatsSafe } = require('../src/git/commands');
86
86
 
87
87
  // ============================================================================
88
88
  // Configuration (imports from src/config/, inline wizard kept here)
@@ -436,7 +436,7 @@ function openInBrowser(url) {
436
436
 
437
437
  async function getRemoteWebUrl(branchName) {
438
438
  try {
439
- const { stdout } = await execAsync(`git remote get-url "${REMOTE_NAME}"`);
439
+ const { stdout } = await execGit(['remote', 'get-url', REMOTE_NAME], { cwd: PROJECT_ROOT });
440
440
  const parsed = parseRemoteUrl(stdout);
441
441
  return buildWebUrl(parsed, branchName);
442
442
  } catch (e) {
@@ -446,20 +446,21 @@ async function getRemoteWebUrl(branchName) {
446
446
 
447
447
  // Extract Claude Code session URL from the most recent commit on a branch
448
448
  async function getSessionUrl(branchName) {
449
- try {
450
- const { stdout } = await execAsync(
451
- `git log "${REMOTE_NAME}/${branchName}" -1 --format=%B 2>/dev/null || git log "${branchName}" -1 --format=%B 2>/dev/null`
452
- );
453
- return extractSessionUrl(stdout);
454
- } catch (e) {
455
- return null;
456
- }
449
+ // Try remote branch first, fall back to local
450
+ const result = await execGitSilent(
451
+ ['log', `${REMOTE_NAME}/${branchName}`, '-1', '--format=%B'],
452
+ { cwd: PROJECT_ROOT }
453
+ ) || await execGitSilent(
454
+ ['log', branchName, '-1', '--format=%B'],
455
+ { cwd: PROJECT_ROOT }
456
+ );
457
+ return result ? extractSessionUrl(result.stdout) : null;
457
458
  }
458
459
 
459
460
  // Check if a CLI tool is available
460
461
  async function hasCommand(cmd) {
461
462
  try {
462
- await execAsync(`which ${cmd} 2>/dev/null`);
463
+ await execCli('which', [cmd]);
463
464
  return true;
464
465
  } catch (e) {
465
466
  return false;
@@ -472,17 +473,18 @@ async function hasCommand(cmd) {
472
473
  async function getPrInfo(branchName, platform, hasGh, hasGlab) {
473
474
  if (platform === 'github' && hasGh) {
474
475
  try {
475
- const { stdout } = await execAsync(
476
- `gh pr list --head "${branchName}" --state all --json number,title,state,reviewDecision,statusCheckRollup --limit 1`
477
- );
476
+ const { stdout } = await execCli('gh', [
477
+ 'pr', 'list', '--head', branchName, '--state', 'all',
478
+ '--json', 'number,title,state,reviewDecision,statusCheckRollup', '--limit', '1',
479
+ ]);
478
480
  return parseGitHubPr(JSON.parse(stdout));
479
481
  } catch (e) { /* gh not authed or other error */ }
480
482
  }
481
483
  if (platform === 'gitlab' && hasGlab) {
482
484
  try {
483
- const { stdout } = await execAsync(
484
- `glab mr list --source-branch="${branchName}" --state all --output json 2>/dev/null`
485
- );
485
+ const { stdout } = await execCli('glab', [
486
+ 'mr', 'list', `--source-branch=${branchName}`, '--state', 'all', '--output', 'json',
487
+ ]);
486
488
  return parseGitLabMr(JSON.parse(stdout));
487
489
  } catch (e) { /* glab not authed or other error */ }
488
490
  }
@@ -492,11 +494,7 @@ async function getPrInfo(branchName, platform, hasGh, hasGlab) {
492
494
  // Check if gh/glab CLI is authenticated
493
495
  async function checkCliAuth(cmd) {
494
496
  try {
495
- if (cmd === 'gh') {
496
- await execAsync('gh auth status 2>&1');
497
- } else if (cmd === 'glab') {
498
- await execAsync('glab auth status 2>&1');
499
- }
497
+ await execCli(cmd, ['auth', 'status']);
500
498
  return true;
501
499
  } catch (e) {
502
500
  return false;
@@ -510,18 +508,19 @@ async function fetchAllPrStatuses() {
510
508
 
511
509
  if (platform === 'github' && hasGh && ghAuthed) {
512
510
  try {
513
- const { stdout } = await execAsync(
514
- 'gh pr list --state all --json headRefName,number,title,state --limit 200'
515
- );
511
+ const { stdout } = await execCli('gh', [
512
+ 'pr', 'list', '--state', 'all',
513
+ '--json', 'headRefName,number,title,state', '--limit', '200',
514
+ ]);
516
515
  return parseGitHubPrList(JSON.parse(stdout));
517
516
  } catch (e) { /* gh error */ }
518
517
  }
519
518
 
520
519
  if (platform === 'gitlab' && hasGlab && glabAuthed) {
521
520
  try {
522
- const { stdout } = await execAsync(
523
- 'glab mr list --state all --output json 2>/dev/null'
524
- );
521
+ const { stdout } = await execCli('glab', [
522
+ 'mr', 'list', '--state', 'all', '--output', 'json',
523
+ ]);
525
524
  return parseGitLabMrList(JSON.parse(stdout));
526
525
  } catch (e) { /* glab error */ }
527
526
  }
@@ -815,22 +814,30 @@ const LIVE_RELOAD_SCRIPT = `
815
814
  // Utility Functions
816
815
  // ============================================================================
817
816
 
818
- // Default timeout for execAsync (30 seconds) — prevents hung git/CLI commands
817
+ // Default timeout for CLI commands (30 seconds) — prevents hung commands
819
818
  // from permanently blocking the polling loop
820
- const EXEC_ASYNC_TIMEOUT = 30000;
819
+ const CLI_TIMEOUT = 30000;
821
820
 
822
- function execAsync(command, options = {}) {
823
- const { timeout = EXEC_ASYNC_TIMEOUT, ...restOptions } = options;
821
+ /**
822
+ * Execute a non-git CLI command safely using execFile (no shell interpolation).
823
+ * For git commands, use execGit/execGitSilent from src/git/commands.js instead.
824
+ * @param {string} cmd - The executable (e.g. 'gh', 'glab', 'which')
825
+ * @param {string[]} args - Arguments array (no shell interpolation)
826
+ * @param {Object} [options] - Execution options
827
+ * @param {number} [options.timeout] - Command timeout in ms
828
+ * @returns {Promise<{stdout: string, stderr: string}>}
829
+ */
830
+ function execCli(cmd, args = [], options = {}) {
831
+ const { timeout = CLI_TIMEOUT, ...restOptions } = options;
824
832
  return new Promise((resolve, reject) => {
825
- const child = exec(command, { cwd: PROJECT_ROOT, timeout, ...restOptions }, (error, stdout, stderr) => {
833
+ const child = execFile(cmd, args, { cwd: PROJECT_ROOT, timeout, ...restOptions }, (error, stdout, stderr) => {
826
834
  if (error) {
827
835
  reject({ error, stdout, stderr });
828
836
  } else {
829
837
  resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
830
838
  }
831
839
  });
832
- // Also kill the child if the timeout fires (exec timeout sends SIGTERM
833
- // but doesn't guarantee cleanup of process trees)
840
+ // Force-kill if process outlives timeout grace period
834
841
  if (timeout > 0) {
835
842
  const killTimer = setTimeout(() => {
836
843
  try { child.kill('SIGKILL'); } catch (e) { /* already dead */ }
@@ -841,18 +848,13 @@ function execAsync(command, options = {}) {
841
848
  }
842
849
 
843
850
  /**
844
- * Get diff stats between two commits
851
+ * Get diff stats between two commits (delegates to src/git/commands.js)
845
852
  * @param {string} fromCommit - Starting commit
846
853
  * @param {string} toCommit - Ending commit (default HEAD)
847
854
  * @returns {Promise<{added: number, deleted: number}>}
848
855
  */
849
856
  async function getDiffStats(fromCommit, toCommit = 'HEAD') {
850
- try {
851
- const { stdout } = await execAsync(`git diff --stat ${fromCommit}..${toCommit}`);
852
- return parseDiffStats(stdout);
853
- } catch (e) {
854
- return { added: 0, deleted: 0 };
855
- }
857
+ return getDiffStatsSafe(fromCommit, toCommit, { cwd: PROJECT_ROOT });
856
858
  }
857
859
 
858
860
  // formatTimeAgo imported from src/utils/time.js
@@ -945,10 +947,15 @@ async function refreshAllSparklines() {
945
947
  for (const branch of currentBranches.slice(0, 20)) { // Limit to top 20
946
948
  if (branch.isDeleted) continue;
947
949
 
948
- // Get commit counts for last 7 days
949
- const { stdout } = await execAsync(
950
- `git log origin/${branch.name} --since="7 days ago" --format="%ad" --date=format:"%Y-%m-%d" 2>/dev/null || git log ${branch.name} --since="7 days ago" --format="%ad" --date=format:"%Y-%m-%d" 2>/dev/null`
951
- ).catch(() => ({ stdout: '' }));
950
+ // Get commit counts for last 7 days (try remote, fall back to local)
951
+ const sparkResult = await execGitSilent(
952
+ ['log', `origin/${branch.name}`, '--since=7 days ago', '--format=%ad', '--date=format:%Y-%m-%d'],
953
+ { cwd: PROJECT_ROOT }
954
+ ) || await execGitSilent(
955
+ ['log', branch.name, '--since=7 days ago', '--format=%ad', '--date=format:%Y-%m-%d'],
956
+ { cwd: PROJECT_ROOT }
957
+ );
958
+ const stdout = sparkResult ? sparkResult.stdout : '';
952
959
 
953
960
  // Count commits per day
954
961
  const dayCounts = new Map();
@@ -978,10 +985,15 @@ async function refreshAllSparklines() {
978
985
 
979
986
  async function getPreviewData(branchName) {
980
987
  try {
981
- // Get last 5 commits
982
- const { stdout: logOutput } = await execAsync(
983
- `git log origin/${branchName} -5 --oneline 2>/dev/null || git log ${branchName} -5 --oneline 2>/dev/null`
984
- ).catch(() => ({ stdout: '' }));
988
+ // Get last 5 commits (try remote, fall back to local)
989
+ const logResult = await execGitSilent(
990
+ ['log', `origin/${branchName}`, '-5', '--oneline'],
991
+ { cwd: PROJECT_ROOT }
992
+ ) || await execGitSilent(
993
+ ['log', branchName, '-5', '--oneline'],
994
+ { cwd: PROJECT_ROOT }
995
+ );
996
+ const logOutput = logResult ? logResult.stdout : '';
985
997
 
986
998
  const commits = logOutput.split('\n').filter(Boolean).map(line => {
987
999
  const [hash, ...msgParts] = line.split(' ');
@@ -990,13 +1002,15 @@ async function getPreviewData(branchName) {
990
1002
 
991
1003
  // Get files changed (comparing to current branch)
992
1004
  let filesChanged = [];
993
- try {
994
- const { stdout: diffOutput } = await execAsync(
995
- `git diff --stat --name-only HEAD...origin/${branchName} 2>/dev/null || git diff --stat --name-only HEAD...${branchName} 2>/dev/null`
996
- );
997
- filesChanged = diffOutput.split('\n').filter(Boolean).slice(0, 8);
998
- } catch (e) {
999
- // No diff available
1005
+ const diffResult = await execGitSilent(
1006
+ ['diff', '--stat', '--name-only', `HEAD...origin/${branchName}`],
1007
+ { cwd: PROJECT_ROOT }
1008
+ ) || await execGitSilent(
1009
+ ['diff', '--stat', '--name-only', `HEAD...${branchName}`],
1010
+ { cwd: PROJECT_ROOT }
1011
+ );
1012
+ if (diffResult) {
1013
+ filesChanged = diffResult.stdout.split('\n').filter(Boolean).slice(0, 8);
1000
1014
  }
1001
1015
 
1002
1016
  return { commits, filesChanged };
@@ -1312,12 +1326,12 @@ function hideStashConfirm() {
1312
1326
 
1313
1327
  async function getCurrentBranch() {
1314
1328
  try {
1315
- const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD');
1329
+ const { stdout } = await execGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: PROJECT_ROOT });
1316
1330
  // Check for detached HEAD state
1317
1331
  if (stdout === 'HEAD') {
1318
1332
  store.setState({ isDetachedHead: true });
1319
1333
  // Get the short commit hash instead
1320
- const { stdout: commitHash } = await execAsync('git rev-parse --short HEAD');
1334
+ const { stdout: commitHash } = await execGit(['rev-parse', '--short', 'HEAD'], { cwd: PROJECT_ROOT });
1321
1335
  return `HEAD@${commitHash}`;
1322
1336
  }
1323
1337
  store.setState({ isDetachedHead: false });
@@ -1329,7 +1343,7 @@ async function getCurrentBranch() {
1329
1343
 
1330
1344
  async function checkRemoteExists() {
1331
1345
  try {
1332
- const { stdout } = await execAsync('git remote');
1346
+ const { stdout } = await execGit(['remote'], { cwd: PROJECT_ROOT });
1333
1347
  const remotes = stdout.split('\n').filter(Boolean);
1334
1348
  return remotes.length > 0;
1335
1349
  } catch (e) {
@@ -1339,7 +1353,7 @@ async function checkRemoteExists() {
1339
1353
 
1340
1354
  async function hasUncommittedChanges() {
1341
1355
  try {
1342
- const { stdout } = await execAsync('git status --porcelain');
1356
+ const { stdout } = await execGit(['status', '--porcelain'], { cwd: PROJECT_ROOT, timeout: 5000 });
1343
1357
  return stdout.length > 0;
1344
1358
  } catch (e) {
1345
1359
  return false;
@@ -1350,14 +1364,15 @@ async function hasUncommittedChanges() {
1350
1364
 
1351
1365
  async function getAllBranches() {
1352
1366
  try {
1353
- await execAsync('git fetch --all --prune 2>/dev/null').catch(() => {});
1367
+ await execGitSilent(['fetch', '--all', '--prune'], { cwd: PROJECT_ROOT, timeout: 60000 });
1354
1368
 
1355
1369
  const branchList = [];
1356
1370
  const seenBranches = new Set();
1357
1371
 
1358
1372
  // Get local branches
1359
- const { stdout: localOutput } = await execAsync(
1360
- 'git for-each-ref --sort=-committerdate --format="%(refname:short)|%(committerdate:iso8601)|%(objectname:short)|%(subject)" refs/heads/'
1373
+ const { stdout: localOutput } = await execGit(
1374
+ ['for-each-ref', '--sort=-committerdate', '--format=%(refname:short)|%(committerdate:iso8601)|%(objectname:short)|%(subject)', 'refs/heads/'],
1375
+ { cwd: PROJECT_ROOT }
1361
1376
  );
1362
1377
 
1363
1378
  for (const line of localOutput.split('\n').filter(Boolean)) {
@@ -1377,9 +1392,11 @@ async function getAllBranches() {
1377
1392
  }
1378
1393
 
1379
1394
  // Get remote branches (using configured remote name)
1380
- const { stdout: remoteOutput } = await execAsync(
1381
- `git for-each-ref --sort=-committerdate --format="%(refname:short)|%(committerdate:iso8601)|%(objectname:short)|%(subject)" refs/remotes/${REMOTE_NAME}/`
1382
- ).catch(() => ({ stdout: '' }));
1395
+ const remoteResult = await execGitSilent(
1396
+ ['for-each-ref', '--sort=-committerdate', '--format=%(refname:short)|%(committerdate:iso8601)|%(objectname:short)|%(subject)', `refs/remotes/${REMOTE_NAME}/`],
1397
+ { cwd: PROJECT_ROOT }
1398
+ );
1399
+ const remoteOutput = remoteResult ? remoteResult.stdout : '';
1383
1400
 
1384
1401
  const remotePrefix = `${REMOTE_NAME}/`;
1385
1402
  for (const line of remoteOutput.split('\n').filter(Boolean)) {
@@ -1442,13 +1459,14 @@ async function switchToBranch(branchName, recordHistory = true) {
1442
1459
  addLog(`Switching to ${safeBranchName}...`, 'update');
1443
1460
  render();
1444
1461
 
1445
- const { stdout: localBranches } = await execAsync('git branch --list');
1446
- const hasLocal = localBranches.split('\n').some(b => b.trim().replace('* ', '') === safeBranchName);
1462
+ const { stdout: localBranches } = await execGit(['branch', '--list'], { cwd: PROJECT_ROOT });
1463
+ const hasLocal = localBranches.split('\n').some(b => b.trim().replace(/^\* /, '') === safeBranchName);
1447
1464
 
1448
1465
  if (hasLocal) {
1449
- await execAsync(`git checkout -- . 2>/dev/null; git checkout "${safeBranchName}"`);
1466
+ await execGitSilent(['checkout', '--', '.'], { cwd: PROJECT_ROOT });
1467
+ await execGit(['checkout', safeBranchName], { cwd: PROJECT_ROOT });
1450
1468
  } else {
1451
- await execAsync(`git checkout -b "${safeBranchName}" "${REMOTE_NAME}/${safeBranchName}"`);
1469
+ await execGit(['checkout', '-b', safeBranchName, `${REMOTE_NAME}/${safeBranchName}`], { cwd: PROJECT_ROOT });
1452
1470
  }
1453
1471
 
1454
1472
  store.setState({ currentBranch: safeBranchName, isDetachedHead: false });
@@ -1540,7 +1558,7 @@ async function pullCurrentBranch() {
1540
1558
  addLog(`Pulling from ${REMOTE_NAME}/${branch}...`, 'update');
1541
1559
  render();
1542
1560
 
1543
- await execAsync(`git pull "${REMOTE_NAME}" "${branch}"`);
1561
+ await execGit(['pull', REMOTE_NAME, branch], { cwd: PROJECT_ROOT, timeout: 60000 });
1544
1562
  addLog('Pulled successfully', 'success');
1545
1563
  pendingDirtyOperation = null;
1546
1564
  notifyClients();
@@ -1867,12 +1885,12 @@ async function pollGitChanges() {
1867
1885
  const oldCommit = currentInfo.commit;
1868
1886
 
1869
1887
  try {
1870
- await execAsync(`git pull "${REMOTE_NAME}" "${autoPullBranchName}"`);
1888
+ await execGit(['pull', REMOTE_NAME, autoPullBranchName], { cwd: PROJECT_ROOT, timeout: 60000 });
1871
1889
  addLog(`Pulled successfully from ${autoPullBranchName}`, 'success');
1872
1890
  currentInfo.hasUpdates = false;
1873
1891
  store.setState({ hasMergeConflict: false });
1874
1892
  // Update the stored commit to the new one
1875
- const newCommit = await execAsync('git rev-parse --short HEAD');
1893
+ const newCommit = await execGit(['rev-parse', '--short', 'HEAD'], { cwd: PROJECT_ROOT });
1876
1894
  currentInfo.commit = newCommit.stdout.trim();
1877
1895
  previousBranchStates.set(autoPullBranchName, newCommit.stdout.trim());
1878
1896
  // Reload browsers
@@ -2273,9 +2291,9 @@ function setupKeyboardInput() {
2273
2291
  try {
2274
2292
  let result;
2275
2293
  if (platform === 'gitlab') {
2276
- result = await execAsync(`glab mr create --source-branch="${aBranch.name}" --fill --yes 2>&1`);
2294
+ result = await execCli('glab', ['mr', 'create', `--source-branch=${aBranch.name}`, '--fill', '--yes']);
2277
2295
  } else {
2278
- result = await execAsync(`gh pr create --head "${aBranch.name}" --fill 2>&1`);
2296
+ result = await execCli('gh', ['pr', 'create', '--head', aBranch.name, '--fill']);
2279
2297
  }
2280
2298
  addLog(`${prLabel} created: ${(result.stdout || '').trim().split('\n').pop()}`, 'success');
2281
2299
  // Invalidate cache and refresh modal data
@@ -2314,9 +2332,9 @@ function setupKeyboardInput() {
2314
2332
  render();
2315
2333
  try {
2316
2334
  if (platform === 'gitlab') {
2317
- await execAsync(`glab mr approve ${prInfo.number} 2>&1`);
2335
+ await execCli('glab', ['mr', 'approve', String(prInfo.number)]);
2318
2336
  } else {
2319
- await execAsync(`gh pr review ${prInfo.number} --approve 2>&1`);
2337
+ await execCli('gh', ['pr', 'review', String(prInfo.number), '--approve']);
2320
2338
  }
2321
2339
  addLog(`${prLabel} #${prInfo.number} approved`, 'success');
2322
2340
  // Refresh PR info to show updated status
@@ -2334,9 +2352,9 @@ function setupKeyboardInput() {
2334
2352
  render();
2335
2353
  try {
2336
2354
  if (platform === 'gitlab') {
2337
- await execAsync(`glab mr merge ${prInfo.number} --squash --remove-source-branch --yes 2>&1`);
2355
+ await execCli('glab', ['mr', 'merge', String(prInfo.number), '--squash', '--remove-source-branch', '--yes']);
2338
2356
  } else {
2339
- await execAsync(`gh pr merge ${prInfo.number} --squash --delete-branch 2>&1`);
2357
+ await execCli('gh', ['pr', 'merge', String(prInfo.number), '--squash', '--delete-branch']);
2340
2358
  }
2341
2359
  addLog(`${prLabel} #${prInfo.number} merged`, 'success');
2342
2360
  store.setState({ actionMode: false, actionData: null, actionLoading: false });
@@ -2356,13 +2374,13 @@ function setupKeyboardInput() {
2356
2374
  render();
2357
2375
  try {
2358
2376
  if (platform === 'gitlab') {
2359
- const result = await execAsync(`glab ci status --branch "${aBranch.name}" 2>&1`);
2377
+ const result = await execCli('glab', ['ci', 'status', '--branch', aBranch.name]);
2360
2378
  const lines = (result.stdout || '').trim().split('\n');
2361
2379
  for (const line of lines.slice(0, 3)) {
2362
2380
  addLog(line.trim(), 'info');
2363
2381
  }
2364
2382
  } else if (prInfo) {
2365
- const result = await execAsync(`gh pr checks ${prInfo.number} 2>&1`);
2383
+ const result = await execCli('gh', ['pr', 'checks', String(prInfo.number)]);
2366
2384
  const lines = (result.stdout || '').trim().split('\n');
2367
2385
  for (const line of lines.slice(0, 5)) {
2368
2386
  addLog(line.trim(), 'info');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "1.8.2",
3
+ "version": "1.8.4",
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": {
@@ -5,7 +5,7 @@
5
5
  * Uses system audio tools when available, falls back gracefully.
6
6
  */
7
7
 
8
- const { exec } = require('child_process');
8
+ const { execFile } = require('child_process');
9
9
  const path = require('path');
10
10
  const fs = require('fs');
11
11
 
@@ -56,20 +56,20 @@ function playFile(soundPath, volume = 0.5) {
56
56
 
57
57
  try {
58
58
  if (platform === 'darwin') {
59
- // macOS: afplay with volume
60
- exec(`afplay -v ${volume} "${soundPath}" 2>/dev/null &`);
59
+ // macOS: afplay with volume — args passed as array (no shell)
60
+ execFile('afplay', ['-v', String(volume), soundPath], () => {});
61
61
  } else if (platform === 'linux') {
62
- // Linux: paplay (PulseAudio) or aplay (ALSA)
63
- // Note: paplay doesn't support volume easily, aplay does via amixer
64
- exec(
65
- `paplay "${soundPath}" 2>/dev/null || aplay -q "${soundPath}" 2>/dev/null &`
66
- );
62
+ // Linux: paplay (PulseAudio), fall back to aplay (ALSA)
63
+ execFile('paplay', [soundPath], (err) => {
64
+ if (err) {
65
+ execFile('aplay', ['-q', soundPath], () => {});
66
+ }
67
+ });
67
68
  } else if (platform === 'win32') {
68
- // Windows: Use PowerShell to play sound
69
- exec(
70
- `powershell -c "(New-Object Media.SoundPlayer '${soundPath}').PlaySync()" 2>nul`,
71
- { windowsHide: true }
72
- );
69
+ // Windows: Use PowerShell to play sound — path passed as argument
70
+ execFile('powershell', [
71
+ '-c', `(New-Object Media.SoundPlayer $args[0]).PlaySync()`, '-args', soundPath,
72
+ ], { windowsHide: true }, () => {});
73
73
  }
74
74
  } catch (e) {
75
75
  // Silently fail - sounds are optional