marmot-logger 1.0.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.
- package/README.md +172 -0
- package/bin/marmot.js +71 -0
- package/package.json +34 -0
- package/src/cli/disable.js +40 -0
- package/src/cli/enable.js +40 -0
- package/src/cli/init.js +85 -0
- package/src/cli/log.js +102 -0
- package/src/cli/login.js +120 -0
- package/src/cli/logs.js +93 -0
- package/src/cli/monitor.js +32 -0
- package/src/cli/status.js +72 -0
- package/src/cli/verify.js +81 -0
- package/src/core/config.js +190 -0
- package/src/core/gitignore.js +109 -0
- package/src/core/logger.js +120 -0
- package/src/core/signer.js +136 -0
- package/src/index.js +20 -0
- package/src/plugins/claude-hooks.js +134 -0
- package/src/plugins/file-monitor.js +233 -0
- package/src/plugins/git-hooks.js +177 -0
- package/src/plugins/index.js +7 -0
- package/src/plugins/makefile.js +68 -0
- package/src/plugins/terminal.js +136 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
const https = require('https');
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const { URL } = require('url');
|
|
4
|
+
const config = require('./config');
|
|
5
|
+
|
|
6
|
+
function request(url, options, data) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const parsedUrl = new URL(url);
|
|
9
|
+
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
|
10
|
+
|
|
11
|
+
const req = protocol.request(url, options, (res) => {
|
|
12
|
+
let body = '';
|
|
13
|
+
res.on('data', (chunk) => body += chunk);
|
|
14
|
+
res.on('end', () => {
|
|
15
|
+
// Check for 401 Unauthorized
|
|
16
|
+
if (res.statusCode === 401) {
|
|
17
|
+
const err = new Error('Unauthorized');
|
|
18
|
+
err.statusCode = 401;
|
|
19
|
+
reject(err);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const json = JSON.parse(body);
|
|
25
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
26
|
+
resolve(json);
|
|
27
|
+
} else {
|
|
28
|
+
const err = new Error(json.detail || json.message || `HTTP ${res.statusCode}`);
|
|
29
|
+
err.statusCode = res.statusCode;
|
|
30
|
+
reject(err);
|
|
31
|
+
}
|
|
32
|
+
} catch (e) {
|
|
33
|
+
reject(new Error(`Invalid JSON response: ${body}`));
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
req.on('error', reject);
|
|
39
|
+
req.setTimeout(10000, () => {
|
|
40
|
+
req.destroy();
|
|
41
|
+
reject(new Error('Request timeout'));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (data) {
|
|
45
|
+
req.write(JSON.stringify(data));
|
|
46
|
+
}
|
|
47
|
+
req.end();
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getCachedToken(projectDir = process.cwd()) {
|
|
52
|
+
return config.getBearerToken(projectDir);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function refreshToken(projectDir = process.cwd()) {
|
|
56
|
+
const apiKey = config.getApiKey(projectDir);
|
|
57
|
+
if (!apiKey) {
|
|
58
|
+
throw new Error('MARMOT_API_KEY not found in .env or environment');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const signingUrl = config.getSigningUrl(projectDir);
|
|
62
|
+
const tokenUrl = `${signingUrl}/token`;
|
|
63
|
+
|
|
64
|
+
const result = await request(tokenUrl, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: {
|
|
67
|
+
'Content-Type': 'application/json'
|
|
68
|
+
}
|
|
69
|
+
}, { api_key: apiKey });
|
|
70
|
+
|
|
71
|
+
// Store token in config file
|
|
72
|
+
config.setBearerToken(result.token, projectDir);
|
|
73
|
+
|
|
74
|
+
return result.token;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function getToken(projectDir = process.cwd()) {
|
|
78
|
+
// First try cached token from config
|
|
79
|
+
const cached = getCachedToken(projectDir);
|
|
80
|
+
if (cached && cached !== 'broken-bearer') {
|
|
81
|
+
return cached;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Otherwise refresh
|
|
85
|
+
return refreshToken(projectDir);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function sign(entry, projectDir = process.cwd()) {
|
|
89
|
+
const token = await getToken(projectDir);
|
|
90
|
+
const signingUrl = config.getSigningUrl(projectDir);
|
|
91
|
+
const signUrl = `${signingUrl}/sign`;
|
|
92
|
+
|
|
93
|
+
return await request(signUrl, {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: {
|
|
96
|
+
'Content-Type': 'application/json',
|
|
97
|
+
'Authorization': `Bearer ${token}`
|
|
98
|
+
}
|
|
99
|
+
}, entry);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function verify(entry, projectDir = process.cwd()) {
|
|
103
|
+
const token = await getToken(projectDir);
|
|
104
|
+
const signingUrl = config.getSigningUrl(projectDir);
|
|
105
|
+
const verifyUrl = `${signingUrl}/verify`;
|
|
106
|
+
|
|
107
|
+
return await request(verifyUrl, {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: {
|
|
110
|
+
'Content-Type': 'application/json',
|
|
111
|
+
'Authorization': `Bearer ${token}`
|
|
112
|
+
}
|
|
113
|
+
}, entry);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function healthCheck(projectDir = process.cwd()) {
|
|
117
|
+
const signingUrl = config.getSigningUrl(projectDir);
|
|
118
|
+
const healthUrl = `${signingUrl}/health`;
|
|
119
|
+
|
|
120
|
+
return await request(healthUrl, {
|
|
121
|
+
method: 'GET'
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function clearTokenCache(projectDir = process.cwd()) {
|
|
126
|
+
config.setBearerToken('broken-bearer', projectDir);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = {
|
|
130
|
+
getToken,
|
|
131
|
+
refreshToken,
|
|
132
|
+
sign,
|
|
133
|
+
verify,
|
|
134
|
+
healthCheck,
|
|
135
|
+
clearTokenCache
|
|
136
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const config = require('./core/config');
|
|
2
|
+
const logger = require('./core/logger');
|
|
3
|
+
const signer = require('./core/signer');
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
// Configuration
|
|
7
|
+
loadConfig: config.loadConfig,
|
|
8
|
+
saveConfig: config.saveConfig,
|
|
9
|
+
createDefaultConfig: config.createDefaultConfig,
|
|
10
|
+
|
|
11
|
+
// Logging
|
|
12
|
+
log: logger.logEvent,
|
|
13
|
+
readLogs: logger.readLogs,
|
|
14
|
+
verifyLogs: logger.verifyLogs,
|
|
15
|
+
|
|
16
|
+
// Signing
|
|
17
|
+
sign: signer.sign,
|
|
18
|
+
verify: signer.verify,
|
|
19
|
+
healthCheck: signer.healthCheck
|
|
20
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
|
|
5
|
+
const CLAUDE_SETTINGS_DIR = '.claude';
|
|
6
|
+
const CLAUDE_SETTINGS_FILE = 'settings.local.json';
|
|
7
|
+
|
|
8
|
+
function getClaudeSettingsPath(projectDir = process.cwd()) {
|
|
9
|
+
return path.join(projectDir, CLAUDE_SETTINGS_DIR, CLAUDE_SETTINGS_FILE);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function loadClaudeSettings(projectDir = process.cwd()) {
|
|
13
|
+
const settingsPath = getClaudeSettingsPath(projectDir);
|
|
14
|
+
|
|
15
|
+
if (!fs.existsSync(settingsPath)) {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
21
|
+
} catch (e) {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function saveClaudeSettings(settings, projectDir = process.cwd()) {
|
|
27
|
+
const settingsDir = path.join(projectDir, CLAUDE_SETTINGS_DIR);
|
|
28
|
+
const settingsPath = getClaudeSettingsPath(projectDir);
|
|
29
|
+
|
|
30
|
+
if (!fs.existsSync(settingsDir)) {
|
|
31
|
+
fs.mkdirSync(settingsDir, { recursive: true });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createHookConfig(hookType, projectDir) {
|
|
38
|
+
return {
|
|
39
|
+
matcher: '*',
|
|
40
|
+
hooks: [
|
|
41
|
+
{
|
|
42
|
+
type: 'command',
|
|
43
|
+
command: `cd "${projectDir}" && marmot log claude-${hookType}`
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function enable(projectConfig) {
|
|
50
|
+
const projectDir = process.cwd();
|
|
51
|
+
const events = projectConfig.plugins?.['claude-hooks']?.events || [
|
|
52
|
+
'PreToolUse', 'PostToolUse', 'Stop', 'SubagentStop',
|
|
53
|
+
'UserPromptSubmit', 'SessionStart', 'SessionEnd'
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const settings = loadClaudeSettings(projectDir);
|
|
57
|
+
|
|
58
|
+
// Initialize hooks object if needed
|
|
59
|
+
if (!settings.hooks) {
|
|
60
|
+
settings.hooks = {};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const installed = [];
|
|
64
|
+
|
|
65
|
+
for (const event of events) {
|
|
66
|
+
// Check if hook already exists
|
|
67
|
+
if (settings.hooks[event]) {
|
|
68
|
+
const existingHooks = settings.hooks[event];
|
|
69
|
+
const hasMarmot = existingHooks.some(h =>
|
|
70
|
+
h.hooks?.some(hh => hh.command?.includes('marmot'))
|
|
71
|
+
);
|
|
72
|
+
if (hasMarmot) continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!settings.hooks[event]) {
|
|
76
|
+
settings.hooks[event] = [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
settings.hooks[event].push(createHookConfig(event, projectDir));
|
|
80
|
+
installed.push(event);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
saveClaudeSettings(settings, projectDir);
|
|
84
|
+
|
|
85
|
+
console.log('');
|
|
86
|
+
console.log(chalk.bold('Claude Hooks Plugin Setup:'));
|
|
87
|
+
console.log(` Settings file: ${chalk.cyan(getClaudeSettingsPath(projectDir))}`);
|
|
88
|
+
|
|
89
|
+
if (installed.length > 0) {
|
|
90
|
+
console.log(` Installed hooks: ${installed.join(', ')}`);
|
|
91
|
+
} else {
|
|
92
|
+
console.log(chalk.yellow(' All hooks were already installed.'));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log('');
|
|
96
|
+
console.log('Claude Code will now log activity when working in this project.');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function disable(projectConfig) {
|
|
100
|
+
const projectDir = process.cwd();
|
|
101
|
+
const settings = loadClaudeSettings(projectDir);
|
|
102
|
+
|
|
103
|
+
if (!settings.hooks) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Remove marmot hooks
|
|
108
|
+
for (const event of Object.keys(settings.hooks)) {
|
|
109
|
+
settings.hooks[event] = settings.hooks[event].filter(h =>
|
|
110
|
+
!h.hooks?.some(hh => hh.command?.includes('marmot'))
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// Clean up empty arrays
|
|
114
|
+
if (settings.hooks[event].length === 0) {
|
|
115
|
+
delete settings.hooks[event];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Clean up empty hooks object
|
|
120
|
+
if (Object.keys(settings.hooks).length === 0) {
|
|
121
|
+
delete settings.hooks;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
saveClaudeSettings(settings, projectDir);
|
|
125
|
+
|
|
126
|
+
console.log('');
|
|
127
|
+
console.log(chalk.bold('Claude Hooks Plugin Disabled:'));
|
|
128
|
+
console.log('Marmot hooks removed from Claude settings.');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
enable,
|
|
133
|
+
disable
|
|
134
|
+
};
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { execSync, spawnSync } = require('child_process');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
const config = require('../core/config');
|
|
6
|
+
const logger = require('../core/logger');
|
|
7
|
+
const gitignore = require('../core/gitignore');
|
|
8
|
+
|
|
9
|
+
const MARMOT_CRON_MARKER = '# marmot-monitor';
|
|
10
|
+
|
|
11
|
+
function getCrontab() {
|
|
12
|
+
try {
|
|
13
|
+
const result = execSync('crontab -l 2>/dev/null', { encoding: 'utf8' });
|
|
14
|
+
return result;
|
|
15
|
+
} catch (err) {
|
|
16
|
+
return '';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function setCrontab(content) {
|
|
21
|
+
try {
|
|
22
|
+
execSync(`echo "${content.replace(/"/g, '\\"')}" | crontab -`, { encoding: 'utf8' });
|
|
23
|
+
return true;
|
|
24
|
+
} catch (err) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function run(projectConfig, projectDir = process.cwd()) {
|
|
30
|
+
const snapshotDir = config.getSnapshotDir(projectConfig, projectDir);
|
|
31
|
+
const logDir = config.getLogDir(projectConfig, projectDir);
|
|
32
|
+
|
|
33
|
+
// Ensure log directory exists
|
|
34
|
+
if (!fs.existsSync(logDir)) {
|
|
35
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Ensure snapshot parent directory exists
|
|
39
|
+
const snapshotParent = path.dirname(snapshotDir);
|
|
40
|
+
if (!fs.existsSync(snapshotParent)) {
|
|
41
|
+
fs.mkdirSync(snapshotParent, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Build rsync exclude args from .gitignore
|
|
45
|
+
const excludeArgs = gitignore.buildRsyncExcludes(projectDir);
|
|
46
|
+
|
|
47
|
+
// First run: create snapshot
|
|
48
|
+
if (!fs.existsSync(snapshotDir)) {
|
|
49
|
+
try {
|
|
50
|
+
execSync(`rsync -a ${excludeArgs.join(' ')} "${projectDir}/" "${snapshotDir}/"`, {
|
|
51
|
+
stdio: 'pipe'
|
|
52
|
+
});
|
|
53
|
+
await logger.logEvent('monitor_initialized', projectDir, 0, {}, projectDir);
|
|
54
|
+
return 1;
|
|
55
|
+
} catch (err) {
|
|
56
|
+
throw new Error(`Failed to create snapshot: ${err.message}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Run git diff to detect changes
|
|
61
|
+
let diffOutput = '';
|
|
62
|
+
try {
|
|
63
|
+
const result = spawnSync('git', ['diff', '--no-index', '--name-status', snapshotDir, projectDir], {
|
|
64
|
+
encoding: 'utf8',
|
|
65
|
+
maxBuffer: 10 * 1024 * 1024
|
|
66
|
+
});
|
|
67
|
+
diffOutput = result.stdout || '';
|
|
68
|
+
} catch (err) {
|
|
69
|
+
// git diff returns exit code 1 when there are differences
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!diffOutput.trim()) {
|
|
73
|
+
return 0; // No changes
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let changesFound = 0;
|
|
77
|
+
const lines = diffOutput.trim().split('\n');
|
|
78
|
+
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
if (!line.trim()) continue;
|
|
81
|
+
|
|
82
|
+
const [status, ...pathParts] = line.split('\t');
|
|
83
|
+
const rawPath = pathParts.join('\t');
|
|
84
|
+
|
|
85
|
+
// Extract relative path
|
|
86
|
+
let relativePath = rawPath
|
|
87
|
+
.replace(snapshotDir + '/', '')
|
|
88
|
+
.replace(snapshotDir + '\\', '')
|
|
89
|
+
.replace(projectDir + '/', '')
|
|
90
|
+
.replace(projectDir + '\\', '');
|
|
91
|
+
|
|
92
|
+
const absPath = path.join(projectDir, relativePath);
|
|
93
|
+
|
|
94
|
+
// Skip excluded paths using gitignore patterns
|
|
95
|
+
if (gitignore.shouldExclude(relativePath, projectDir)) continue;
|
|
96
|
+
|
|
97
|
+
changesFound++;
|
|
98
|
+
|
|
99
|
+
switch (status) {
|
|
100
|
+
case 'A': // Added (created in current)
|
|
101
|
+
if (fs.existsSync(absPath) && fs.statSync(absPath).isFile()) {
|
|
102
|
+
const size = fs.statSync(absPath).size;
|
|
103
|
+
await logger.logEvent('created', absPath, size, {}, projectDir);
|
|
104
|
+
}
|
|
105
|
+
break;
|
|
106
|
+
|
|
107
|
+
case 'D': // Deleted
|
|
108
|
+
await logger.logEvent('deleted', absPath, 0, {}, projectDir);
|
|
109
|
+
break;
|
|
110
|
+
|
|
111
|
+
case 'M': // Modified
|
|
112
|
+
if (fs.existsSync(absPath) && fs.statSync(absPath).isFile()) {
|
|
113
|
+
const size = fs.statSync(absPath).size;
|
|
114
|
+
const snapshotFile = path.join(snapshotDir, relativePath);
|
|
115
|
+
|
|
116
|
+
let additions = 0;
|
|
117
|
+
let deletions = 0;
|
|
118
|
+
|
|
119
|
+
// Get additions/deletions using git diff --numstat
|
|
120
|
+
if (fs.existsSync(snapshotFile)) {
|
|
121
|
+
try {
|
|
122
|
+
const numstat = spawnSync('git', ['diff', '--no-index', '--numstat', snapshotFile, absPath], {
|
|
123
|
+
encoding: 'utf8'
|
|
124
|
+
});
|
|
125
|
+
const statLine = (numstat.stdout || '').trim().split('\n')[0];
|
|
126
|
+
if (statLine) {
|
|
127
|
+
const [add, del] = statLine.split('\t');
|
|
128
|
+
additions = add === '-' ? 0 : parseInt(add) || 0;
|
|
129
|
+
deletions = del === '-' ? 0 : parseInt(del) || 0;
|
|
130
|
+
}
|
|
131
|
+
} catch (e) {
|
|
132
|
+
// Ignore numstat errors
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
await logger.logEvent('modified', absPath, size, { additions, deletions }, projectDir);
|
|
137
|
+
}
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Update snapshot
|
|
143
|
+
if (changesFound > 0) {
|
|
144
|
+
try {
|
|
145
|
+
execSync(`rsync -a --delete ${excludeArgs.join(' ')} "${projectDir}/" "${snapshotDir}/"`, {
|
|
146
|
+
stdio: 'pipe'
|
|
147
|
+
});
|
|
148
|
+
} catch (err) {
|
|
149
|
+
// Ignore rsync errors
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return changesFound;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function enable(projectConfig) {
|
|
157
|
+
const projectDir = process.cwd();
|
|
158
|
+
const snapshotDir = config.getSnapshotDir(projectConfig, projectDir);
|
|
159
|
+
const marmotDir = config.getMarmotDir(projectDir);
|
|
160
|
+
const logDir = config.getLogDir(projectConfig, projectDir);
|
|
161
|
+
|
|
162
|
+
console.log('');
|
|
163
|
+
console.log(chalk.bold('File Monitor Plugin Setup:'));
|
|
164
|
+
console.log(` Marmot directory: ${chalk.cyan(marmotDir)}`);
|
|
165
|
+
console.log(` Snapshot directory: ${chalk.cyan(snapshotDir)}`);
|
|
166
|
+
console.log(` Exclusions: ${chalk.cyan('Using .gitignore')}`);
|
|
167
|
+
|
|
168
|
+
// Install cron job
|
|
169
|
+
const cronJob = `* * * * * cd "${projectDir}" && marmot monitor >> "${logDir}/cron.log" 2>&1 ${MARMOT_CRON_MARKER}`;
|
|
170
|
+
const currentCrontab = getCrontab();
|
|
171
|
+
|
|
172
|
+
if (currentCrontab.includes(projectDir) && currentCrontab.includes('marmot monitor')) {
|
|
173
|
+
console.log(` Cron job: ${chalk.yellow('Already installed')}`);
|
|
174
|
+
} else {
|
|
175
|
+
const newCrontab = currentCrontab.trim() + '\n' + cronJob + '\n';
|
|
176
|
+
if (setCrontab(newCrontab)) {
|
|
177
|
+
console.log(` Cron job: ${chalk.green('Installed (runs every minute)')}`);
|
|
178
|
+
} else {
|
|
179
|
+
console.log(` Cron job: ${chalk.red('Failed to install')}`);
|
|
180
|
+
console.log('');
|
|
181
|
+
console.log('To add manually:');
|
|
182
|
+
console.log(chalk.cyan(` crontab -e`));
|
|
183
|
+
console.log('Then add:');
|
|
184
|
+
console.log(chalk.cyan(` ${cronJob}`));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
console.log('');
|
|
189
|
+
console.log('Or run manually with:');
|
|
190
|
+
console.log(chalk.cyan(' marmot monitor'));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function disable(projectConfig) {
|
|
194
|
+
const projectDir = process.cwd();
|
|
195
|
+
const snapshotDir = config.getSnapshotDir(projectConfig, projectDir);
|
|
196
|
+
|
|
197
|
+
console.log('');
|
|
198
|
+
console.log(chalk.bold('File Monitor Plugin Disabled:'));
|
|
199
|
+
|
|
200
|
+
// Remove cron job
|
|
201
|
+
const currentCrontab = getCrontab();
|
|
202
|
+
if (currentCrontab.includes(projectDir) && currentCrontab.includes('marmot monitor')) {
|
|
203
|
+
const lines = currentCrontab.split('\n');
|
|
204
|
+
const filteredLines = lines.filter(line => {
|
|
205
|
+
if (line.includes(projectDir) && line.includes('marmot monitor')) return false;
|
|
206
|
+
if (line.includes(MARMOT_CRON_MARKER)) return false;
|
|
207
|
+
return true;
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const newCrontab = filteredLines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
211
|
+
|
|
212
|
+
if (setCrontab(newCrontab + '\n')) {
|
|
213
|
+
console.log(` Cron job: ${chalk.green('Removed')}`);
|
|
214
|
+
} else {
|
|
215
|
+
console.log(` Cron job: ${chalk.red('Failed to remove')}`);
|
|
216
|
+
console.log(' Remove manually with: crontab -e');
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
console.log(` Cron job: ${chalk.gray('Not found')}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Keep snapshot but inform user
|
|
223
|
+
if (fs.existsSync(snapshotDir)) {
|
|
224
|
+
console.log(` Snapshot: ${chalk.cyan(snapshotDir)} (preserved)`);
|
|
225
|
+
console.log(' To remove snapshot: rm -rf ' + snapshotDir);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = {
|
|
230
|
+
run,
|
|
231
|
+
enable,
|
|
232
|
+
disable
|
|
233
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
|
|
5
|
+
const MARMOT_MARKER = '# Marmot:';
|
|
6
|
+
|
|
7
|
+
const HOOKS = {
|
|
8
|
+
'post-commit': `#!/bin/bash
|
|
9
|
+
# Marmot: Log git commits
|
|
10
|
+
COMMIT_HASH=$(git rev-parse --short HEAD)
|
|
11
|
+
COMMIT_MSG=$(git log -1 --pretty=%s | head -c 100 | sed 's/"/\\\\"/g')
|
|
12
|
+
cd "{{PROJECT_DIR}}" && marmot log git-commit -d "{\\"path\\": \\"$COMMIT_HASH: $COMMIT_MSG\\"}" 2>/dev/null &
|
|
13
|
+
`,
|
|
14
|
+
|
|
15
|
+
'pre-push': `#!/bin/bash
|
|
16
|
+
# Marmot: Log git pushes
|
|
17
|
+
BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "detached")
|
|
18
|
+
REMOTE=$(git remote | head -1)
|
|
19
|
+
cd "{{PROJECT_DIR}}" && marmot log git-push -d "{\\"path\\": \\"$BRANCH -> $REMOTE\\"}" 2>/dev/null &
|
|
20
|
+
exit 0
|
|
21
|
+
`,
|
|
22
|
+
|
|
23
|
+
'post-checkout': `#!/bin/bash
|
|
24
|
+
# Marmot: Log git checkouts
|
|
25
|
+
# $3 is branch flag: 1 = branch checkout, 0 = file checkout
|
|
26
|
+
if [ "$3" = "1" ]; then
|
|
27
|
+
BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "detached")
|
|
28
|
+
cd "{{PROJECT_DIR}}" && marmot log git-checkout -d "{\\"path\\": \\"$BRANCH\\"}" 2>/dev/null &
|
|
29
|
+
fi
|
|
30
|
+
`,
|
|
31
|
+
|
|
32
|
+
'post-merge': `#!/bin/bash
|
|
33
|
+
# Marmot: Log git merges
|
|
34
|
+
BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "detached")
|
|
35
|
+
cd "{{PROJECT_DIR}}" && marmot log git-merge -d "{\\"path\\": \\"$BRANCH\\"}" 2>/dev/null &
|
|
36
|
+
`
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const HOOK_NAMES = ['post-commit', 'pre-push', 'post-checkout', 'post-merge'];
|
|
40
|
+
|
|
41
|
+
function getGitHooksDir(projectDir = process.cwd()) {
|
|
42
|
+
const gitDir = path.join(projectDir, '.git');
|
|
43
|
+
if (!fs.existsSync(gitDir)) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return path.join(gitDir, 'hooks');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function enable(projectConfig) {
|
|
50
|
+
const projectDir = process.cwd();
|
|
51
|
+
const hooksDir = getGitHooksDir(projectDir);
|
|
52
|
+
|
|
53
|
+
if (!hooksDir) {
|
|
54
|
+
console.log(chalk.yellow('Not a git repository. Git hooks not installed.'));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Ensure hooks directory exists
|
|
59
|
+
if (!fs.existsSync(hooksDir)) {
|
|
60
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const events = projectConfig.plugins?.['git-hooks']?.events || ['commit', 'push', 'checkout', 'merge'];
|
|
64
|
+
const installed = [];
|
|
65
|
+
|
|
66
|
+
for (const event of events) {
|
|
67
|
+
let hookName;
|
|
68
|
+
switch (event) {
|
|
69
|
+
case 'commit': hookName = 'post-commit'; break;
|
|
70
|
+
case 'push': hookName = 'pre-push'; break;
|
|
71
|
+
case 'checkout': hookName = 'post-checkout'; break;
|
|
72
|
+
case 'merge': hookName = 'post-merge'; break;
|
|
73
|
+
default: continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const hookPath = path.join(hooksDir, hookName);
|
|
77
|
+
const hookContent = HOOKS[hookName].replace(/\{\{PROJECT_DIR\}\}/g, projectDir);
|
|
78
|
+
|
|
79
|
+
// Check for existing hook
|
|
80
|
+
if (fs.existsSync(hookPath)) {
|
|
81
|
+
const existing = fs.readFileSync(hookPath, 'utf8');
|
|
82
|
+
// Skip if marmot hook already installed (check marker or marmot command)
|
|
83
|
+
if (existing.includes(MARMOT_MARKER) || existing.includes('marmot log')) {
|
|
84
|
+
installed.push(hookName);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// Append to existing non-marmot hook
|
|
88
|
+
fs.appendFileSync(hookPath, '\n' + hookContent);
|
|
89
|
+
} else {
|
|
90
|
+
fs.writeFileSync(hookPath, hookContent);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
fs.chmodSync(hookPath, '755');
|
|
94
|
+
installed.push(hookName);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
console.log('');
|
|
98
|
+
console.log(chalk.bold('Git Hooks Plugin Setup:'));
|
|
99
|
+
console.log(` Hooks directory: ${chalk.cyan(hooksDir)}`);
|
|
100
|
+
console.log(` Installed hooks: ${installed.join(', ')}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function disable(projectConfig) {
|
|
104
|
+
const projectDir = process.cwd();
|
|
105
|
+
const hooksDir = getGitHooksDir(projectDir);
|
|
106
|
+
|
|
107
|
+
if (!hooksDir) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
console.log('');
|
|
112
|
+
console.log(chalk.bold('Git Hooks Plugin Disabled:'));
|
|
113
|
+
|
|
114
|
+
let removed = 0;
|
|
115
|
+
|
|
116
|
+
for (const hookName of HOOK_NAMES) {
|
|
117
|
+
const hookPath = path.join(hooksDir, hookName);
|
|
118
|
+
|
|
119
|
+
if (!fs.existsSync(hookPath)) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const content = fs.readFileSync(hookPath, 'utf8');
|
|
124
|
+
|
|
125
|
+
// Skip if no marmot content (check marker or marmot command)
|
|
126
|
+
if (!content.includes(MARMOT_MARKER) && !content.includes('marmot log')) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Remove all lines containing marmot-related content
|
|
131
|
+
const lines = content.split('\n');
|
|
132
|
+
const filteredLines = lines.filter(line => {
|
|
133
|
+
// Keep shebang
|
|
134
|
+
if (line.startsWith('#!')) return true;
|
|
135
|
+
// Remove marmot marker comments
|
|
136
|
+
if (line.includes(MARMOT_MARKER)) return false;
|
|
137
|
+
// Remove marmot-specific comments (like "# $3 is branch flag")
|
|
138
|
+
if (line.includes('$3 is branch flag')) return false;
|
|
139
|
+
// Remove lines with marmot commands
|
|
140
|
+
if (line.includes('marmot log')) return false;
|
|
141
|
+
// Remove variable assignments used by marmot hooks
|
|
142
|
+
if (/^\s*(COMMIT_HASH|COMMIT_MSG|BRANCH|REMOTE)=/.test(line)) return false;
|
|
143
|
+
// Remove if blocks checking $3 (marmot checkout hook)
|
|
144
|
+
if (/^\s*if \[ "\$3"/.test(line)) return false;
|
|
145
|
+
// Remove fi statements (but only if this is a marmot-only hook)
|
|
146
|
+
if (line.trim() === 'fi' && content.includes('if [ "$3"')) return false;
|
|
147
|
+
// Remove exit 0 (marmot pre-push)
|
|
148
|
+
if (line.trim() === 'exit 0' && content.includes(MARMOT_MARKER)) return false;
|
|
149
|
+
// Keep everything else
|
|
150
|
+
return true;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Clean up the result
|
|
154
|
+
let cleanedContent = filteredLines.join('\n')
|
|
155
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
156
|
+
.trim();
|
|
157
|
+
|
|
158
|
+
// If only shebang remains (or empty), delete the file
|
|
159
|
+
if (!cleanedContent || cleanedContent === '#!/bin/bash' || cleanedContent.match(/^#!\/bin\/bash\s*$/)) {
|
|
160
|
+
fs.unlinkSync(hookPath);
|
|
161
|
+
console.log(` Removed: ${chalk.cyan(hookName)}`);
|
|
162
|
+
} else {
|
|
163
|
+
fs.writeFileSync(hookPath, cleanedContent + '\n');
|
|
164
|
+
console.log(` Cleaned: ${chalk.cyan(hookName)} (preserved non-marmot content)`);
|
|
165
|
+
}
|
|
166
|
+
removed++;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (removed === 0) {
|
|
170
|
+
console.log(' No marmot hooks found to remove.');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = {
|
|
175
|
+
enable,
|
|
176
|
+
disable
|
|
177
|
+
};
|