git-watchtower 1.6.1 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/git-watchtower.js +49 -2
- 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 +1328 -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/src/index.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git Watchtower - Modular Architecture
|
|
3
|
+
*
|
|
4
|
+
* This is the main entry point for the refactored modules.
|
|
5
|
+
* Import what you need from this file.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const { Store, createStore } = require('./src');
|
|
9
|
+
* const { GitError, ConfigError } = require('./src');
|
|
10
|
+
* const { ansi, box } = require('./src');
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Utilities
|
|
14
|
+
const asyncUtils = require('./utils/async');
|
|
15
|
+
const errors = require('./utils/errors');
|
|
16
|
+
|
|
17
|
+
// State management
|
|
18
|
+
const state = require('./state/store');
|
|
19
|
+
|
|
20
|
+
// UI components
|
|
21
|
+
const ui = require('./ui/ansi');
|
|
22
|
+
const rendererModule = require('./ui/renderer');
|
|
23
|
+
const actionsModule = require('./ui/actions');
|
|
24
|
+
const keybindingsModule = require('./ui/keybindings');
|
|
25
|
+
|
|
26
|
+
// Git operations
|
|
27
|
+
const gitCommands = require('./git/commands');
|
|
28
|
+
const gitBranch = require('./git/branch');
|
|
29
|
+
|
|
30
|
+
// Configuration
|
|
31
|
+
const configSchema = require('./config/schema');
|
|
32
|
+
const configLoader = require('./config/loader');
|
|
33
|
+
|
|
34
|
+
// Server management
|
|
35
|
+
const serverProcess = require('./server/process');
|
|
36
|
+
|
|
37
|
+
// Telemetry
|
|
38
|
+
const telemetryModule = require('./telemetry');
|
|
39
|
+
|
|
40
|
+
// CLI and utilities
|
|
41
|
+
const cliArgs = require('./cli/args');
|
|
42
|
+
const timeUtils = require('./utils/time');
|
|
43
|
+
const browserUtils = require('./utils/browser');
|
|
44
|
+
const soundUtils = require('./utils/sound');
|
|
45
|
+
const gitRemote = require('./git/remote');
|
|
46
|
+
|
|
47
|
+
module.exports = {
|
|
48
|
+
// Async utilities
|
|
49
|
+
Mutex: asyncUtils.Mutex,
|
|
50
|
+
withTimeout: asyncUtils.withTimeout,
|
|
51
|
+
retry: asyncUtils.retry,
|
|
52
|
+
sleep: asyncUtils.sleep,
|
|
53
|
+
debounce: asyncUtils.debounce,
|
|
54
|
+
throttle: asyncUtils.throttle,
|
|
55
|
+
|
|
56
|
+
// Error classes
|
|
57
|
+
AppError: errors.AppError,
|
|
58
|
+
GitError: errors.GitError,
|
|
59
|
+
ConfigError: errors.ConfigError,
|
|
60
|
+
ServerError: errors.ServerError,
|
|
61
|
+
ValidationError: errors.ValidationError,
|
|
62
|
+
ErrorHandler: errors.ErrorHandler,
|
|
63
|
+
isAuthError: errors.isAuthError,
|
|
64
|
+
isMergeConflict: errors.isMergeConflict,
|
|
65
|
+
isNetworkError: errors.isNetworkError,
|
|
66
|
+
|
|
67
|
+
// State management
|
|
68
|
+
Store: state.Store,
|
|
69
|
+
createStore: state.createStore,
|
|
70
|
+
getInitialState: state.getInitialState,
|
|
71
|
+
|
|
72
|
+
// UI utilities
|
|
73
|
+
ansi: ui.ansi,
|
|
74
|
+
box: ui.box,
|
|
75
|
+
sparkline: ui.sparkline,
|
|
76
|
+
indicators: ui.indicators,
|
|
77
|
+
stripAnsi: ui.stripAnsi,
|
|
78
|
+
visibleLength: ui.visibleLength,
|
|
79
|
+
truncate: ui.truncate,
|
|
80
|
+
pad: ui.pad,
|
|
81
|
+
padRight: ui.padRight,
|
|
82
|
+
padLeft: ui.padLeft,
|
|
83
|
+
getMaxBranchesForScreen: ui.getMaxBranchesForScreen,
|
|
84
|
+
drawBox: ui.drawBox,
|
|
85
|
+
clearArea: ui.clearArea,
|
|
86
|
+
wordWrap: ui.wordWrap,
|
|
87
|
+
horizontalLine: ui.horizontalLine,
|
|
88
|
+
style: ui.style,
|
|
89
|
+
|
|
90
|
+
// Renderer
|
|
91
|
+
renderer: rendererModule,
|
|
92
|
+
|
|
93
|
+
// Actions (keyboard handlers)
|
|
94
|
+
actions: actionsModule,
|
|
95
|
+
|
|
96
|
+
// Keybindings
|
|
97
|
+
keybindings: keybindingsModule,
|
|
98
|
+
|
|
99
|
+
// Git commands
|
|
100
|
+
parseDiffStats: gitCommands.parseDiffStats,
|
|
101
|
+
getDiffStats: gitCommands.getDiffStats,
|
|
102
|
+
execGit: gitCommands.execGit,
|
|
103
|
+
execGitSilent: gitCommands.execGitSilent,
|
|
104
|
+
isGitAvailable: gitCommands.isGitAvailable,
|
|
105
|
+
isGitRepository: gitCommands.isGitRepository,
|
|
106
|
+
getRemotes: gitCommands.getRemotes,
|
|
107
|
+
remoteExists: gitCommands.remoteExists,
|
|
108
|
+
fetch: gitCommands.fetch,
|
|
109
|
+
pull: gitCommands.pull,
|
|
110
|
+
log: gitCommands.log,
|
|
111
|
+
getCommitsByDay: gitCommands.getCommitsByDay,
|
|
112
|
+
hasUncommittedChanges: gitCommands.hasUncommittedChanges,
|
|
113
|
+
stash: gitCommands.stash,
|
|
114
|
+
stashPop: gitCommands.stashPop,
|
|
115
|
+
getChangedFiles: gitCommands.getChangedFiles,
|
|
116
|
+
deleteLocalBranch: gitCommands.deleteLocalBranch,
|
|
117
|
+
|
|
118
|
+
// Git branch operations
|
|
119
|
+
isValidBranchName: gitBranch.isValidBranchName,
|
|
120
|
+
sanitizeBranchName: gitBranch.sanitizeBranchName,
|
|
121
|
+
getCurrentBranch: gitBranch.getCurrentBranch,
|
|
122
|
+
getAllBranches: gitBranch.getAllBranches,
|
|
123
|
+
detectBranchChanges: gitBranch.detectBranchChanges,
|
|
124
|
+
checkout: gitBranch.checkout,
|
|
125
|
+
getPreviewData: gitBranch.getPreviewData,
|
|
126
|
+
generateSparkline: gitBranch.generateSparkline,
|
|
127
|
+
getLocalBranches: gitBranch.getLocalBranches,
|
|
128
|
+
localBranchExists: gitBranch.localBranchExists,
|
|
129
|
+
getGoneBranches: gitBranch.getGoneBranches,
|
|
130
|
+
deleteGoneBranches: gitBranch.deleteGoneBranches,
|
|
131
|
+
|
|
132
|
+
// Configuration schema
|
|
133
|
+
SERVER_MODES: configSchema.SERVER_MODES,
|
|
134
|
+
DEFAULTS: configSchema.DEFAULTS,
|
|
135
|
+
LIMITS: configSchema.LIMITS,
|
|
136
|
+
getDefaultConfig: configSchema.getDefaultConfig,
|
|
137
|
+
validatePort: configSchema.validatePort,
|
|
138
|
+
validateServerMode: configSchema.validateServerMode,
|
|
139
|
+
validatePollInterval: configSchema.validatePollInterval,
|
|
140
|
+
validateVisibleBranches: configSchema.validateVisibleBranches,
|
|
141
|
+
validateConfig: configSchema.validateConfig,
|
|
142
|
+
migrateConfig: configSchema.migrateConfig,
|
|
143
|
+
|
|
144
|
+
// Configuration loading
|
|
145
|
+
CONFIG_FILE_NAME: configLoader.CONFIG_FILE_NAME,
|
|
146
|
+
getConfigPath: configLoader.getConfigPath,
|
|
147
|
+
configExists: configLoader.configExists,
|
|
148
|
+
loadConfig: configLoader.loadConfig,
|
|
149
|
+
saveConfig: configLoader.saveConfig,
|
|
150
|
+
deleteConfig: configLoader.deleteConfig,
|
|
151
|
+
applyCliArgs: configLoader.applyCliArgs,
|
|
152
|
+
parseCliArgs: configLoader.parseCliArgs,
|
|
153
|
+
ensureConfig: configLoader.ensureConfig,
|
|
154
|
+
|
|
155
|
+
// Server process management
|
|
156
|
+
ProcessManager: serverProcess.ProcessManager,
|
|
157
|
+
parseCommand: serverProcess.parseCommand,
|
|
158
|
+
|
|
159
|
+
// CLI argument parsing
|
|
160
|
+
parseArgs: cliArgs.parseArgs,
|
|
161
|
+
applyCliArgsToConfig: cliArgs.applyCliArgsToConfig,
|
|
162
|
+
getHelpText: cliArgs.getHelpText,
|
|
163
|
+
CLI_PACKAGE_VERSION: cliArgs.PACKAGE_VERSION,
|
|
164
|
+
|
|
165
|
+
// Utility modules
|
|
166
|
+
formatTimeAgo: timeUtils.formatTimeAgo,
|
|
167
|
+
openInBrowser: browserUtils.openInBrowser,
|
|
168
|
+
playSound: soundUtils.playSound,
|
|
169
|
+
|
|
170
|
+
// Telemetry
|
|
171
|
+
telemetry: telemetryModule,
|
|
172
|
+
|
|
173
|
+
// Git remote URL utilities
|
|
174
|
+
parseRemoteUrl: gitRemote.parseRemoteUrl,
|
|
175
|
+
buildBranchUrl: gitRemote.buildBranchUrl,
|
|
176
|
+
detectPlatform: gitRemote.detectPlatform,
|
|
177
|
+
buildWebUrl: gitRemote.buildWebUrl,
|
|
178
|
+
extractSessionUrl: gitRemote.extractSessionUrl,
|
|
179
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Polling engine — pure logic for branch change detection and sorting
|
|
3
|
+
* @module polling/engine
|
|
4
|
+
*/
|
|
5
|
+
|
|
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
|
+
if (branch.isDeleted) continue;
|
|
61
|
+
const prevCommit = previousStates.get(branch.name);
|
|
62
|
+
if (prevCommit && prevCommit !== branch.commit && branch.name !== currentBranch) {
|
|
63
|
+
branch.justUpdated = true;
|
|
64
|
+
updated.push(branch);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return updated;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Sort branches: new first, then by date, merged near bottom, deleted at bottom.
|
|
72
|
+
* @param {Array} branches - Branch list to sort
|
|
73
|
+
* @param {Map} prStatusMap - Map of branch name -> PR status
|
|
74
|
+
* @returns {Array} Sorted branches (mutates and returns input)
|
|
75
|
+
*/
|
|
76
|
+
function sortBranches(branches, prStatusMap) {
|
|
77
|
+
return branches.sort((a, b) => {
|
|
78
|
+
const aIsBase = isBaseBranch(a.name);
|
|
79
|
+
const bIsBase = isBaseBranch(b.name);
|
|
80
|
+
const aMerged = !aIsBase && prStatusMap.has(a.name) && prStatusMap.get(a.name).state === 'MERGED';
|
|
81
|
+
const bMerged = !bIsBase && prStatusMap.has(b.name) && prStatusMap.get(b.name).state === 'MERGED';
|
|
82
|
+
if (a.isDeleted && !b.isDeleted) return 1;
|
|
83
|
+
if (!a.isDeleted && b.isDeleted) return -1;
|
|
84
|
+
if (aMerged && !bMerged && !b.isDeleted) return 1;
|
|
85
|
+
if (!aMerged && bMerged && !a.isDeleted) return -1;
|
|
86
|
+
if (a.isNew && !b.isNew) return -1;
|
|
87
|
+
if (!a.isNew && b.isNew) return 1;
|
|
88
|
+
return b.date - a.date;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Calculate adaptive polling interval based on fetch duration.
|
|
94
|
+
* @param {number} fetchDuration - How long the fetch took (ms)
|
|
95
|
+
* @param {number} currentInterval - Current polling interval (ms)
|
|
96
|
+
* @param {number} baseInterval - Base/default polling interval (ms)
|
|
97
|
+
* @returns {{ interval: number, warning: string|null }}
|
|
98
|
+
*/
|
|
99
|
+
function calculateAdaptiveInterval(fetchDuration, currentInterval, baseInterval) {
|
|
100
|
+
if (fetchDuration > 30000) {
|
|
101
|
+
return {
|
|
102
|
+
interval: Math.min(currentInterval * 2, 60000),
|
|
103
|
+
warning: 'very_slow',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (fetchDuration > 15000) {
|
|
107
|
+
return {
|
|
108
|
+
interval: currentInterval,
|
|
109
|
+
warning: 'slow',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (fetchDuration < 5000 && currentInterval > baseInterval) {
|
|
113
|
+
return {
|
|
114
|
+
interval: baseInterval,
|
|
115
|
+
warning: 'restored',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
interval: currentInterval,
|
|
120
|
+
warning: null,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Restore selection index after branch list reorder.
|
|
126
|
+
* @param {Array<{name: string}>} branches - New branch list
|
|
127
|
+
* @param {string|null} previousName - Previously selected branch name
|
|
128
|
+
* @param {number} previousIndex - Previously selected index
|
|
129
|
+
* @returns {{ selectedIndex: number, selectedBranchName: string|null }}
|
|
130
|
+
*/
|
|
131
|
+
function restoreSelection(branches, previousName, previousIndex) {
|
|
132
|
+
if (previousName) {
|
|
133
|
+
const newIndex = branches.findIndex(b => b.name === previousName);
|
|
134
|
+
if (newIndex >= 0) {
|
|
135
|
+
return { selectedIndex: newIndex, selectedBranchName: previousName };
|
|
136
|
+
}
|
|
137
|
+
const clampedIndex = Math.min(previousIndex, Math.max(0, branches.length - 1));
|
|
138
|
+
return {
|
|
139
|
+
selectedIndex: clampedIndex,
|
|
140
|
+
selectedBranchName: branches[clampedIndex] ? branches[clampedIndex].name : null,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (previousIndex >= branches.length) {
|
|
144
|
+
const idx = Math.max(0, branches.length - 1);
|
|
145
|
+
return { selectedIndex: idx, selectedBranchName: branches[idx] ? branches[idx].name : null };
|
|
146
|
+
}
|
|
147
|
+
return { selectedIndex: previousIndex, selectedBranchName: previousName };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = {
|
|
151
|
+
detectNewBranches,
|
|
152
|
+
detectDeletedBranches,
|
|
153
|
+
detectUpdatedBranches,
|
|
154
|
+
sortBranches,
|
|
155
|
+
calculateAdaptiveInterval,
|
|
156
|
+
restoreSelection,
|
|
157
|
+
};
|
|
@@ -0,0 +1,329 @@
|
|
|
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
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} ServerProcessState
|
|
11
|
+
* @property {import('child_process').ChildProcess|null} process - The server process
|
|
12
|
+
* @property {boolean} running - Is the server running
|
|
13
|
+
* @property {boolean} crashed - Did the server crash
|
|
14
|
+
* @property {Array<{timestamp: string, line: string, isError: boolean}>} logs - Server logs
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Maximum log lines to keep in buffer
|
|
19
|
+
*/
|
|
20
|
+
const MAX_LOG_LINES = 500;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Grace period before force kill (ms)
|
|
24
|
+
*/
|
|
25
|
+
const KILL_GRACE_PERIOD = 3000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Restart delay after stop (ms)
|
|
29
|
+
*/
|
|
30
|
+
const RESTART_DELAY = 500;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse a command string into command and arguments
|
|
34
|
+
* Handles quoted strings properly
|
|
35
|
+
* @param {string} commandString - Command string to parse
|
|
36
|
+
* @returns {{command: string, args: string[]}}
|
|
37
|
+
*/
|
|
38
|
+
function parseCommand(commandString) {
|
|
39
|
+
const args = [];
|
|
40
|
+
let current = '';
|
|
41
|
+
let inQuotes = false;
|
|
42
|
+
let quoteChar = '';
|
|
43
|
+
|
|
44
|
+
for (let i = 0; i < commandString.length; i++) {
|
|
45
|
+
const char = commandString[i];
|
|
46
|
+
|
|
47
|
+
if ((char === '"' || char === "'") && !inQuotes) {
|
|
48
|
+
inQuotes = true;
|
|
49
|
+
quoteChar = char;
|
|
50
|
+
} else if (char === quoteChar && inQuotes) {
|
|
51
|
+
inQuotes = false;
|
|
52
|
+
quoteChar = '';
|
|
53
|
+
} else if (char === ' ' && !inQuotes) {
|
|
54
|
+
if (current) {
|
|
55
|
+
args.push(current);
|
|
56
|
+
current = '';
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
current += char;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (current) {
|
|
64
|
+
args.push(current);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
command: args[0] || '',
|
|
69
|
+
args: args.slice(1),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Server process manager
|
|
75
|
+
*/
|
|
76
|
+
class ProcessManager {
|
|
77
|
+
/**
|
|
78
|
+
* @param {Object} [options]
|
|
79
|
+
* @param {string} [options.cwd] - Working directory
|
|
80
|
+
* @param {Function} [options.onLog] - Log callback (line, isError)
|
|
81
|
+
* @param {Function} [options.onStateChange] - State change callback (state)
|
|
82
|
+
*/
|
|
83
|
+
constructor(options = {}) {
|
|
84
|
+
this.cwd = options.cwd || process.cwd();
|
|
85
|
+
this.onLog = options.onLog || (() => {});
|
|
86
|
+
this.onStateChange = options.onStateChange || (() => {});
|
|
87
|
+
|
|
88
|
+
this.process = null;
|
|
89
|
+
this.running = false;
|
|
90
|
+
this.crashed = false;
|
|
91
|
+
this.logs = [];
|
|
92
|
+
this.command = '';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get current state
|
|
97
|
+
* @returns {ServerProcessState}
|
|
98
|
+
*/
|
|
99
|
+
getState() {
|
|
100
|
+
return {
|
|
101
|
+
process: this.process,
|
|
102
|
+
running: this.running,
|
|
103
|
+
crashed: this.crashed,
|
|
104
|
+
logs: [...this.logs],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Add a log entry
|
|
110
|
+
* @param {string} line - Log line
|
|
111
|
+
* @param {boolean} [isError=false] - Is error output
|
|
112
|
+
*/
|
|
113
|
+
addLog(line, isError = false) {
|
|
114
|
+
const entry = {
|
|
115
|
+
timestamp: new Date().toLocaleTimeString(),
|
|
116
|
+
line,
|
|
117
|
+
isError,
|
|
118
|
+
};
|
|
119
|
+
this.logs.push(entry);
|
|
120
|
+
if (this.logs.length > MAX_LOG_LINES) {
|
|
121
|
+
this.logs.shift();
|
|
122
|
+
}
|
|
123
|
+
this.onLog(line, isError);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Clear log buffer
|
|
128
|
+
*/
|
|
129
|
+
clearLogs() {
|
|
130
|
+
this.logs = [];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Start the server process
|
|
135
|
+
* @param {string} commandString - Command to run
|
|
136
|
+
* @returns {{success: boolean, error?: Error, pid?: number}}
|
|
137
|
+
*/
|
|
138
|
+
start(commandString) {
|
|
139
|
+
if (!commandString) {
|
|
140
|
+
return {
|
|
141
|
+
success: false,
|
|
142
|
+
error: ServerError.startFailed(commandString, 'No command specified'),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Stop existing process first
|
|
147
|
+
if (this.process) {
|
|
148
|
+
this.stop();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this.clearLogs();
|
|
152
|
+
this.crashed = false;
|
|
153
|
+
this.running = false;
|
|
154
|
+
this.command = commandString;
|
|
155
|
+
|
|
156
|
+
this.addLog(`$ ${commandString}`);
|
|
157
|
+
|
|
158
|
+
// Parse command
|
|
159
|
+
const { command, args } = parseCommand(commandString);
|
|
160
|
+
|
|
161
|
+
if (!command) {
|
|
162
|
+
const error = ServerError.startFailed(commandString, 'Invalid command');
|
|
163
|
+
this.crashed = true;
|
|
164
|
+
this.addLog(`Failed to start: Invalid command`, true);
|
|
165
|
+
this.notifyStateChange();
|
|
166
|
+
return { success: false, error };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Spawn options
|
|
170
|
+
const isWindows = process.platform === 'win32';
|
|
171
|
+
/** @type {import('child_process').SpawnOptions} */
|
|
172
|
+
const spawnOptions = {
|
|
173
|
+
cwd: this.cwd,
|
|
174
|
+
env: { ...process.env, FORCE_COLOR: '1' },
|
|
175
|
+
shell: isWindows,
|
|
176
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
this.process = spawn(command, args, spawnOptions);
|
|
181
|
+
this.running = true;
|
|
182
|
+
|
|
183
|
+
// Handle stdout
|
|
184
|
+
this.process.stdout?.on('data', (data) => {
|
|
185
|
+
const lines = data.toString().split('\n').filter(Boolean);
|
|
186
|
+
lines.forEach((line) => this.addLog(line));
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Handle stderr
|
|
190
|
+
this.process.stderr?.on('data', (data) => {
|
|
191
|
+
const lines = data.toString().split('\n').filter(Boolean);
|
|
192
|
+
lines.forEach((line) => this.addLog(line, true));
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Handle error
|
|
196
|
+
this.process.on('error', (err) => {
|
|
197
|
+
this.running = false;
|
|
198
|
+
this.crashed = true;
|
|
199
|
+
this.addLog(`Error: ${err.message}`, true);
|
|
200
|
+
this.notifyStateChange();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Handle close
|
|
204
|
+
this.process.on('close', (code) => {
|
|
205
|
+
this.running = false;
|
|
206
|
+
if (code !== 0 && code !== null) {
|
|
207
|
+
this.crashed = true;
|
|
208
|
+
this.addLog(`Process exited with code ${code}`, true);
|
|
209
|
+
} else {
|
|
210
|
+
this.addLog('Process stopped');
|
|
211
|
+
}
|
|
212
|
+
this.process = null;
|
|
213
|
+
this.notifyStateChange();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
this.notifyStateChange();
|
|
217
|
+
return { success: true, pid: this.process.pid };
|
|
218
|
+
} catch (err) {
|
|
219
|
+
this.crashed = true;
|
|
220
|
+
this.addLog(`Failed to start: ${err.message}`, true);
|
|
221
|
+
this.notifyStateChange();
|
|
222
|
+
return {
|
|
223
|
+
success: false,
|
|
224
|
+
error: ServerError.startFailed(commandString, err.message),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Stop the server process
|
|
231
|
+
* @returns {boolean} - True if a process was stopped
|
|
232
|
+
*/
|
|
233
|
+
stop() {
|
|
234
|
+
if (!this.process) {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Capture reference before nulling — needed for deferred SIGKILL
|
|
239
|
+
const proc = this.process;
|
|
240
|
+
|
|
241
|
+
// Try graceful shutdown first
|
|
242
|
+
if (process.platform === 'win32') {
|
|
243
|
+
try {
|
|
244
|
+
spawn('taskkill', ['/pid', proc.pid.toString(), '/f', '/t']);
|
|
245
|
+
} catch (e) {
|
|
246
|
+
// Ignore taskkill errors
|
|
247
|
+
}
|
|
248
|
+
} else {
|
|
249
|
+
try {
|
|
250
|
+
proc.kill('SIGTERM');
|
|
251
|
+
|
|
252
|
+
// Force kill after grace period
|
|
253
|
+
const forceKillTimeout = setTimeout(() => {
|
|
254
|
+
try {
|
|
255
|
+
proc.kill('SIGKILL');
|
|
256
|
+
} catch (e) {
|
|
257
|
+
// Process may already be dead
|
|
258
|
+
}
|
|
259
|
+
}, KILL_GRACE_PERIOD);
|
|
260
|
+
|
|
261
|
+
// Clear timeout if process exits cleanly
|
|
262
|
+
proc.once('close', () => {
|
|
263
|
+
clearTimeout(forceKillTimeout);
|
|
264
|
+
});
|
|
265
|
+
} catch (e) {
|
|
266
|
+
// Process may already be dead
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
this.process = null;
|
|
271
|
+
this.running = false;
|
|
272
|
+
this.notifyStateChange();
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Restart the server process
|
|
278
|
+
* @returns {Promise<{success: boolean, error?: Error, pid?: number}>}
|
|
279
|
+
*/
|
|
280
|
+
async restart() {
|
|
281
|
+
const command = this.command;
|
|
282
|
+
this.stop();
|
|
283
|
+
|
|
284
|
+
// Wait before restarting
|
|
285
|
+
await new Promise((resolve) => setTimeout(resolve, RESTART_DELAY));
|
|
286
|
+
|
|
287
|
+
return this.start(command);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Check if server is running
|
|
292
|
+
* @returns {boolean}
|
|
293
|
+
*/
|
|
294
|
+
isRunning() {
|
|
295
|
+
return this.running;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Check if server crashed
|
|
300
|
+
* @returns {boolean}
|
|
301
|
+
*/
|
|
302
|
+
hasCrashed() {
|
|
303
|
+
return this.crashed;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Get server PID
|
|
308
|
+
* @returns {number|null}
|
|
309
|
+
*/
|
|
310
|
+
getPid() {
|
|
311
|
+
return this.process ? this.process.pid : null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Notify state change
|
|
316
|
+
* @private
|
|
317
|
+
*/
|
|
318
|
+
notifyStateChange() {
|
|
319
|
+
this.onStateChange(this.getState());
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
module.exports = {
|
|
324
|
+
ProcessManager,
|
|
325
|
+
parseCommand,
|
|
326
|
+
MAX_LOG_LINES,
|
|
327
|
+
KILL_GRACE_PERIOD,
|
|
328
|
+
RESTART_DELAY,
|
|
329
|
+
};
|