@traisetech/autopilot 0.1.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.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Git helper module - Clean, testable Git operations
3
+ * Built by Praise Masunga (PraiseTechzw)
4
+ */
5
+
6
+ const { execa } = require('execa');
7
+
8
+ /**
9
+ * Get current branch name
10
+ * @param {string} root - Repository root path
11
+ * @returns {Promise<string|null>} Branch name or null on error
12
+ */
13
+ async function getBranch(root) {
14
+ try {
15
+ const { stdout } = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root });
16
+ return stdout.trim();
17
+ } catch (error) {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Check if repository has uncommitted changes
24
+ * @param {string} root - Repository root path
25
+ * @returns {Promise<boolean>} True if there are changes
26
+ */
27
+ async function hasChanges(root) {
28
+ try {
29
+ const { stdout } = await execa('git', ['status', '--porcelain'], { cwd: root });
30
+ return stdout.trim().length > 0;
31
+ } catch (error) {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Get porcelain status - parsed list of changed files
38
+ * @param {string} root - Repository root path
39
+ * @returns {Promise<{ok: boolean, files: string[], raw: string}>} Status object
40
+ */
41
+ async function getPorcelainStatus(root) {
42
+ try {
43
+ const { stdout } = await execa('git', ['status', '--porcelain'], { cwd: root });
44
+ const raw = stdout.trim();
45
+
46
+ if (!raw) {
47
+ return { ok: true, files: [], raw: '' };
48
+ }
49
+
50
+ const files = raw
51
+ .split(/\r?\n/)
52
+ .map(line => line.slice(3).trim()); // Remove status prefix (XY + space)
53
+
54
+ return { ok: true, files, raw };
55
+ } catch (error) {
56
+ return { ok: false, files: [], raw: error.message };
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Stage all changes (git add -A)
62
+ * @param {string} root - Repository root path
63
+ * @returns {Promise<{ok: boolean, stdout: string, stderr: string}>} Result object
64
+ */
65
+ async function addAll(root) {
66
+ try {
67
+ const { stdout, stderr } = await execa('git', ['add', '-A'], { cwd: root });
68
+ return { ok: true, stdout, stderr };
69
+ } catch (error) {
70
+ return { ok: false, stdout: '', stderr: error.message };
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Commit staged changes
76
+ * @param {string} root - Repository root path
77
+ * @param {string} message - Commit message
78
+ * @returns {Promise<{ok: boolean, stdout: string, stderr: string}>} Result object
79
+ */
80
+ async function commit(root, message) {
81
+ try {
82
+ const { stdout, stderr } = await execa('git', ['commit', '-m', message], { cwd: root });
83
+ return { ok: true, stdout, stderr };
84
+ } catch (error) {
85
+ return { ok: false, stdout: '', stderr: error.message };
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Fetch updates from remote
91
+ * @param {string} root - Repository root path
92
+ * @returns {Promise<{ok: boolean, stdout: string, stderr: string}>} Result object
93
+ */
94
+ async function fetch(root) {
95
+ try {
96
+ const { stdout, stderr } = await execa('git', ['fetch'], { cwd: root });
97
+ return { ok: true, stdout, stderr };
98
+ } catch (error) {
99
+ return { ok: false, stdout: '', stderr: error.message };
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Check if remote is ahead/behind
105
+ * @param {string} root - Repository root path
106
+ * @returns {Promise<{ok: boolean, ahead: boolean, behind: boolean, raw: string}>} Status object
107
+ */
108
+ async function isRemoteAhead(root) {
109
+ try {
110
+ const branch = await getBranch(root);
111
+ if (!branch) return { ok: false, ahead: false, behind: false, raw: 'No branch' };
112
+
113
+ // Ensure we have latest info
114
+ await fetch(root);
115
+
116
+ const { stdout } = await execa('git', ['rev-list', '--left-right', '--count', `${branch}...origin/${branch}`], { cwd: root });
117
+ const [aheadCount, behindCount] = stdout.trim().split(/\s+/).map(Number);
118
+
119
+ return {
120
+ ok: true,
121
+ ahead: aheadCount > 0,
122
+ behind: behindCount > 0,
123
+ raw: stdout.trim()
124
+ };
125
+ } catch (error) {
126
+ return { ok: false, ahead: false, behind: false, raw: error.message };
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Push changes to remote
132
+ * @param {string} root - Repository root path
133
+ * @param {string} branch - Branch to push
134
+ * @returns {Promise<{ok: boolean, stdout: string, stderr: string}>} Result object
135
+ */
136
+ async function push(root, branch) {
137
+ try {
138
+ const { stdout, stderr } = await execa('git', ['push', 'origin', branch], { cwd: root });
139
+ return { ok: true, stdout, stderr };
140
+ } catch (error) {
141
+ return { ok: false, stdout: '', stderr: error.message };
142
+ }
143
+ }
144
+
145
+ module.exports = {
146
+ getBranch,
147
+ hasChanges,
148
+ getPorcelainStatus,
149
+ addAll,
150
+ commit,
151
+ fetch,
152
+ isRemoteAhead,
153
+ push,
154
+ };
@@ -0,0 +1,38 @@
1
+ const logger = require('../utils/logger');
2
+
3
+ const validateConfig = (config) => {
4
+ const errors = [];
5
+
6
+ if (!config) {
7
+ errors.push('Configuration is missing');
8
+ return errors;
9
+ }
10
+
11
+ if (config.commitMessage && typeof config.commitMessage !== 'string') {
12
+ errors.push('commitMessage must be a string');
13
+ }
14
+
15
+ if (config.autoPush && typeof config.autoPush !== 'boolean') {
16
+ errors.push('autoPush must be a boolean');
17
+ }
18
+
19
+ if (config.ignore && !Array.isArray(config.ignore)) {
20
+ errors.push('ignore must be an array');
21
+ }
22
+
23
+ return errors;
24
+ };
25
+
26
+ const validateBeforeCommit = async (repoPath) => {
27
+ const errors = [];
28
+
29
+ // Add safety checks before committing
30
+ // For example, check for large files, sensitive files, etc.
31
+
32
+ return errors;
33
+ };
34
+
35
+ module.exports = {
36
+ validateConfig,
37
+ validateBeforeCommit,
38
+ };
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Autopilot Watcher Engine
3
+ * Built by Praise Masunga (PraiseTechzw)
4
+ */
5
+
6
+ const fs = require('fs-extra');
7
+ const path = require('path');
8
+ const chokidar = require('chokidar');
9
+ const { execa } = require('execa');
10
+ const logger = require('../utils/logger');
11
+ const git = require('./git');
12
+ const { generateCommitMessage } = require('./commit');
13
+ const { getIgnorePath } = require('../utils/paths');
14
+ const { savePid, removePid, registerProcessHandlers } = require('../utils/process');
15
+ const { loadConfig } = require('../config/loader');
16
+
17
+ class Watcher {
18
+ constructor(repoPath) {
19
+ this.repoPath = repoPath;
20
+ this.config = null;
21
+ this.watcher = null;
22
+ this.isWatching = false;
23
+ this.isProcessing = false;
24
+ this.debounceTimer = null;
25
+ this.lastCommitAt = 0;
26
+ this.logFilePath = path.join(repoPath, 'autopilot.log');
27
+ this.ignorePatterns = [];
28
+ }
29
+
30
+ /**
31
+ * Initialize and start the watcher
32
+ */
33
+ async start() {
34
+ try {
35
+ if (this.isWatching) {
36
+ logger.warn('Watcher is already running');
37
+ return;
38
+ }
39
+
40
+ // Initialize environment
41
+ await fs.ensureFile(this.logFilePath);
42
+ await savePid(this.repoPath);
43
+
44
+ this.logVerbose('Starting Autopilot watcher...');
45
+
46
+ // Load configuration
47
+ await this.reloadConfig();
48
+ await this.reloadIgnore();
49
+
50
+ // Initial safety check
51
+ const currentBranch = await git.getBranch(this.repoPath);
52
+ if (currentBranch && this.config.blockedBranches?.includes(currentBranch)) {
53
+ logger.error(`Branch '${currentBranch}' is blocked in config. Stopping.`);
54
+ this.logVerbose(`Blocked branch detected: ${currentBranch}`);
55
+ await this.stop();
56
+ return;
57
+ }
58
+
59
+ // Combine defaults + loaded patterns
60
+ const finalIgnored = [
61
+ ...this.ignorePatterns,
62
+ /(^|[\/\\])\.git([\/\\]|$)/, // Regex for .git folder
63
+ '**/autopilot.log',
64
+ '**/.autopilot.pid',
65
+ 'node_modules' // Sensible default
66
+ ];
67
+
68
+ // Start Chokidar
69
+ this.watcher = chokidar.watch(this.repoPath, {
70
+ ignored: finalIgnored,
71
+ ignoreInitial: true,
72
+ persistent: true,
73
+ awaitWriteFinish: {
74
+ stabilityThreshold: 1000,
75
+ pollInterval: 100,
76
+ }
77
+ });
78
+
79
+ this.watcher
80
+ .on('add', (path) => this.onFsEvent('add', path))
81
+ .on('change', (path) => this.onFsEvent('change', path))
82
+ .on('unlink', (path) => this.onFsEvent('unlink', path))
83
+ .on('error', (error) => this.handleError(error));
84
+
85
+ // Handle process signals
86
+ registerProcessHandlers(async () => {
87
+ await this.stop();
88
+ });
89
+
90
+ this.isWatching = true;
91
+ logger.success(`Autopilot is watching ${this.repoPath}`);
92
+ logger.info(`Logs: ${this.logFilePath}`);
93
+
94
+ } catch (error) {
95
+ logger.error(`Failed to start watcher: ${error.message}`);
96
+ this.logVerbose(`Start error: ${error.stack}`);
97
+ await this.stop();
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Stop the watcher
103
+ */
104
+ async stop() {
105
+ try {
106
+ if (this.debounceTimer) {
107
+ clearTimeout(this.debounceTimer);
108
+ this.debounceTimer = null;
109
+ }
110
+
111
+ if (this.watcher) {
112
+ await this.watcher.close();
113
+ this.watcher = null;
114
+ }
115
+
116
+ await removePid(this.repoPath);
117
+ this.isWatching = false;
118
+ logger.info('Watcher stopped');
119
+ } catch (error) {
120
+ logger.error(`Error stopping watcher: ${error.message}`);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Handle filesystem events
126
+ */
127
+ onFsEvent(type, filePath) {
128
+ if (this.isProcessing) return;
129
+
130
+ // Double check ignore (safety net)
131
+ if (filePath.includes('.git') || filePath.endsWith('autopilot.log')) return;
132
+
133
+ this.logVerbose(`File event: ${type} ${filePath}`);
134
+ this.scheduleProcess();
135
+ }
136
+
137
+ /**
138
+ * Schedule processing with debounce
139
+ */
140
+ scheduleProcess() {
141
+ const debounceMs = (this.config?.debounceSeconds || 5) * 1000;
142
+
143
+ if (this.debounceTimer) {
144
+ clearTimeout(this.debounceTimer);
145
+ }
146
+
147
+ this.debounceTimer = setTimeout(() => {
148
+ this.processChanges();
149
+ }, debounceMs);
150
+ }
151
+
152
+ /**
153
+ * Main processing loop
154
+ */
155
+ async processChanges() {
156
+ if (this.isProcessing) return;
157
+ this.isProcessing = true;
158
+
159
+ try {
160
+ // 1. Min interval check
161
+ const now = Date.now();
162
+ const minInterval = (this.config?.minIntervalSeconds || 30) * 1000;
163
+ if (now - this.lastCommitAt < minInterval) {
164
+ this.logVerbose('Skipping: Minimum interval not met');
165
+ return;
166
+ }
167
+
168
+ // 2. Safety: Branch check
169
+ const branch = await git.getBranch(this.repoPath);
170
+ if (this.config?.blockedBranches?.includes(branch)) {
171
+ logger.warn(`Current branch '${branch}' is blocked. Skipping.`);
172
+ return;
173
+ }
174
+
175
+ // 3. Safety: Remote check (fetch -> behind?)
176
+ this.logVerbose('Checking remote status...');
177
+ const remoteStatus = await git.isRemoteAhead(this.repoPath);
178
+ if (remoteStatus.behind) {
179
+ logger.warn('Local branch is behind remote. Please pull changes.');
180
+ this.logVerbose('Behind remote. Pausing auto-commit.');
181
+ return;
182
+ }
183
+
184
+ // 4. Safety: Custom checks
185
+ if (this.config?.requireChecks) {
186
+ const checksPassed = await this.runChecks();
187
+ if (!checksPassed) {
188
+ logger.warn('Checks failed. Skipping commit.');
189
+ return;
190
+ }
191
+ }
192
+
193
+ // 5. Flow: Status -> Add -> Commit -> Push
194
+ const status = await git.getPorcelainStatus(this.repoPath);
195
+ if (!status.ok || status.files.length === 0) {
196
+ this.logVerbose('No changes to commit');
197
+ return;
198
+ }
199
+
200
+ this.logVerbose(`Detecting ${status.files.length} changed files`);
201
+
202
+ // Add all
203
+ await git.addAll(this.repoPath);
204
+
205
+ // Generate message
206
+ const message = generateCommitMessage(status.files);
207
+ this.logVerbose(`Generated message: ${message}`);
208
+
209
+ // Commit
210
+ const commitResult = await git.commit(this.repoPath, message);
211
+ if (commitResult.ok) {
212
+ logger.success(`Committed: ${message}`);
213
+ this.lastCommitAt = Date.now();
214
+
215
+ // Push if enabled
216
+ if (this.config?.autoPush) {
217
+ logger.info('Pushing to remote...');
218
+ const pushResult = await git.push(this.repoPath, branch);
219
+ if (pushResult.ok) {
220
+ logger.success('Pushed successfully');
221
+ } else {
222
+ logger.error(`Push failed: ${pushResult.stderr}`);
223
+ this.logVerbose(`Push error: ${pushResult.stderr}`);
224
+ }
225
+ }
226
+ } else {
227
+ logger.error(`Commit failed: ${commitResult.stderr}`);
228
+ this.logVerbose(`Commit error: ${commitResult.stderr}`);
229
+ }
230
+
231
+ } catch (error) {
232
+ logger.error(`Process error: ${error.message}`);
233
+ this.logVerbose(`Process exception: ${error.stack}`);
234
+ } finally {
235
+ this.isProcessing = false;
236
+ this.debounceTimer = null;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Run user-defined checks
242
+ */
243
+ async runChecks() {
244
+ const checks = this.config?.checks || [];
245
+ if (checks.length === 0) return true;
246
+
247
+ this.logVerbose(`Running checks: ${checks.join(', ')}`);
248
+
249
+ for (const cmd of checks) {
250
+ try {
251
+ logger.info(`Running check: ${cmd}`);
252
+ await execa(cmd, { cwd: this.repoPath, shell: true });
253
+ } catch (error) {
254
+ logger.error(`Check failed: ${cmd}`);
255
+ this.logVerbose(`Check output: ${error.message}`);
256
+ return false;
257
+ }
258
+ }
259
+ return true;
260
+ }
261
+
262
+ /**
263
+ * Reload configuration
264
+ */
265
+ async reloadConfig() {
266
+ this.config = await loadConfig(this.repoPath);
267
+ this.logVerbose('Config reloaded');
268
+ }
269
+
270
+ /**
271
+ * Reload ignore patterns
272
+ */
273
+ async reloadIgnore() {
274
+ const ignorePath = getIgnorePath(this.repoPath);
275
+ this.ignorePatterns = [];
276
+
277
+ if (await fs.pathExists(ignorePath)) {
278
+ try {
279
+ const content = await fs.readFile(ignorePath, 'utf-8');
280
+ const lines = content.split('\n')
281
+ .map(l => l.trim())
282
+ .filter(l => l && !l.startsWith('#'));
283
+
284
+ this.ignorePatterns.push(...lines);
285
+ this.logVerbose(`Loaded ${lines.length} ignore patterns`);
286
+ } catch (error) {
287
+ logger.warn(`Failed to load ignore file: ${error.message}`);
288
+ }
289
+ }
290
+
291
+ // Also load ignore patterns from config json if they exist
292
+ if (this.config?.ignore && Array.isArray(this.config.ignore)) {
293
+ this.ignorePatterns.push(...this.config.ignore);
294
+ }
295
+ }
296
+
297
+ handleError(error) {
298
+ logger.error(`Watcher error: ${error.message}`);
299
+ this.logVerbose(`Watcher error: ${error.stack}`);
300
+ }
301
+
302
+ logVerbose(message) {
303
+ const timestamp = new Date().toISOString();
304
+ const logLine = `[${timestamp}] ${message}\n`;
305
+ fs.appendFile(this.logFilePath, logLine).catch(() => {});
306
+ }
307
+ }
308
+
309
+ module.exports = Watcher;
package/src/index.js ADDED
@@ -0,0 +1,50 @@
1
+ const { Command } = require('commander');
2
+ const { initRepo } = require('./commands/init');
3
+ const { startWatcher } = require('./commands/start');
4
+ const { stopWatcher } = require('./commands/stop');
5
+ const { statusWatcher } = require('./commands/status');
6
+ const { doctor } = require('./commands/doctor');
7
+ const pkg = require('../package.json');
8
+
9
+ function run() {
10
+ const program = new Command();
11
+
12
+ program
13
+ .name('autopilot')
14
+ .description('Git automation with safety rails')
15
+ .version(pkg.version, '-v, --version', 'Show version');
16
+
17
+ program
18
+ .command('init')
19
+ .description('Initialize autopilot configuration in repository')
20
+ .action(initRepo);
21
+
22
+ program
23
+ .command('start')
24
+ .description('Start autopilot watcher in foreground')
25
+ .action(startWatcher);
26
+
27
+ program
28
+ .command('stop')
29
+ .description('Stop the running autopilot watcher')
30
+ .action(stopWatcher);
31
+
32
+ program
33
+ .command('status')
34
+ .description('Show autopilot watcher status')
35
+ .action(statusWatcher);
36
+
37
+ program
38
+ .command('doctor')
39
+ .description('Diagnose and validate autopilot setup')
40
+ .action(doctor);
41
+
42
+ program
43
+ .addHelpText('after', '\nBuilt by Praise Masunga (PraiseTechzw)')
44
+ .addHelpCommand(true, 'Show help for command')
45
+ .showHelpAfterError('(add --help for command information)');
46
+
47
+ program.parse(process.argv);
48
+ }
49
+
50
+ module.exports = { run };
@@ -0,0 +1,6 @@
1
+ const pkg = require('../../package.json');
2
+
3
+ module.exports = () => {
4
+ console.log(`\nšŸš€ Autopilot CLI v${pkg.version}`);
5
+ console.log(` Built by Praise Masunga (PraiseTechzw)\n`);
6
+ };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Simple logger utility for consistent CLI output
3
+ * Built by Praise Masunga (PraiseTechzw)
4
+ */
5
+
6
+ const logger = {
7
+ /**
8
+ * Log informational message
9
+ * @param {string} message - Message to log
10
+ */
11
+ info: (message) => {
12
+ console.log(`ā„¹ļø ${message}`);
13
+ },
14
+
15
+ /**
16
+ * Log success message
17
+ * @param {string} message - Message to log
18
+ */
19
+ success: (message) => {
20
+ console.log(`āœ… ${message}`);
21
+ },
22
+
23
+ /**
24
+ * Log warning message
25
+ * @param {string} message - Message to log
26
+ */
27
+ warn: (message) => {
28
+ console.warn(`āš ļø ${message}`);
29
+ },
30
+
31
+ /**
32
+ * Log error message
33
+ * @param {string} message - Message to log
34
+ */
35
+ error: (message) => {
36
+ console.error(`āŒ ${message}`);
37
+ },
38
+
39
+ /**
40
+ * Log section header
41
+ * @param {string} title - Section title
42
+ */
43
+ section: (title) => {
44
+ console.log(`\n${title}`);
45
+ console.log('─'.repeat(50));
46
+ },
47
+ };
48
+
49
+ module.exports = logger;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Path utilities for Autopilot
3
+ * Built by Praise Masunga (PraiseTechzw)
4
+ */
5
+
6
+ const path = require('path');
7
+ const os = require('os');
8
+ const fs = require('fs-extra');
9
+
10
+ /**
11
+ * Get path to config file in repository
12
+ * @param {string} repoPath - Repository path
13
+ * @returns {string} Config file path
14
+ */
15
+ const getConfigPath = (repoPath) => {
16
+ return path.join(repoPath, '.autopilotrc.json');
17
+ };
18
+
19
+ /**
20
+ * Get path to ignore file in repository
21
+ * @param {string} repoPath - Repository path
22
+ * @returns {string} Ignore file path
23
+ */
24
+ const getIgnorePath = (repoPath) => {
25
+ return path.join(repoPath, '.autopilotignore');
26
+ };
27
+
28
+ /**
29
+ * Get path to .git directory
30
+ * @param {string} repoPath - Repository path
31
+ * @returns {string} .git directory path
32
+ */
33
+ const getGitPath = (repoPath) => {
34
+ return path.join(repoPath, '.git');
35
+ };
36
+
37
+ /**
38
+ * Get global config directory
39
+ * @returns {string} Config directory path
40
+ */
41
+ const getConfigDir = () => {
42
+ return path.join(os.homedir(), '.autopilot');
43
+ };
44
+
45
+ /**
46
+ * Ensure config directory exists
47
+ * @returns {Promise<void>}
48
+ */
49
+ const ensureConfigDir = async () => {
50
+ await fs.ensureDir(getConfigDir());
51
+ };
52
+
53
+ module.exports = {
54
+ getConfigPath,
55
+ getIgnorePath,
56
+ getGitPath,
57
+ getConfigDir,
58
+ ensureConfigDir,
59
+ };