git-watchtower 1.6.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Configuration file loading and saving
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { ConfigError } = require('../utils/errors');
8
+ const { getDefaultConfig, validateConfig, migrateConfig } = require('./schema');
9
+
10
+ /**
11
+ * Default configuration file name
12
+ */
13
+ const CONFIG_FILE_NAME = '.watchtowerrc.json';
14
+
15
+ /**
16
+ * Get the configuration file path
17
+ * @param {string} [projectRoot] - Project root directory
18
+ * @returns {string}
19
+ */
20
+ function getConfigPath(projectRoot = process.cwd()) {
21
+ return path.join(projectRoot, CONFIG_FILE_NAME);
22
+ }
23
+
24
+ /**
25
+ * Check if configuration file exists
26
+ * @param {string} [projectRoot] - Project root directory
27
+ * @returns {boolean}
28
+ */
29
+ function configExists(projectRoot) {
30
+ return fs.existsSync(getConfigPath(projectRoot));
31
+ }
32
+
33
+ /**
34
+ * Load configuration from file
35
+ * @param {string} [projectRoot] - Project root directory
36
+ * @returns {Object|null} - Raw config object or null if not found
37
+ * @throws {ConfigError} - If file exists but cannot be parsed
38
+ */
39
+ function loadConfigRaw(projectRoot) {
40
+ const configPath = getConfigPath(projectRoot);
41
+
42
+ if (!fs.existsSync(configPath)) {
43
+ return null;
44
+ }
45
+
46
+ try {
47
+ const content = fs.readFileSync(configPath, 'utf8');
48
+ return JSON.parse(content);
49
+ } catch (error) {
50
+ if (error instanceof SyntaxError) {
51
+ throw ConfigError.parseError(error);
52
+ }
53
+ throw new ConfigError(
54
+ `Failed to read configuration: ${error.message}`,
55
+ 'CONFIG_READ_ERROR',
56
+ { path: configPath }
57
+ );
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Load and validate configuration from file
63
+ * @param {string} [projectRoot] - Project root directory
64
+ * @returns {Object|null} - Validated config or null if not found
65
+ * @throws {ConfigError} - If config is invalid
66
+ */
67
+ function loadConfig(projectRoot) {
68
+ const raw = loadConfigRaw(projectRoot);
69
+
70
+ if (!raw) {
71
+ return null;
72
+ }
73
+
74
+ // Migrate old format if needed
75
+ return migrateConfig(raw);
76
+ }
77
+
78
+ /**
79
+ * Save configuration to file
80
+ * @param {Object} config - Configuration to save
81
+ * @param {string} [projectRoot] - Project root directory
82
+ * @throws {ConfigError} - If save fails
83
+ */
84
+ function saveConfig(config, projectRoot) {
85
+ const configPath = getConfigPath(projectRoot);
86
+
87
+ try {
88
+ // Validate before saving
89
+ const validated = validateConfig(config);
90
+ const content = JSON.stringify(validated, null, 2) + '\n';
91
+ fs.writeFileSync(configPath, content, 'utf8');
92
+ } catch (error) {
93
+ if (error instanceof ConfigError) {
94
+ throw error;
95
+ }
96
+ throw new ConfigError(
97
+ `Failed to save configuration: ${error.message}`,
98
+ 'CONFIG_WRITE_ERROR',
99
+ { path: configPath }
100
+ );
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Delete configuration file
106
+ * @param {string} [projectRoot] - Project root directory
107
+ * @returns {boolean} - True if deleted, false if didn't exist
108
+ */
109
+ function deleteConfig(projectRoot) {
110
+ const configPath = getConfigPath(projectRoot);
111
+
112
+ if (!fs.existsSync(configPath)) {
113
+ return false;
114
+ }
115
+
116
+ fs.unlinkSync(configPath);
117
+ return true;
118
+ }
119
+
120
+ /**
121
+ * Apply CLI arguments to configuration
122
+ * CLI args take precedence over config file values
123
+ * @param {Object} config - Base configuration
124
+ * @param {Object} cliArgs - CLI arguments
125
+ * @returns {Object} - Merged configuration
126
+ */
127
+ function applyCliArgs(config, cliArgs) {
128
+ const result = JSON.parse(JSON.stringify(config)); // Deep clone
129
+
130
+ // Server settings
131
+ if (cliArgs.mode !== undefined && cliArgs.mode !== null) {
132
+ result.server.mode = cliArgs.mode;
133
+ }
134
+ if (cliArgs.noServer) {
135
+ result.server.mode = 'none';
136
+ }
137
+ if (cliArgs.port !== undefined && cliArgs.port !== null) {
138
+ result.server.port = cliArgs.port;
139
+ }
140
+ if (cliArgs.staticDir !== undefined && cliArgs.staticDir !== null) {
141
+ result.server.staticDir = cliArgs.staticDir;
142
+ }
143
+ if (cliArgs.command !== undefined && cliArgs.command !== null) {
144
+ result.server.command = cliArgs.command;
145
+ }
146
+ if (cliArgs.restartOnSwitch !== undefined && cliArgs.restartOnSwitch !== null) {
147
+ result.server.restartOnSwitch = cliArgs.restartOnSwitch;
148
+ }
149
+
150
+ // Git settings
151
+ if (cliArgs.remote !== undefined && cliArgs.remote !== null) {
152
+ result.remoteName = cliArgs.remote;
153
+ }
154
+ if (cliArgs.autoPull !== undefined && cliArgs.autoPull !== null) {
155
+ result.autoPull = cliArgs.autoPull;
156
+ }
157
+ if (cliArgs.pollInterval !== undefined && cliArgs.pollInterval !== null) {
158
+ result.gitPollInterval = cliArgs.pollInterval;
159
+ }
160
+
161
+ // UI settings
162
+ if (cliArgs.sound !== undefined && cliArgs.sound !== null) {
163
+ result.soundEnabled = cliArgs.sound;
164
+ }
165
+ if (cliArgs.visibleBranches !== undefined && cliArgs.visibleBranches !== null) {
166
+ result.visibleBranches = cliArgs.visibleBranches;
167
+ }
168
+ if (cliArgs.casino !== undefined && cliArgs.casino !== null) {
169
+ result.casinoMode = cliArgs.casino;
170
+ }
171
+
172
+ return result;
173
+ }
174
+
175
+ /**
176
+ * Parse CLI arguments
177
+ * @param {string[]} [argv] - Command line arguments (defaults to process.argv.slice(2))
178
+ * @returns {Object} - Parsed arguments
179
+ */
180
+ function parseCliArgs(argv = process.argv.slice(2)) {
181
+ const result = {
182
+ // Server settings
183
+ mode: null,
184
+ noServer: false,
185
+ port: null,
186
+ staticDir: null,
187
+ command: null,
188
+ restartOnSwitch: null,
189
+ // Git settings
190
+ remote: null,
191
+ autoPull: null,
192
+ pollInterval: null,
193
+ // UI settings
194
+ sound: null,
195
+ visibleBranches: null,
196
+ casino: null,
197
+ // Special flags
198
+ init: false,
199
+ help: false,
200
+ version: false,
201
+ };
202
+
203
+ for (let i = 0; i < argv.length; i++) {
204
+ const arg = argv[i];
205
+
206
+ switch (arg) {
207
+ // Server settings
208
+ case '--mode':
209
+ result.mode = argv[++i];
210
+ break;
211
+ case '--no-server':
212
+ result.noServer = true;
213
+ break;
214
+ case '--port':
215
+ case '-p':
216
+ result.port = parseInt(argv[++i], 10);
217
+ break;
218
+ case '--static-dir':
219
+ result.staticDir = argv[++i];
220
+ break;
221
+ case '--command':
222
+ case '-c':
223
+ result.command = argv[++i];
224
+ break;
225
+ case '--restart-on-switch':
226
+ result.restartOnSwitch = true;
227
+ break;
228
+ case '--no-restart-on-switch':
229
+ result.restartOnSwitch = false;
230
+ break;
231
+
232
+ // Git settings
233
+ case '--remote':
234
+ case '-r':
235
+ result.remote = argv[++i];
236
+ break;
237
+ case '--auto-pull':
238
+ result.autoPull = true;
239
+ break;
240
+ case '--no-auto-pull':
241
+ result.autoPull = false;
242
+ break;
243
+ case '--poll-interval':
244
+ result.pollInterval = parseInt(argv[++i], 10) * 1000; // Convert seconds to ms
245
+ break;
246
+
247
+ // UI settings
248
+ case '--sound':
249
+ result.sound = true;
250
+ break;
251
+ case '--no-sound':
252
+ result.sound = false;
253
+ break;
254
+ case '--visible-branches':
255
+ result.visibleBranches = parseInt(argv[++i], 10);
256
+ break;
257
+ case '--casino':
258
+ result.casino = true;
259
+ break;
260
+ case '--no-casino':
261
+ result.casino = false;
262
+ break;
263
+
264
+ // Special flags
265
+ case '--init':
266
+ result.init = true;
267
+ break;
268
+ case '--help':
269
+ case '-h':
270
+ result.help = true;
271
+ break;
272
+ case '--version':
273
+ case '-v':
274
+ result.version = true;
275
+ break;
276
+ }
277
+ }
278
+
279
+ return result;
280
+ }
281
+
282
+ /**
283
+ * Ensure configuration exists
284
+ * Loads from file, runs wizard if needed, and applies CLI args
285
+ * @param {Object} cliArgs - CLI arguments
286
+ * @param {Object} [options] - Options
287
+ * @param {string} [options.projectRoot] - Project root directory
288
+ * @param {boolean} [options.interactive=true] - Allow interactive prompts
289
+ * @param {Function} [options.runWizard] - Wizard function to run if needed
290
+ * @returns {Promise<Object>} - Final configuration
291
+ */
292
+ async function ensureConfig(cliArgs, options = {}) {
293
+ const { projectRoot, interactive = true, runWizard } = options;
294
+
295
+ // Check if --init flag was passed (force reconfiguration)
296
+ if (cliArgs.init && runWizard) {
297
+ const config = await runWizard();
298
+ return applyCliArgs(config, cliArgs);
299
+ }
300
+
301
+ // Load existing config
302
+ let config = loadConfig(projectRoot);
303
+
304
+ // If no config exists
305
+ if (!config) {
306
+ if (runWizard && interactive && process.stdin.isTTY) {
307
+ config = await runWizard();
308
+ } else {
309
+ // Use defaults
310
+ config = getDefaultConfig();
311
+ }
312
+ }
313
+
314
+ // Apply CLI args over config
315
+ return applyCliArgs(config, cliArgs);
316
+ }
317
+
318
+ module.exports = {
319
+ CONFIG_FILE_NAME,
320
+ getConfigPath,
321
+ configExists,
322
+ loadConfigRaw,
323
+ loadConfig,
324
+ saveConfig,
325
+ deleteConfig,
326
+ applyCliArgs,
327
+ parseCliArgs,
328
+ ensureConfig,
329
+ };
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Configuration schema and defaults
3
+ * Defines the structure and validation for Git Watchtower configuration
4
+ */
5
+
6
+ const path = require('path');
7
+ const { ConfigError, ValidationError } = require('../utils/errors');
8
+
9
+ /**
10
+ * @typedef {'static' | 'command' | 'none'} ServerMode
11
+ */
12
+
13
+ /**
14
+ * @typedef {Object} ServerConfig
15
+ * @property {ServerMode} mode - Server mode
16
+ * @property {string} staticDir - Directory for static files
17
+ * @property {string} command - Command for command mode
18
+ * @property {number} port - Server port
19
+ * @property {boolean} restartOnSwitch - Restart on branch switch
20
+ */
21
+
22
+ /**
23
+ * @typedef {Object} Config
24
+ * @property {ServerConfig} server - Server configuration
25
+ * @property {string} remoteName - Git remote name
26
+ * @property {boolean} autoPull - Auto-pull enabled
27
+ * @property {number} gitPollInterval - Polling interval in ms
28
+ * @property {boolean} soundEnabled - Sound notifications enabled
29
+ * @property {number} visibleBranches - Number of visible branches
30
+ * @property {boolean} casinoMode - Casino mode enabled
31
+ */
32
+
33
+ /**
34
+ * Valid server modes
35
+ */
36
+ const SERVER_MODES = ['static', 'command', 'none'];
37
+
38
+ /**
39
+ * Configuration defaults
40
+ * @type {Config}
41
+ */
42
+ const DEFAULTS = {
43
+ server: {
44
+ mode: /** @type {ServerMode} */ ('static'),
45
+ staticDir: 'public',
46
+ command: '',
47
+ port: 3000,
48
+ restartOnSwitch: true,
49
+ },
50
+ remoteName: 'origin',
51
+ autoPull: true,
52
+ gitPollInterval: 5000,
53
+ soundEnabled: true,
54
+ visibleBranches: 7,
55
+ casinoMode: false,
56
+ };
57
+
58
+ /**
59
+ * Configuration limits
60
+ */
61
+ const LIMITS = {
62
+ port: { min: 1, max: 65535 },
63
+ gitPollInterval: { min: 1000, max: 300000 }, // 1s to 5min
64
+ visibleBranches: { min: 1, max: 50 },
65
+ };
66
+
67
+ /**
68
+ * Get default configuration
69
+ * @returns {Config}
70
+ */
71
+ function getDefaultConfig() {
72
+ return {
73
+ server: { ...DEFAULTS.server },
74
+ remoteName: DEFAULTS.remoteName,
75
+ autoPull: DEFAULTS.autoPull,
76
+ gitPollInterval: DEFAULTS.gitPollInterval,
77
+ soundEnabled: DEFAULTS.soundEnabled,
78
+ visibleBranches: DEFAULTS.visibleBranches,
79
+ casinoMode: DEFAULTS.casinoMode,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Validate a port number
85
+ * @param {*} port - Port to validate
86
+ * @returns {number}
87
+ * @throws {ValidationError}
88
+ */
89
+ function validatePort(port) {
90
+ const num = Number(port);
91
+ if (isNaN(num) || !Number.isInteger(num)) {
92
+ throw ValidationError.invalidPort(port);
93
+ }
94
+ if (num < LIMITS.port.min || num > LIMITS.port.max) {
95
+ throw ValidationError.invalidPort(port);
96
+ }
97
+ return num;
98
+ }
99
+
100
+ /**
101
+ * Validate server mode
102
+ * @param {*} mode - Mode to validate
103
+ * @returns {ServerMode}
104
+ * @throws {ConfigError}
105
+ */
106
+ function validateServerMode(mode) {
107
+ if (!SERVER_MODES.includes(mode)) {
108
+ throw ConfigError.invalid(
109
+ `Invalid server mode: "${mode}". Must be one of: ${SERVER_MODES.join(', ')}`,
110
+ { field: 'server.mode', value: mode, valid: SERVER_MODES }
111
+ );
112
+ }
113
+ return mode;
114
+ }
115
+
116
+ /**
117
+ * Validate poll interval
118
+ * @param {*} interval - Interval to validate
119
+ * @returns {number}
120
+ * @throws {ConfigError}
121
+ */
122
+ function validatePollInterval(interval) {
123
+ const num = Number(interval);
124
+ if (isNaN(num) || num < LIMITS.gitPollInterval.min || num > LIMITS.gitPollInterval.max) {
125
+ throw ConfigError.invalid(
126
+ `Invalid poll interval: ${interval}. Must be between ${LIMITS.gitPollInterval.min}ms and ${LIMITS.gitPollInterval.max}ms`,
127
+ { field: 'gitPollInterval', value: interval }
128
+ );
129
+ }
130
+ return Math.round(num);
131
+ }
132
+
133
+ /**
134
+ * Validate visible branches count
135
+ * @param {*} count - Count to validate
136
+ * @returns {number}
137
+ * @throws {ConfigError}
138
+ */
139
+ function validateVisibleBranches(count) {
140
+ const num = Number(count);
141
+ if (isNaN(num) || !Number.isInteger(num)) {
142
+ throw ConfigError.invalid(
143
+ `Invalid visible branches: ${count}. Must be an integer`,
144
+ { field: 'visibleBranches', value: count }
145
+ );
146
+ }
147
+ if (num < LIMITS.visibleBranches.min || num > LIMITS.visibleBranches.max) {
148
+ throw ConfigError.invalid(
149
+ `Invalid visible branches: ${count}. Must be between ${LIMITS.visibleBranches.min} and ${LIMITS.visibleBranches.max}`,
150
+ { field: 'visibleBranches', value: count }
151
+ );
152
+ }
153
+ return num;
154
+ }
155
+
156
+ /**
157
+ * Validate and normalize a full configuration object
158
+ * @param {Object} config - Configuration to validate
159
+ * @returns {Config}
160
+ * @throws {ConfigError}
161
+ */
162
+ function validateConfig(config) {
163
+ if (!config || typeof config !== 'object') {
164
+ throw ConfigError.invalid('Configuration must be an object');
165
+ }
166
+
167
+ const result = getDefaultConfig();
168
+
169
+ // Validate server config
170
+ if (config.server) {
171
+ if (typeof config.server !== 'object') {
172
+ throw ConfigError.invalid('server must be an object');
173
+ }
174
+
175
+ if (config.server.mode !== undefined) {
176
+ result.server.mode = validateServerMode(config.server.mode);
177
+ }
178
+
179
+ if (config.server.port !== undefined) {
180
+ result.server.port = validatePort(config.server.port);
181
+ }
182
+
183
+ if (config.server.staticDir !== undefined) {
184
+ if (typeof config.server.staticDir !== 'string') {
185
+ throw ConfigError.invalid('server.staticDir must be a string');
186
+ }
187
+ // Reject absolute paths and path traversal attempts
188
+ if (path.isAbsolute(config.server.staticDir)) {
189
+ throw ConfigError.invalid(
190
+ 'server.staticDir must be a relative path within the project',
191
+ { field: 'server.staticDir', value: config.server.staticDir }
192
+ );
193
+ }
194
+ const resolved = path.resolve(config.server.staticDir);
195
+ const cwd = process.cwd();
196
+ if (!resolved.startsWith(cwd + path.sep) && resolved !== cwd) {
197
+ throw ConfigError.invalid(
198
+ 'server.staticDir must not escape the project directory',
199
+ { field: 'server.staticDir', value: config.server.staticDir }
200
+ );
201
+ }
202
+ result.server.staticDir = config.server.staticDir;
203
+ }
204
+
205
+ if (config.server.command !== undefined) {
206
+ if (typeof config.server.command !== 'string') {
207
+ throw ConfigError.invalid('server.command must be a string');
208
+ }
209
+ // Reject commands containing shell injection patterns
210
+ const dangerousPatterns = /[|;&`$(){}]|>\s*\/|<\s*\//;
211
+ if (config.server.command && dangerousPatterns.test(config.server.command)) {
212
+ throw ConfigError.invalid(
213
+ 'server.command contains potentially dangerous shell characters (|;&`$(){}). ' +
214
+ 'Only simple commands like "npm run dev" are allowed.',
215
+ { field: 'server.command', value: config.server.command }
216
+ );
217
+ }
218
+ result.server.command = config.server.command;
219
+ }
220
+
221
+ if (config.server.restartOnSwitch !== undefined) {
222
+ result.server.restartOnSwitch = Boolean(config.server.restartOnSwitch);
223
+ }
224
+ }
225
+
226
+ // Validate Git settings
227
+ if (config.remoteName !== undefined) {
228
+ if (typeof config.remoteName !== 'string' || !config.remoteName.trim()) {
229
+ throw ConfigError.invalid('remoteName must be a non-empty string');
230
+ }
231
+ result.remoteName = config.remoteName.trim();
232
+ }
233
+
234
+ if (config.autoPull !== undefined) {
235
+ result.autoPull = Boolean(config.autoPull);
236
+ }
237
+
238
+ if (config.gitPollInterval !== undefined) {
239
+ result.gitPollInterval = validatePollInterval(config.gitPollInterval);
240
+ }
241
+
242
+ // Validate UI settings
243
+ if (config.soundEnabled !== undefined) {
244
+ result.soundEnabled = Boolean(config.soundEnabled);
245
+ }
246
+
247
+ if (config.visibleBranches !== undefined) {
248
+ result.visibleBranches = validateVisibleBranches(config.visibleBranches);
249
+ }
250
+
251
+ if (config.casinoMode !== undefined) {
252
+ result.casinoMode = Boolean(config.casinoMode);
253
+ }
254
+
255
+ return result;
256
+ }
257
+
258
+ /**
259
+ * Migrate old config format to new format
260
+ * @param {Object} config - Old config
261
+ * @returns {Config}
262
+ */
263
+ function migrateConfig(config) {
264
+ // Already in new format
265
+ if (config.server) {
266
+ return validateConfig(config);
267
+ }
268
+
269
+ // Convert old format to new
270
+ const newConfig = getDefaultConfig();
271
+
272
+ if (config.noServer) {
273
+ newConfig.server.mode = 'none';
274
+ }
275
+ if (config.port !== undefined) {
276
+ newConfig.server.port = validatePort(config.port);
277
+ }
278
+ if (config.staticDir !== undefined) {
279
+ newConfig.server.staticDir = config.staticDir;
280
+ }
281
+ if (config.gitPollInterval !== undefined) {
282
+ newConfig.gitPollInterval = validatePollInterval(config.gitPollInterval);
283
+ }
284
+ if (typeof config.soundEnabled === 'boolean') {
285
+ newConfig.soundEnabled = config.soundEnabled;
286
+ }
287
+ if (config.visibleBranches !== undefined) {
288
+ newConfig.visibleBranches = validateVisibleBranches(config.visibleBranches);
289
+ }
290
+
291
+ return newConfig;
292
+ }
293
+
294
+ module.exports = {
295
+ SERVER_MODES,
296
+ DEFAULTS,
297
+ LIMITS,
298
+ getDefaultConfig,
299
+ validatePort,
300
+ validateServerMode,
301
+ validatePollInterval,
302
+ validateVisibleBranches,
303
+ validateConfig,
304
+ migrateConfig,
305
+ };