git-watchtower 2.2.2 → 2.2.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "2.2.2",
3
+ "version": "2.2.3",
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": {
package/src/cli/args.js CHANGED
@@ -169,6 +169,19 @@ function parseArgs(argv, options = {}) {
169
169
  result.errors.push(`Unknown option: ${args[i]}`);
170
170
  }
171
171
  }
172
+
173
+ // Cross-validation: if both --port and --web-port are explicit on the
174
+ // CLI, they must differ. The web dashboard's EADDRINUSE-retry loop
175
+ // would silently bump the web port to the next free slot, hiding the
176
+ // misconfiguration — and the user thinks they're hitting :4000 when
177
+ // it's actually :4001. Surface the conflict at parse time instead.
178
+ if (result.port !== null && result.webPort !== null && result.port === result.webPort) {
179
+ result.errors.push(
180
+ `--port and --web-port cannot share the same value (${result.port}). ` +
181
+ `Pick a different value for one of them.`,
182
+ );
183
+ }
184
+
172
185
  return result;
173
186
  }
174
187
 
package/src/git/branch.js CHANGED
@@ -198,81 +198,6 @@ async function getAllBranches(options = {}) {
198
198
  }
199
199
  }
200
200
 
201
- /**
202
- * Detect changes between two branch lists
203
- * @param {Branch[]} oldBranches - Previous branch list
204
- * @param {Branch[]} newBranches - Current branch list
205
- * @returns {{added: Branch[], removed: Branch[], updated: Branch[]}}
206
- */
207
- function detectBranchChanges(oldBranches, newBranches) {
208
- const oldNames = new Map(oldBranches.map((b) => [b.name, b]));
209
- const newNames = new Map(newBranches.map((b) => [b.name, b]));
210
-
211
- const added = newBranches.filter((b) => !oldNames.has(b.name));
212
- const removed = oldBranches.filter((b) => !newNames.has(b.name));
213
- const updated = newBranches.filter((b) => {
214
- const old = oldNames.get(b.name);
215
- return old && old.commit !== b.commit;
216
- });
217
-
218
- return { added, removed, updated };
219
- }
220
-
221
- /**
222
- * Check out a branch
223
- * @param {string} branchName - Branch to check out
224
- * @param {Object} [options] - Options
225
- * @param {string} [options.remoteName='origin'] - Remote name
226
- * @param {boolean} [options.force=false] - Force checkout (discard changes)
227
- * @param {string} [options.cwd] - Working directory
228
- * @returns {Promise<{success: boolean, error?: GitError}>}
229
- */
230
- async function checkout(branchName, options = {}) {
231
- const { remoteName = 'origin', force = false, cwd } = options;
232
-
233
- try {
234
- // Validate branch name
235
- const safeName = sanitizeBranchName(branchName);
236
-
237
- // Check for uncommitted changes (unless force)
238
- if (!force && (await hasUncommittedChanges(cwd))) {
239
- return {
240
- success: false,
241
- error: new GitError(
242
- 'Cannot switch: uncommitted changes in working directory',
243
- 'GIT_DIRTY_WORKDIR'
244
- ),
245
- };
246
- }
247
-
248
- // Check if local branch exists
249
- const { stdout: localBranches } = await execGit(['branch', '--list'], { cwd });
250
- const hasLocal = localBranches
251
- .split('\n')
252
- .some((b) => b.trim().replace(/^\* /, '') === safeName);
253
-
254
- if (hasLocal) {
255
- // Local branch exists - just check out
256
- if (force) {
257
- await execGit(['checkout', '--', '.'], { cwd });
258
- await execGit(['checkout', safeName], { cwd });
259
- } else {
260
- await execGit(['checkout', safeName], { cwd });
261
- }
262
- } else {
263
- // Create local branch from remote
264
- await execGit(['checkout', '-b', safeName, `${remoteName}/${safeName}`], { cwd });
265
- }
266
-
267
- return { success: true };
268
- } catch (error) {
269
- return {
270
- success: false,
271
- error: error instanceof GitError ? error : GitError.fromExecError(error, 'checkout'),
272
- };
273
- }
274
- }
275
-
276
201
  /**
277
202
  * Get preview data for a branch
278
203
  * @param {string} branchName - Branch name
@@ -449,8 +374,6 @@ module.exports = {
449
374
  sanitizeBranchName,
450
375
  getCurrentBranch,
451
376
  getAllBranches,
452
- detectBranchChanges,
453
- checkout,
454
377
  getPreviewData,
455
378
  generateSparkline,
456
379
  getLocalBranches,
package/src/index.js CHANGED
@@ -123,8 +123,6 @@ module.exports = {
123
123
  sanitizeBranchName: gitBranch.sanitizeBranchName,
124
124
  getCurrentBranch: gitBranch.getCurrentBranch,
125
125
  getAllBranches: gitBranch.getAllBranches,
126
- detectBranchChanges: gitBranch.detectBranchChanges,
127
- checkout: gitBranch.checkout,
128
126
  getPreviewData: gitBranch.getPreviewData,
129
127
  generateSparkline: gitBranch.generateSparkline,
130
128
  getLocalBranches: gitBranch.getLocalBranches,
@@ -153,7 +151,6 @@ module.exports = {
153
151
  deleteConfig: configLoader.deleteConfig,
154
152
 
155
153
  // Server process management
156
- ProcessManager: serverProcess.ProcessManager,
157
154
  parseCommand: serverProcess.parseCommand,
158
155
 
159
156
  // Web dashboard server
@@ -1,154 +1,14 @@
1
1
  /**
2
- * Polling engine — pure logic for branch change detection and sorting
2
+ * Polling engine — pure logic for branch tracking-set maintenance.
3
+ *
4
+ * Note: this module previously also exported helpers like detectNewBranches,
5
+ * detectDeletedBranches, sortBranches, calculateAdaptiveInterval, etc., but
6
+ * the bin reimplements those flows inline. Only pruneStaleEntries is wired
7
+ * up in production, so that's all that lives here now.
8
+ *
3
9
  * @module polling/engine
4
10
  */
5
11
 
6
- const { isBaseBranch } = require('../git/pr');
7
-
8
- /**
9
- * Detect new branches not previously known.
10
- * @param {Array<{name: string, isNew?: boolean, newAt?: number}>} allBranches - All currently fetched branches
11
- * @param {Set<string>} knownBranchNames - Previously known branch names
12
- * @returns {Array<{name: string, isNew?: boolean, newAt?: number}>} Branches with isNew flag
13
- */
14
- function detectNewBranches(allBranches, knownBranchNames) {
15
- const now = Date.now();
16
- const newBranches = [];
17
- for (const branch of allBranches) {
18
- if (!knownBranchNames.has(branch.name)) {
19
- branch.isNew = true;
20
- branch.newAt = now;
21
- newBranches.push(branch);
22
- }
23
- }
24
- return newBranches;
25
- }
26
-
27
- /**
28
- * Detect deleted branches (were known but no longer exist in fetched set).
29
- * @param {Set<string>} knownBranchNames - Previously known branch names
30
- * @param {Set<string>} fetchedBranchNames - Currently fetched branch names
31
- * @param {Array<{name: string, isDeleted?: boolean, deletedAt?: number}>} existingBranches - Previous branch list
32
- * @returns {Array<{name: string, isDeleted?: boolean, deletedAt?: number}>} Deleted branches
33
- */
34
- function detectDeletedBranches(knownBranchNames, fetchedBranchNames, existingBranches) {
35
- const now = Date.now();
36
- const deleted = [];
37
- for (const knownName of knownBranchNames) {
38
- if (!fetchedBranchNames.has(knownName)) {
39
- const existing = existingBranches.find(b => b.name === knownName);
40
- if (existing && !existing.isDeleted) {
41
- existing.isDeleted = true;
42
- existing.deletedAt = now;
43
- deleted.push(existing);
44
- }
45
- }
46
- }
47
- return deleted;
48
- }
49
-
50
- /**
51
- * Detect branches that have been updated (commit changed) since last poll.
52
- * @param {Array<{name: string, commit: string, isDeleted?: boolean, justUpdated?: boolean}>} branches - Current branch list
53
- * @param {Map<string, string>} previousStates - Map of branch name -> previous commit hash
54
- * @param {string} currentBranch - Name of current branch (excluded from updates)
55
- * @returns {Array<{name: string, commit: string, isDeleted?: boolean, justUpdated?: boolean}>} Updated branches
56
- */
57
- function detectUpdatedBranches(branches, previousStates, currentBranch) {
58
- const updated = [];
59
- for (const branch of branches) {
60
- // Clear previous cycle's flag so only freshly-updated branches are highlighted
61
- branch.justUpdated = false;
62
- if (branch.isDeleted) continue;
63
- const prevCommit = previousStates.get(branch.name);
64
- if (prevCommit && prevCommit !== branch.commit && branch.name !== currentBranch) {
65
- branch.justUpdated = true;
66
- updated.push(branch);
67
- }
68
- }
69
- return updated;
70
- }
71
-
72
- /**
73
- * Sort branches: new first, then by date, merged near bottom, deleted at bottom.
74
- * @param {Array} branches - Branch list to sort
75
- * @param {Map} prStatusMap - Map of branch name -> PR status
76
- * @returns {Array} Sorted branches (mutates and returns input)
77
- */
78
- function sortBranches(branches, prStatusMap) {
79
- return branches.sort((a, b) => {
80
- const aIsBase = isBaseBranch(a.name);
81
- const bIsBase = isBaseBranch(b.name);
82
- const aMerged = !aIsBase && prStatusMap.has(a.name) && prStatusMap.get(a.name).state === 'MERGED';
83
- const bMerged = !bIsBase && prStatusMap.has(b.name) && prStatusMap.get(b.name).state === 'MERGED';
84
- if (a.isDeleted && !b.isDeleted) return 1;
85
- if (!a.isDeleted && b.isDeleted) return -1;
86
- if (aMerged && !bMerged && !b.isDeleted) return 1;
87
- if (!aMerged && bMerged && !a.isDeleted) return -1;
88
- if (a.isNew && !b.isNew) return -1;
89
- if (!a.isNew && b.isNew) return 1;
90
- return (b.date || 0) - (a.date || 0);
91
- });
92
- }
93
-
94
- /**
95
- * Calculate adaptive polling interval based on fetch duration.
96
- * @param {number} fetchDuration - How long the fetch took (ms)
97
- * @param {number} currentInterval - Current polling interval (ms)
98
- * @param {number} baseInterval - Base/default polling interval (ms)
99
- * @returns {{ interval: number, warning: string|null }}
100
- */
101
- function calculateAdaptiveInterval(fetchDuration, currentInterval, baseInterval) {
102
- if (fetchDuration > 30000) {
103
- return {
104
- interval: Math.min(currentInterval * 2, 60000),
105
- warning: 'very_slow',
106
- };
107
- }
108
- if (fetchDuration > 15000) {
109
- return {
110
- interval: currentInterval,
111
- warning: 'slow',
112
- };
113
- }
114
- if (fetchDuration < 5000 && currentInterval > baseInterval) {
115
- return {
116
- interval: baseInterval,
117
- warning: 'restored',
118
- };
119
- }
120
- return {
121
- interval: currentInterval,
122
- warning: null,
123
- };
124
- }
125
-
126
- /**
127
- * Restore selection index after branch list reorder.
128
- * @param {Array<{name: string}>} branches - New branch list
129
- * @param {string|null} previousName - Previously selected branch name
130
- * @param {number} previousIndex - Previously selected index
131
- * @returns {{ selectedIndex: number, selectedBranchName: string|null }}
132
- */
133
- function restoreSelection(branches, previousName, previousIndex) {
134
- if (previousName) {
135
- const newIndex = branches.findIndex(b => b.name === previousName);
136
- if (newIndex >= 0) {
137
- return { selectedIndex: newIndex, selectedBranchName: previousName };
138
- }
139
- const clampedIndex = Math.min(previousIndex, Math.max(0, branches.length - 1));
140
- return {
141
- selectedIndex: clampedIndex,
142
- selectedBranchName: branches[clampedIndex] ? branches[clampedIndex].name : null,
143
- };
144
- }
145
- if (previousIndex >= branches.length) {
146
- const idx = Math.max(0, branches.length - 1);
147
- return { selectedIndex: idx, selectedBranchName: branches[idx] ? branches[idx].name : null };
148
- }
149
- return { selectedIndex: previousIndex, selectedBranchName: previousName };
150
- }
151
-
152
12
  /**
153
13
  * Prune stale entries from tracking sets and caches for branches
154
14
  * that no longer exist in git.
@@ -182,11 +42,5 @@ function pruneStaleEntries({ knownBranchNames, fetchedBranchNames, allBranches,
182
42
  }
183
43
 
184
44
  module.exports = {
185
- detectNewBranches,
186
- detectDeletedBranches,
187
- detectUpdatedBranches,
188
- sortBranches,
189
- calculateAdaptiveInterval,
190
- restoreSelection,
191
45
  pruneStaleEntries,
192
46
  };
@@ -1,34 +1,10 @@
1
1
  /**
2
- * Server process management for command mode
3
- * Manages spawning, stopping, and monitoring of user's dev server
4
- */
5
-
6
- const { spawn } = require('child_process');
7
- const { ServerError } = require('../utils/errors');
8
- const { Mutex } = require('../utils/async');
9
-
10
- /**
11
- * @typedef {Object} ServerProcessState
12
- * @property {import('child_process').ChildProcess|null} process - The server process
13
- * @property {boolean} running - Is the server running
14
- * @property {boolean} crashed - Did the server crash
15
- * @property {Array<{timestamp: string, line: string, isError: boolean}>} logs - Server logs
16
- */
17
-
18
- /**
19
- * Maximum log lines to keep in buffer
20
- */
21
- const MAX_LOG_LINES = 500;
22
-
23
- /**
24
- * Grace period before force kill (ms)
25
- */
26
- const KILL_GRACE_PERIOD = 3000;
27
-
28
- /**
29
- * Restart delay after stop (ms)
2
+ * Server process management — command parsing for command mode
3
+ *
4
+ * Note: the bin's `startServerProcess` / `stopServerProcess` /
5
+ * `restartServerProcess` implement the actual lifecycle inline; this
6
+ * file only exports the parseCommand helper they use.
30
7
  */
31
- const RESTART_DELAY = 500;
32
8
 
33
9
  /**
34
10
  * Parse a command string into command and arguments.
@@ -96,333 +72,6 @@ function parseCommand(commandString) {
96
72
  };
97
73
  }
98
74
 
99
- /**
100
- * Server process manager
101
- */
102
- class ProcessManager {
103
- /**
104
- * @param {Object} [options]
105
- * @param {string} [options.cwd] - Working directory
106
- * @param {Function} [options.onLog] - Log callback (line, isError)
107
- * @param {Function} [options.onStateChange] - State change callback (state)
108
- */
109
- constructor(options = {}) {
110
- this.cwd = options.cwd || process.cwd();
111
- this.onLog = options.onLog || (() => {});
112
- this.onStateChange = options.onStateChange || (() => {});
113
-
114
- this.process = null;
115
- this.running = false;
116
- this.crashed = false;
117
- this.logs = [];
118
- this.command = '';
119
- this._restartMutex = new Mutex();
120
- }
121
-
122
- /**
123
- * Get current state
124
- * @returns {ServerProcessState}
125
- */
126
- getState() {
127
- return {
128
- process: this.process,
129
- running: this.running,
130
- crashed: this.crashed,
131
- logs: [...this.logs],
132
- };
133
- }
134
-
135
- /**
136
- * Add a log entry
137
- * @param {string} line - Log line
138
- * @param {boolean} [isError=false] - Is error output
139
- */
140
- addLog(line, isError = false) {
141
- const entry = {
142
- timestamp: new Date().toLocaleTimeString(),
143
- line,
144
- isError,
145
- };
146
- this.logs.push(entry);
147
- if (this.logs.length > MAX_LOG_LINES) {
148
- this.logs.shift();
149
- }
150
- this.onLog(line, isError);
151
- }
152
-
153
- /**
154
- * Clear log buffer
155
- */
156
- clearLogs() {
157
- this.logs = [];
158
- }
159
-
160
- /**
161
- * Start the server process
162
- * @param {string} commandString - Command to run
163
- * @returns {{success: boolean, error?: Error, pid?: number}}
164
- */
165
- start(commandString) {
166
- if (!commandString) {
167
- return {
168
- success: false,
169
- error: ServerError.startFailed(commandString, 'No command specified'),
170
- };
171
- }
172
-
173
- // Stop existing process first
174
- if (this.process) {
175
- this.stop();
176
- }
177
-
178
- this.clearLogs();
179
- this.crashed = false;
180
- this.running = false;
181
- this.command = commandString;
182
-
183
- this.addLog(`$ ${commandString}`);
184
-
185
- // Parse command
186
- const { command, args } = parseCommand(commandString);
187
-
188
- if (!command) {
189
- const error = ServerError.startFailed(commandString, 'Invalid command');
190
- this.crashed = true;
191
- this.addLog(`Failed to start: Invalid command`, true);
192
- this.notifyStateChange();
193
- return { success: false, error };
194
- }
195
-
196
- // Spawn options
197
- const isWindows = process.platform === 'win32';
198
- /** @type {import('child_process').SpawnOptions} */
199
- const spawnOptions = {
200
- cwd: this.cwd,
201
- env: { ...process.env, FORCE_COLOR: '1' },
202
- shell: isWindows,
203
- stdio: ['ignore', 'pipe', 'pipe'],
204
- // On Unix, create a new process group so we can kill the entire tree
205
- // (e.g. npm -> node -> next). On Windows, taskkill /t handles this.
206
- detached: !isWindows,
207
- };
208
-
209
- try {
210
- this.process = spawn(command, args, spawnOptions);
211
- this.running = true;
212
-
213
- // Handle stdout
214
- this.process.stdout?.on('data', (data) => {
215
- const lines = data.toString().split('\n').filter(Boolean);
216
- lines.forEach((line) => this.addLog(line));
217
- });
218
-
219
- // Handle stderr
220
- this.process.stderr?.on('data', (data) => {
221
- const lines = data.toString().split('\n').filter(Boolean);
222
- lines.forEach((line) => this.addLog(line, true));
223
- });
224
-
225
- // Handle error
226
- this.process.on('error', (err) => {
227
- this.running = false;
228
- this.crashed = true;
229
- this.addLog(`Error: ${err.message}`, true);
230
- this.notifyStateChange();
231
- });
232
-
233
- // Handle close
234
- this.process.on('close', (code) => {
235
- this.running = false;
236
- if (code !== 0 && code !== null) {
237
- this.crashed = true;
238
- this.addLog(`Process exited with code ${code}`, true);
239
- } else {
240
- this.addLog('Process stopped');
241
- }
242
- this.process = null;
243
- this.notifyStateChange();
244
- });
245
-
246
- this.notifyStateChange();
247
- return { success: true, pid: this.process.pid };
248
- } catch (err) {
249
- this.crashed = true;
250
- this.addLog(`Failed to start: ${err.message}`, true);
251
- this.notifyStateChange();
252
- return {
253
- success: false,
254
- error: ServerError.startFailed(commandString, err.message),
255
- };
256
- }
257
- }
258
-
259
- /**
260
- * Stop the server process
261
- * @returns {boolean} - True if a process was stopped
262
- */
263
- stop() {
264
- if (!this.process) {
265
- return false;
266
- }
267
-
268
- // Capture reference before nulling — needed for deferred force-kill
269
- const proc = this.process;
270
-
271
- if (process.platform === 'win32') {
272
- this._stopWindows(proc);
273
- } else {
274
- this._stopUnix(proc);
275
- }
276
-
277
- this.process = null;
278
- this.running = false;
279
- this.notifyStateChange();
280
- return true;
281
- }
282
-
283
- /**
284
- * Unix stop: SIGTERM the process group, then SIGKILL after a grace period.
285
- * The grace timer is unref'd so it doesn't keep the event loop alive when
286
- * the main process wants to exit.
287
- * @param {import('child_process').ChildProcess} proc
288
- * @private
289
- */
290
- _stopUnix(proc) {
291
- // If the process has already exited, there's nothing to signal.
292
- if (proc.exitCode !== null || proc.signalCode !== null) return;
293
-
294
- try {
295
- process.kill(-proc.pid, 'SIGTERM');
296
- } catch (e) {
297
- // Process group may already be dead
298
- return;
299
- }
300
-
301
- const forceKillTimeout = setTimeout(() => {
302
- // Re-check: process may have exited during the grace period.
303
- if (proc.exitCode !== null || proc.signalCode !== null) return;
304
- try {
305
- process.kill(-proc.pid, 'SIGKILL');
306
- } catch (e) {
307
- // Process group may already be dead
308
- }
309
- }, KILL_GRACE_PERIOD);
310
-
311
- // Don't let this timer keep the event loop alive on shutdown.
312
- forceKillTimeout.unref();
313
-
314
- // Clear early if the process exits before the grace period.
315
- proc.once('close', () => {
316
- clearTimeout(forceKillTimeout);
317
- });
318
- }
319
-
320
- /**
321
- * Windows stop: taskkill /t (tree kill). If the process doesn't exit
322
- * within the grace period, retry with /f (force).
323
- * @param {import('child_process').ChildProcess} proc
324
- * @private
325
- */
326
- _stopWindows(proc) {
327
- if (proc.exitCode !== null || proc.signalCode !== null) return;
328
-
329
- try {
330
- spawn('taskkill', ['/pid', proc.pid.toString(), '/t']);
331
- } catch (e) {
332
- // Ignore spawn errors (PID already gone, etc.)
333
- return;
334
- }
335
-
336
- // Fallback: force-kill if the process is still alive after the
337
- // grace period. This mirrors the Unix SIGTERM → SIGKILL pattern.
338
- const forceKillTimeout = setTimeout(() => {
339
- if (proc.exitCode !== null || proc.signalCode !== null) return;
340
- try {
341
- spawn('taskkill', ['/pid', proc.pid.toString(), '/f', '/t']);
342
- } catch (e) {
343
- // Ignore — process may already be dead
344
- }
345
- }, KILL_GRACE_PERIOD);
346
-
347
- forceKillTimeout.unref();
348
-
349
- proc.once('close', () => {
350
- clearTimeout(forceKillTimeout);
351
- });
352
- }
353
-
354
- /**
355
- * Restart the server process.
356
- *
357
- * Waits for the old process to fully exit (so it releases its port)
358
- * rather than sleeping a static RESTART_DELAY that is shorter than
359
- * the SIGKILL grace period. Bounded by KILL_GRACE_PERIOD + a small
360
- * margin so we never hang indefinitely if 'close' doesn't fire.
361
- *
362
- * @returns {Promise<{success: boolean, error?: Error, pid?: number}>}
363
- */
364
- async restart() {
365
- return this._restartMutex.withLock(async () => {
366
- const command = this.command;
367
- // Capture before stop() nulls this.process.
368
- const oldProc = this.process;
369
-
370
- this.stop();
371
-
372
- if (oldProc && oldProc.exitCode === null && oldProc.signalCode === null) {
373
- // Old process hasn't exited yet — wait for 'close' with a bounded
374
- // timeout so we don't hang if the process ignores all signals.
375
- await new Promise((resolve) => {
376
- const timeout = setTimeout(resolve, KILL_GRACE_PERIOD + RESTART_DELAY);
377
- timeout.unref();
378
- oldProc.once('close', () => {
379
- clearTimeout(timeout);
380
- resolve();
381
- });
382
- });
383
- }
384
-
385
- return this.start(command);
386
- });
387
- }
388
-
389
- /**
390
- * Check if server is running
391
- * @returns {boolean}
392
- */
393
- isRunning() {
394
- return this.running;
395
- }
396
-
397
- /**
398
- * Check if server crashed
399
- * @returns {boolean}
400
- */
401
- hasCrashed() {
402
- return this.crashed;
403
- }
404
-
405
- /**
406
- * Get server PID
407
- * @returns {number|null}
408
- */
409
- getPid() {
410
- return this.process ? this.process.pid : null;
411
- }
412
-
413
- /**
414
- * Notify state change
415
- * @private
416
- */
417
- notifyStateChange() {
418
- this.onStateChange(this.getState());
419
- }
420
- }
421
-
422
75
  module.exports = {
423
- ProcessManager,
424
76
  parseCommand,
425
- MAX_LOG_LINES,
426
- KILL_GRACE_PERIOD,
427
- RESTART_DELAY,
428
77
  };
@@ -3,30 +3,57 @@
3
3
  * @module utils/sound
4
4
  */
5
5
 
6
- const { exec } = require('child_process');
6
+ const { execFile } = require('child_process');
7
+
8
+ const noop = () => {};
9
+
10
+ const playBell = () => {
11
+ process.stdout.write('\x07');
12
+ };
13
+
14
+ // Linux audio-tool cascade. Each entry is [command, args]; we try them in
15
+ // order and fall through on non-zero exit, ending in a terminal bell.
16
+ // Mirrors the previous shell `||` chain without spawning a shell.
17
+ const LINUX_ATTEMPTS = [
18
+ ['paplay', ['/usr/share/sounds/freedesktop/stereo/message-new-instant.oga']],
19
+ ['paplay', ['/usr/share/sounds/freedesktop/stereo/complete.oga']],
20
+ ['aplay', ['-q', '/usr/share/sounds/sound-icons/prompt.wav']],
21
+ ];
22
+
23
+ function cascade(attempts, onAllFailed, options) {
24
+ if (attempts.length === 0) {
25
+ onAllFailed();
26
+ return;
27
+ }
28
+ const [cmd, args] = attempts[0];
29
+ execFile(cmd, args, options, (err) => {
30
+ if (err) cascade(attempts.slice(1), onAllFailed, options);
31
+ });
32
+ }
7
33
 
8
34
  /**
9
35
  * Play a system notification sound (non-blocking).
10
- * Cross-platform: macOS (afplay), Linux (paplay/aplay), Windows (terminal bell).
36
+ *
37
+ * Cross-platform: macOS (afplay), Linux (paplay/aplay cascade), Windows
38
+ * (terminal bell). Uses execFile (no shell) to match the pattern in
39
+ * casino/sounds.js — the previous exec calls passed fixed strings so
40
+ * there was no injection surface either way, but dropping the shell
41
+ * removes the per-call /bin/sh fork and makes the two sound modules
42
+ * consistent.
43
+ *
11
44
  * @param {object} [options]
12
- * @param {string} [options.cwd] - Working directory for exec
45
+ * @param {string} [options.cwd] - Working directory
13
46
  */
14
47
  function playSound(options = {}) {
15
48
  const { platform } = process;
16
49
  const cwd = options.cwd || process.cwd();
17
50
 
18
51
  if (platform === 'darwin') {
19
- exec('afplay /System/Library/Sounds/Pop.aiff 2>/dev/null', { cwd });
52
+ execFile('afplay', ['/System/Library/Sounds/Pop.aiff'], { cwd }, noop);
20
53
  } else if (platform === 'linux') {
21
- exec(
22
- 'paplay /usr/share/sounds/freedesktop/stereo/message-new-instant.oga 2>/dev/null || ' +
23
- 'paplay /usr/share/sounds/freedesktop/stereo/complete.oga 2>/dev/null || ' +
24
- 'aplay /usr/share/sounds/sound-icons/prompt.wav 2>/dev/null || ' +
25
- 'printf "\\a"',
26
- { cwd }
27
- );
54
+ cascade(LINUX_ATTEMPTS, playBell, { cwd });
28
55
  } else {
29
- process.stdout.write('\x07');
56
+ playBell();
30
57
  }
31
58
  }
32
59