git-watchtower 2.2.1 → 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/README.md CHANGED
@@ -95,9 +95,9 @@ Real-time branch monitoring, PR workflows, CI status, and session statistics —
95
95
 
96
96
  | Mode | Flag | Description |
97
97
  |------|------|-------------|
98
- | **Static Site** | `--mode static` | Built-in server with live reload for HTML/CSS/JS (default) |
98
+ | **No Server** | `--no-server` | Branch monitoring only (default) |
99
+ | **Static Site** | `--mode static` | Built-in server with live reload for HTML/CSS/JS |
99
100
  | **Custom Command** | `--mode command -c "npm run dev"` | Run your own dev server (Next.js, Vite, Nuxt, etc.) |
100
- | **No Server** | `--no-server` | Branch monitoring only |
101
101
 
102
102
  ## 🎰 Casino Mode
103
103
 
@@ -862,7 +862,7 @@ let pollIntervalId = null;
862
862
  const { ansi, box, truncate, sparkline: uiSparkline, visibleLength, stripAnsi, padRight, padLeft, getMaxBranchesForScreen: calcMaxBranches, drawBox: renderBox, clearArea: renderClearArea } = require('../src/ui/ansi');
863
863
 
864
864
  // Error detection utilities imported from src/utils/errors.js
865
- const { ErrorHandler, isAuthError, isMergeConflict, isNetworkError } = require('../src/utils/errors');
865
+ const { isAuthError, isMergeConflict, isNetworkError } = require('../src/utils/errors');
866
866
  const { Mutex, sleep } = require('../src/utils/async');
867
867
 
868
868
  // Keyboard handling utilities imported from src/ui/keybindings.js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "2.2.1",
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
 
@@ -255,9 +268,9 @@ Usage:
255
268
  git-watchtower [options]
256
269
 
257
270
  Server Options:
258
- -m, --mode <mode> Server mode: static, command, or none
271
+ -m, --mode <mode> Server mode: static, command, or none (default: none)
259
272
  -p, --port <port> Server port (default: 3000)
260
- -n, --no-server Shorthand for --mode none
273
+ -n, --no-server Shorthand for --mode none (default)
261
274
  --static-dir <dir> Directory for static file serving (default: public)
262
275
  -c, --command <cmd> Command to run in command mode (e.g., "npm run dev")
263
276
  --restart-on-switch Restart server on branch switch (default)
@@ -287,9 +300,9 @@ General:
287
300
  -h, --help Show this help message
288
301
 
289
302
  Server Modes:
290
- static Serve static files with live reload (default)
303
+ none Branch monitoring only (default)
304
+ static Serve static files with live reload
291
305
  command Run your own dev server (Next.js, Vite, Nuxt, etc.)
292
- none Branch monitoring only
293
306
 
294
307
  Configuration:
295
308
  On first run, Git Watchtower will prompt you to configure settings.
@@ -48,7 +48,7 @@ const SERVER_MODES = ['static', 'command', 'none'];
48
48
  */
49
49
  const DEFAULTS = {
50
50
  server: {
51
- mode: /** @type {ServerMode} */ ('static'),
51
+ mode: /** @type {ServerMode} */ ('none'),
52
52
  staticDir: 'public',
53
53
  command: '',
54
54
  port: 3000,
@@ -291,12 +291,12 @@ function migrateConfig(config) {
291
291
  return validateConfig(config);
292
292
  }
293
293
 
294
- // Convert old format to new
294
+ // Convert old format to new. Legacy configs predate the 'none' default,
295
+ // so preserve the old behavior: noServer maps to 'none', anything else
296
+ // implies the user wanted the static server that used to be the default.
295
297
  const newConfig = getDefaultConfig();
296
298
 
297
- if (config.noServer) {
298
- newConfig.server.mode = 'none';
299
- }
299
+ newConfig.server.mode = config.noServer ? 'none' : 'static';
300
300
  if (config.port !== undefined) {
301
301
  newConfig.server.port = validatePort(config.port);
302
302
  }
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
  };
@@ -56,14 +56,22 @@ const LIVE_RELOAD_SCRIPT = `
56
56
 
57
57
  /**
58
58
  * Inject live reload script into HTML content.
59
+ *
60
+ * Splits on the LAST `</body>` rather than the first. String.prototype.replace
61
+ * with a string argument only replaces the first match, which mis-injects
62
+ * the script when the page has a literal `</body>` earlier in the document —
63
+ * e.g. inside a `<pre>` or `<code>` block on a docs site rendering HTML
64
+ * samples. The browser only honours the last `</body>` as the actual close,
65
+ * so that's where the script needs to land.
66
+ *
59
67
  * @param {string} html - HTML content
60
- * @returns {string} HTML with live reload script injected before </body>
68
+ * @returns {string} HTML with live reload script injected before the closing </body>
61
69
  */
62
70
  function injectLiveReload(html) {
63
- if (html.includes('</body>')) {
64
- return html.replace('</body>', LIVE_RELOAD_SCRIPT);
65
- }
66
- return html;
71
+ const closeTag = '</body>';
72
+ const idx = html.lastIndexOf(closeTag);
73
+ if (idx === -1) return html;
74
+ return html.slice(0, idx) + LIVE_RELOAD_SCRIPT + html.slice(idx + closeTag.length);
67
75
  }
68
76
 
69
77
  /**
@@ -252,33 +252,6 @@ class ServerError extends AppError {
252
252
  this.name = 'ServerError';
253
253
  }
254
254
 
255
- /**
256
- * Create ServerError for port in use
257
- * @param {number} port - The port that's in use
258
- * @returns {ServerError}
259
- */
260
- static portInUse(port) {
261
- return new ServerError(
262
- `Port ${port} is already in use`,
263
- 'PORT_IN_USE',
264
- { port }
265
- );
266
- }
267
-
268
- /**
269
- * Create ServerError for process crash
270
- * @param {string} command - The command that crashed
271
- * @param {number} exitCode - Exit code
272
- * @returns {ServerError}
273
- */
274
- static processCrashed(command, exitCode) {
275
- return new ServerError(
276
- `Server process crashed with exit code ${exitCode}`,
277
- 'PROCESS_CRASHED',
278
- { command, exitCode }
279
- );
280
- }
281
-
282
255
  /**
283
256
  * Create ServerError for start failure
284
257
  * @param {string} command - The command that failed to start
@@ -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