@misterhuydo/sentinel 1.3.5 → 1.3.7
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/.cairn/.hint-lock +1 -1
- package/.cairn/session.json +2 -2
- package/.cairn/views/244a09_generate.js +39 -107
- package/.cairn/views/2a85cc_init.js +339 -0
- package/lib/add.js +11 -20
- package/lib/generate.js +6 -2
- package/lib/init.js +3 -2
- package/package.json +1 -1
- package/python/sentinel/config_loader.py +2 -2
- package/python/sentinel/fix_engine.py +3 -2
- package/python/sentinel/git_manager.py +16 -2
- package/templates/repo-configs/_example.properties +0 -3
package/.cairn/.hint-lock
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-03-
|
|
1
|
+
2026-03-24T06:20:19.272Z
|
package/.cairn/session.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"message": "Auto-checkpoint at 2026-03-
|
|
3
|
-
"checkpoint_at": "2026-03-
|
|
2
|
+
"message": "Auto-checkpoint at 2026-03-24T06:16:50.325Z",
|
|
3
|
+
"checkpoint_at": "2026-03-24T06:16:50.326Z",
|
|
4
4
|
"active_files": [],
|
|
5
5
|
"notes": [],
|
|
6
6
|
"mtime_snapshot": {}
|
|
@@ -34,7 +34,7 @@ if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
|
|
34
34
|
exit 0
|
|
35
35
|
fi
|
|
36
36
|
# Kill any orphaned sentinel processes for this project (stale PIDs not in PID file)
|
|
37
|
-
pkill -f "sentinel.main --config $
|
|
37
|
+
pkill -f "sentinel.main --config $DIR/config" 2>/dev/null || true
|
|
38
38
|
rm -f "$PID_FILE"
|
|
39
39
|
# Check Claude Code authentication
|
|
40
40
|
AUTH_OUT=$(claude --print \"hi\" 2>&1 || true)
|
|
@@ -75,7 +75,7 @@ fi
|
|
|
75
75
|
rm -f "$PID_FILE"
|
|
76
76
|
`, { mode: 0o755 });
|
|
77
77
|
}
|
|
78
|
-
function generateWorkspaceScripts(workspace, smtpConfig = {}, slackConfig = {}) {
|
|
78
|
+
function generateWorkspaceScripts(workspace, smtpConfig = {}, slackConfig = {}, authConfig = {}, githubToken = '') {
|
|
79
79
|
const workspaceProps = path.join(workspace, 'sentinel.properties');
|
|
80
80
|
if (!fs.existsSync(workspaceProps)) {
|
|
81
81
|
const tplDir = path.join(__dirname, '..', 'templates');
|
|
@@ -86,122 +86,54 @@ function generateWorkspaceScripts(workspace, smtpConfig = {}, slackConfig = {})
|
|
|
86
86
|
if (smtpConfig.password) tpl = tpl.replace('SMTP_PASSWORD=<app-password>', 'SMTP_PASSWORD=' + smtpConfig.password);
|
|
87
87
|
fs.writeFileSync(workspaceProps, tpl);
|
|
88
88
|
}
|
|
89
|
+
if (authConfig.apiKey || authConfig.claudeProForTasks !== undefined) {
|
|
90
|
+
let props = fs.readFileSync(workspaceProps, 'utf8');
|
|
91
|
+
if (authConfig.apiKey) {
|
|
92
|
+
if (/^#?\s*ANTHROPIC_API_KEY=/m.test(props))
|
|
93
|
+
props = props.replace(/^#?\s*ANTHROPIC_API_KEY=.*/mg, 'ANTHROPIC_API_KEY=' + authConfig.apiKey);
|
|
94
|
+
else
|
|
95
|
+
props = props.trimEnd() + '\nANTHROPIC_API_KEY=' + authConfig.apiKey + '\n';
|
|
96
|
+
}
|
|
97
|
+
if (authConfig.claudeProForTasks !== undefined) {
|
|
98
|
+
const val = authConfig.claudeProForTasks ? 'true' : 'false';
|
|
99
|
+
if (/^#?\s*CLAUDE_PRO_FOR_TASKS=/m.test(props))
|
|
100
|
+
props = props.replace(/^#?\s*CLAUDE_PRO_FOR_TASKS=.*/mg, 'CLAUDE_PRO_FOR_TASKS=' + val);
|
|
101
|
+
else
|
|
102
|
+
props = props.trimEnd() + '\nCLAUDE_PRO_FOR_TASKS=' + val + '\n';
|
|
103
|
+
}
|
|
104
|
+
fs.writeFileSync(workspaceProps, props);
|
|
105
|
+
}
|
|
106
|
+
if (githubToken) {
|
|
107
|
+
let props = fs.readFileSync(workspaceProps, 'utf8');
|
|
108
|
+
if (/^#?\s*GITHUB_TOKEN=/m.test(props))
|
|
109
|
+
props = props.replace(/^#?\s*GITHUB_TOKEN=.*/mg, 'GITHUB_TOKEN=' + githubToken);
|
|
110
|
+
else
|
|
111
|
+
props = props.trimEnd() + '\nGITHUB_TOKEN=' + githubToken + '\n';
|
|
112
|
+
fs.writeFileSync(workspaceProps, props);
|
|
113
|
+
}
|
|
89
114
|
if (slackConfig.botToken || slackConfig.appToken) {
|
|
90
115
|
let props = fs.readFileSync(workspaceProps, 'utf8');
|
|
91
116
|
if (slackConfig.botToken) {
|
|
92
|
-
|
|
93
|
-
|
|
117
|
+
if (/^#?\s*SLACK_BOT_TOKEN=/m.test(props))
|
|
118
|
+
props = props.replace(/^#?\s*SLACK_BOT_TOKEN=.*/mg, 'SLACK_BOT_TOKEN=' + slackConfig.botToken);
|
|
119
|
+
else
|
|
120
|
+
props = props.trimEnd() + '\nSLACK_BOT_TOKEN=' + slackConfig.botToken + '\n';
|
|
94
121
|
}
|
|
95
122
|
if (slackConfig.appToken) {
|
|
96
|
-
|
|
97
|
-
|
|
123
|
+
if (/^#?\s*SLACK_APP_TOKEN=/m.test(props))
|
|
124
|
+
props = props.replace(/^#?\s*SLACK_APP_TOKEN=.*/mg, 'SLACK_APP_TOKEN=' + slackConfig.appToken);
|
|
125
|
+
else
|
|
126
|
+
props = props.trimEnd() + '\nSLACK_APP_TOKEN=' + slackConfig.appToken + '\n';
|
|
98
127
|
}
|
|
99
128
|
fs.writeFileSync(workspaceProps, props);
|
|
100
129
|
}
|
|
101
130
|
fs.writeFileSync(path.join(workspace, 'startAll.sh'), `#!/usr/bin/env bash
|
|
102
131
|
# Start all valid Sentinel project instances.
|
|
103
|
-
# A valid project must have config/repo-configs
|
|
104
|
-
|
|
105
|
-
[[ "$name" == "code" ]] && continue
|
|
106
|
-
# Auto-generate start.sh / stop.sh if missing (codeDir = $WORKSPACE/code)
|
|
107
|
-
if [[ ! -f "$project_dir/start.sh" ]]; then
|
|
108
|
-
code_dir="$WORKSPACE/code"
|
|
109
|
-
python_bin="$code_dir/.venv/bin/python3"
|
|
110
|
-
sed -e "s|__NAME__|$name|g" -e "s|__CODE_DIR__|$code_dir|g" -e "s|__PYTHON_BIN__|$python_bin|g" << 'STARTSH' > "$project_dir/start.sh"
|
|
111
|
-
#!/usr/bin/env bash
|
|
112
|
-
set -euo pipefail
|
|
113
|
-
DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
114
|
-
PID_FILE="$DIR/sentinel.pid"
|
|
115
|
-
if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
|
116
|
-
echo "[sentinel] __NAME__ already running (PID $(cat "$PID_FILE"))"
|
|
117
|
-
exit 0
|
|
118
|
-
fi
|
|
119
|
-
pkill -f "sentinel.main --config ${DIR}/config" 2>/dev/null || true
|
|
120
|
-
rm -f "$PID_FILE"
|
|
121
|
-
AUTH_OUT=$(claude --print \"hi\" 2>&1 || true)
|
|
122
|
-
if echo "$AUTH_OUT" | grep -Eqi "not logged in|/login"; then
|
|
123
|
-
echo "[sentinel] Claude Code is not authenticated. Run: claude then /login"
|
|
124
|
-
exit 1
|
|
125
|
-
fi
|
|
126
|
-
mkdir -p "$DIR/logs" "$DIR/workspace/fetched" "$DIR/workspace/patches" "$DIR/issues"
|
|
127
|
-
cd "$DIR"
|
|
128
|
-
PYTHONPATH="__CODE_DIR__" "__PYTHON_BIN__" -m sentinel.main --config ./config \
|
|
129
|
-
>> "$DIR/logs/sentinel.log" 2>&1 &
|
|
130
|
-
echo $! > "$PID_FILE"
|
|
131
|
-
echo "[sentinel] __NAME__ started (PID $!)"
|
|
132
|
-
STARTSH
|
|
133
|
-
chmod +x "$project_dir/start.sh"
|
|
134
|
-
echo "[sentinel] Auto-generated start.sh for $name"
|
|
135
|
-
fi
|
|
136
|
-
if [[ ! -f "$project_dir/stop.sh" ]]; then
|
|
137
|
-
sed -e "s|__NAME__|$name|g" << 'STOPSH' > "$project_dir/stop.sh"
|
|
138
|
-
#!/usr/bin/env bash
|
|
139
|
-
set -euo pipefail
|
|
140
|
-
DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
141
|
-
PID_FILE="$DIR/sentinel.pid"
|
|
142
|
-
if [[ ! -f "$PID_FILE" ]]; then
|
|
143
|
-
echo "[sentinel] __NAME__ — no PID file, not running"
|
|
144
|
-
exit 0
|
|
145
|
-
fi
|
|
146
|
-
PID=$(cat "$PID_FILE")
|
|
147
|
-
if kill -0 "$PID" 2>/dev/null; then
|
|
148
|
-
kill "$PID"
|
|
149
|
-
echo "[sentinel] __NAME__ stopped (PID $PID)"
|
|
150
|
-
else
|
|
151
|
-
echo "[sentinel] __NAME__ — PID $PID not running"
|
|
152
|
-
fi
|
|
153
|
-
rm -f "$PID_FILE"
|
|
154
|
-
STOPSH
|
|
155
|
-
chmod +x "$project_dir/stop.sh"
|
|
156
|
-
echo "[sentinel] Auto-generated stop.sh for $name"
|
|
157
|
-
fi
|
|
158
|
-
# Must have at least one repo-config with a valid GitHub REPO_URL
|
|
159
|
-
repo_configs_dir="$project_dir/config/repo-configs"
|
|
160
|
-
if [[ ! -d "$repo_configs_dir" ]]; then
|
|
161
|
-
echo "[sentinel] Skipping $name — config/repo-configs/ directory not found"
|
|
162
|
-
skipped=$((skipped + 1))
|
|
163
|
-
continue
|
|
164
|
-
fi
|
|
165
|
-
has_config=false
|
|
166
|
-
valid_repo=false
|
|
167
|
-
for props in "$repo_configs_dir/"*.properties; do
|
|
168
|
-
[[ -f "$props" ]] || continue
|
|
169
|
-
[[ "$(basename "$props")" == _* ]] && continue
|
|
170
|
-
has_config=true
|
|
171
|
-
if grep -qE "^REPO_URL[[:space:]]*=[[:space:]]*(git@github\.com:|https://github\.com/)" "$props"; then
|
|
172
|
-
valid_repo=true
|
|
173
|
-
break
|
|
174
|
-
else
|
|
175
|
-
repo_url=$(grep -E "^REPO_URL[[:space:]]*=" "$props" | head -1 | cut -d= -f2- | xargs 2>/dev/null || true)
|
|
176
|
-
if [[ -z "$repo_url" ]]; then
|
|
177
|
-
echo "[sentinel] Skipping $name — REPO_URL not set in $(basename \"$props\")"
|
|
178
|
-
else
|
|
179
|
-
echo "[sentinel] Skipping $name — REPO_URL in $(basename \"$props\") is not a GitHub URL: $repo_url"
|
|
180
|
-
fi
|
|
181
|
-
fi
|
|
182
|
-
done
|
|
183
|
-
if [[ "$has_config" == "false" ]]; then
|
|
184
|
-
echo "[sentinel] Skipping $name — no .properties files in config/repo-configs/ (only _example?)"
|
|
185
|
-
skipped=$((skipped + 1))
|
|
186
|
-
continue
|
|
187
|
-
fi
|
|
188
|
-
if [[ "$valid_repo" == "false" ]]; then
|
|
189
|
-
skipped=$((skipped + 1))
|
|
190
|
-
continue
|
|
191
|
-
fi
|
|
192
|
-
bash "$project_dir/start.sh"
|
|
193
|
-
started=$((started + 1))
|
|
194
|
-
done
|
|
195
|
-
echo "[sentinel] $started project(s) started, $skipped skipped"
|
|
196
|
-
`, { mode: 0o755 });
|
|
197
|
-
// stopAll.sh
|
|
198
|
-
fs.writeFileSync(path.join(workspace, 'stopAll.sh'), `#!/usr/bin/env bash
|
|
199
|
-
# Stop all Sentinel project instances
|
|
200
|
-
WORKSPACE="$(cd "$(dirname "$0")" && pwd)"
|
|
201
|
-
stopped=0
|
|
202
|
-
for project_dir in "$WORKSPACE"/*/; do
|
|
132
|
+
# A valid project must have config/repo-configs "$WORKSPACE"/repos "$WORKSPACE"/repos/*/; do
|
|
133
|
+
[[ -d "$project_dir" ]] || continue
|
|
203
134
|
name=$(basename "$project_dir")
|
|
204
|
-
[[ "$name" == "code" ]]
|
|
135
|
+
[[ "$name" == "code" ]] && continue
|
|
136
|
+
[[ "$name" == "repos" ]] && continue
|
|
205
137
|
[[ -f "$project_dir/stop.sh" ]] || continue
|
|
206
138
|
bash "$project_dir/stop.sh"
|
|
207
139
|
stopped=$((stopped + 1))
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const { execSync, spawnSync } = require('child_process');
|
|
6
|
+
const prompts = require('prompts');
|
|
7
|
+
const chalk = require('chalk');
|
|
8
|
+
const { generateProjectScripts, generateWorkspaceScripts, writeExampleProject } = require('./generate');
|
|
9
|
+
const ok = msg => console.log(chalk.green(' ✔'), msg);
|
|
10
|
+
const info = msg => console.log(chalk.cyan(' →'), msg);
|
|
11
|
+
const warn = msg => console.log(chalk.yellow(' ⚠'), msg);
|
|
12
|
+
const step = msg => console.log('\n' + chalk.bold.white(msg));
|
|
13
|
+
module.exports = async function init() {
|
|
14
|
+
const defaultWorkspace = path.join(os.homedir(), 'sentinel');
|
|
15
|
+
const existing = readExistingConfig(defaultWorkspace);
|
|
16
|
+
if (Object.keys(existing).length) {
|
|
17
|
+
console.log(chalk.cyan('\n → Existing workspace config found — showing current values as defaults\n'));
|
|
18
|
+
}
|
|
19
|
+
const answers = await prompts([
|
|
20
|
+
{
|
|
21
|
+
type: 'text',
|
|
22
|
+
name: 'workspace',
|
|
23
|
+
message: 'Workspace directory (each project lives here as a subdirectory)',
|
|
24
|
+
initial: path.join(os.homedir(), 'sentinel'),
|
|
25
|
+
format: v => v.replace(/^~/, os.homedir()),
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
type: 'select',
|
|
29
|
+
name: 'authMode',
|
|
30
|
+
message: 'Claude authentication strategy',
|
|
31
|
+
hint: 'Boss = Slack AI; Fix Engine = autonomous code repair',
|
|
32
|
+
choices: [
|
|
33
|
+
{
|
|
34
|
+
title: 'Both (RECOMMENDED) — API key for Boss, Claude Pro for Fix Engine',
|
|
35
|
+
value: 'both',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
title: 'API key only — full Boss tools; Fix Engine billed per token',
|
|
39
|
+
value: 'apikey',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
title: 'Claude Pro / OAuth only — run `claude login`; Boss has limited tools',
|
|
43
|
+
value: 'oauth',
|
|
44
|
+
},
|
|
45
|
+
{ title: 'Skip (configure manually in sentinel.properties)', value: 'skip' },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
type: prev => (prev === 'apikey' || prev === 'both') ? 'password' : null,
|
|
50
|
+
name: 'anthropicKey',
|
|
51
|
+
message: existing.ANTHROPIC_API_KEY
|
|
52
|
+
? 'Anthropic API key (press Enter to keep current)'
|
|
53
|
+
: 'Anthropic API key (sk-ant-...)',
|
|
54
|
+
validate: v => !v || v.startsWith('sk-ant-') ? true : 'Key should start with sk-ant-',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
type: 'confirm',
|
|
58
|
+
name: 'example',
|
|
59
|
+
message: 'Create an example project to show how to configure?',
|
|
60
|
+
initial: true,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
type: 'confirm',
|
|
64
|
+
name: 'systemd',
|
|
65
|
+
message: 'Set up systemd service for auto-start on reboot?',
|
|
66
|
+
initial: process.platform === 'linux',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
type: 'text',
|
|
70
|
+
name: 'smtpUser',
|
|
71
|
+
message: 'SMTP sender address',
|
|
72
|
+
initial: existing.SMTP_USER || 'sentinel@yourdomain.com',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
type: prev => prev ? 'password' : null,
|
|
76
|
+
name: 'smtpPassword',
|
|
77
|
+
message: existing.SMTP_PASSWORD ? 'SMTP password / app password (press Enter to keep current)' : 'SMTP password / app password',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
type: prev => prev ? 'text' : null,
|
|
81
|
+
name: 'smtpHost',
|
|
82
|
+
message: 'SMTP host',
|
|
83
|
+
initial: existing.SMTP_HOST || 'smtp.gmail.com',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
type: 'confirm',
|
|
87
|
+
name: 'setupSlack',
|
|
88
|
+
message: 'Set up Slack Bot (Sentinel Boss — conversational AI interface)?',
|
|
89
|
+
initial: !!(existing.SLACK_BOT_TOKEN),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
type: prev => prev ? 'password' : null,
|
|
93
|
+
name: 'slackBotToken',
|
|
94
|
+
message: existing.SLACK_BOT_TOKEN ? 'Slack Bot Token (press Enter to keep current)' : 'Slack Bot Token (xoxb-...)',
|
|
95
|
+
validate: v => !v || v.startsWith('xoxb-') ? true : 'Should start with xoxb-',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
type: (_, { setupSlack }) => setupSlack ? 'password' : null,
|
|
99
|
+
name: 'slackAppToken',
|
|
100
|
+
message: existing.SLACK_APP_TOKEN ? 'Slack App-Level Token (press Enter to keep current)' : 'Slack App-Level Token (xapp-...)',
|
|
101
|
+
validate: v => !v || v.startsWith('xapp-') ? true : 'Should start with xapp-',
|
|
102
|
+
},
|
|
103
|
+
], { onCancel: () => process.exit(0) });
|
|
104
|
+
const { workspace, authMode, anthropicKey, example, systemd, smtpUser, smtpPassword, smtpHost, setupSlack, slackBotToken, slackAppToken } = answers;
|
|
105
|
+
const effectiveAnthropicKey = anthropicKey || existing.ANTHROPIC_API_KEY || '';
|
|
106
|
+
const effectiveSmtpPassword = smtpPassword || existing.SMTP_PASSWORD || '';
|
|
107
|
+
const effectiveSlackBotToken = slackBotToken || existing.SLACK_BOT_TOKEN || '';
|
|
108
|
+
const effectiveSlackAppToken = slackAppToken || existing.SLACK_APP_TOKEN || '';
|
|
109
|
+
const codeDir = path.join(workspace, 'code');
|
|
110
|
+
step('Checking Python…');
|
|
111
|
+
const python = findPython();
|
|
112
|
+
if (!python) {
|
|
113
|
+
console.error(chalk.red(' ✖ python3 not found. Install it first:'));
|
|
114
|
+
console.error(' sudo apt-get install -y python3 python3-pip python3-venv');
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
ok(`Python: ${run(python, ['--version']).trim()}`);
|
|
118
|
+
step('Installing Sentinel code…');
|
|
119
|
+
const bundledPython = path.join(__dirname, '..', 'python');
|
|
120
|
+
if (!fs.existsSync(bundledPython)) {
|
|
121
|
+
console.error(chalk.red(' ✖ Bundled Python source not found (run npm publish from the Sentinel repo)'));
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
fs.ensureDirSync(codeDir);
|
|
125
|
+
fs.copySync(bundledPython, codeDir, { overwrite: true });
|
|
126
|
+
ok(`Sentinel code → ${codeDir}`);
|
|
127
|
+
step('Setting up Python environment…');
|
|
128
|
+
const venv = path.join(codeDir, '.venv');
|
|
129
|
+
if (!fs.existsSync(venv)) {
|
|
130
|
+
info('Creating virtual environment…');
|
|
131
|
+
runLive(python, ['-m', 'venv', venv]);
|
|
132
|
+
}
|
|
133
|
+
const pip = path.join(venv, 'bin', 'pip3');
|
|
134
|
+
const pythonBin = path.join(venv, 'bin', 'python3');
|
|
135
|
+
info('Installing Python packages…');
|
|
136
|
+
runLive(pip, ['install', '--quiet', '--upgrade', 'pip']);
|
|
137
|
+
runLive(pip, ['install', '--quiet', '-r', path.join(codeDir, 'requirements.txt')]);
|
|
138
|
+
ok('Python packages installed');
|
|
139
|
+
step('Installing Node tools…');
|
|
140
|
+
installNpmGlobal('@misterhuydo/cairn-mcp', 'cairn');
|
|
141
|
+
installNpmGlobal('@anthropic-ai/claude-code', 'claude');
|
|
142
|
+
info('Hooking Cairn MCP into Claude Code…');
|
|
143
|
+
runLive('cairn', ['install']);
|
|
144
|
+
step('Patching Claude Code permissions…');
|
|
145
|
+
ensureClaudePermissions();
|
|
146
|
+
step('Claude Code authentication…');
|
|
147
|
+
if (authMode === 'both' && effectiveAnthropicKey) {
|
|
148
|
+
ok('API key → Sentinel Boss (full tools, structured responses)');
|
|
149
|
+
ok('Claude Pro → Fix Engine + Ask Codebase (heavy coding, Pro subscription)');
|
|
150
|
+
info('Run `claude login` on this server now (or before starting projects)');
|
|
151
|
+
info('CLAUDE_PRO_FOR_TASKS=true written to workspace sentinel.properties');
|
|
152
|
+
} else if (authMode === 'apikey' && effectiveAnthropicKey) {
|
|
153
|
+
ok('API key → all Claude usage (Boss + Fix Engine billed to your API quota)');
|
|
154
|
+
info('CLAUDE_PRO_FOR_TASKS=false written — Fix Engine will use API key');
|
|
155
|
+
warn('Heavy fix tasks will consume API tokens. Claude Pro is cheaper for those.');
|
|
156
|
+
} else if (authMode === 'oauth') {
|
|
157
|
+
ok('Claude Pro / OAuth → Fix Engine + Ask Codebase');
|
|
158
|
+
warn('Boss will use CLI fallback — some tools unavailable without an API key');
|
|
159
|
+
info('Run `claude login` on this server to authenticate');
|
|
160
|
+
} else {
|
|
161
|
+
warn('No auth configured — add ANTHROPIC_API_KEY or run `claude login` before starting');
|
|
162
|
+
info('See: workspace sentinel.properties for full auth documentation');
|
|
163
|
+
}
|
|
164
|
+
if (effectiveSlackBotToken && effectiveSlackAppToken) {
|
|
165
|
+
step('Slack Bot (Sentinel Boss)…');
|
|
166
|
+
ok('Tokens will be written to workspace sentinel.properties');
|
|
167
|
+
info('Sentinel Boss starts automatically when the project starts');
|
|
168
|
+
} else if (setupSlack) {
|
|
169
|
+
warn('Slack tokens not provided — add them to config/sentinel.properties later');
|
|
170
|
+
}
|
|
171
|
+
step('Creating workspace…');
|
|
172
|
+
fs.ensureDirSync(workspace);
|
|
173
|
+
ok(`Workspace: ${workspace}`);
|
|
174
|
+
if (example) {
|
|
175
|
+
step('Creating example project…');
|
|
176
|
+
const exampleDir = path.join(workspace, 'my-project');
|
|
177
|
+
writeExampleProject(exampleDir, codeDir, pythonBin, effectiveAnthropicKey, { botToken: effectiveSlackBotToken, appToken: effectiveSlackAppToken });
|
|
178
|
+
ok(`Example project: ${exampleDir}`);
|
|
179
|
+
}
|
|
180
|
+
step('Generating scripts…');
|
|
181
|
+
const authConfig = {};
|
|
182
|
+
if (effectiveAnthropicKey) authConfig.apiKey = effectiveAnthropicKey;
|
|
183
|
+
if (authMode === 'both' || authMode === 'oauth') authConfig.claudeProForTasks = true;
|
|
184
|
+
if (authMode === 'apikey') authConfig.claudeProForTasks = false;
|
|
185
|
+
generateWorkspaceScripts(workspace, { host: smtpHost, user: smtpUser, password: effectiveSmtpPassword }, { botToken: effectiveSlackBotToken, appToken: effectiveSlackAppToken }, authConfig);
|
|
186
|
+
ok(`${workspace}/startAll.sh`);
|
|
187
|
+
ok(`${workspace}/stopAll.sh`);
|
|
188
|
+
if (systemd) {
|
|
189
|
+
step('Setting up systemd…');
|
|
190
|
+
setupSystemd(workspace);
|
|
191
|
+
}
|
|
192
|
+
console.log('\n' + chalk.bold.green('══ Done! ══') + '\n');
|
|
193
|
+
console.log(`${chalk.bold('Next steps:')}`);
|
|
194
|
+
if (example) {
|
|
195
|
+
console.log(`
|
|
196
|
+
1. Configure your first project:
|
|
197
|
+
${chalk.cyan(`${workspace}/my-project/config/sentinel.properties`)}
|
|
198
|
+
${chalk.cyan(`${workspace}/my-project/config/log-configs/`)}
|
|
199
|
+
${chalk.cyan(`${workspace}/my-project/config/repo-configs/`)}
|
|
200
|
+
2. Start all projects (Sentinel clones repos, indexes, and monitors automatically):
|
|
201
|
+
${chalk.cyan(`${workspace}/startAll.sh`)}
|
|
202
|
+
3. Stop all projects:
|
|
203
|
+
${chalk.cyan(`${workspace}/stopAll.sh`)}
|
|
204
|
+
`);
|
|
205
|
+
}
|
|
206
|
+
if (systemd) {
|
|
207
|
+
console.log(` Auto-start is enabled. To manage:
|
|
208
|
+
${chalk.cyan('sudo systemctl start sentinel')}
|
|
209
|
+
${chalk.cyan('sudo systemctl status sentinel')}
|
|
210
|
+
${chalk.cyan('journalctl -u sentinel -f')}
|
|
211
|
+
`);
|
|
212
|
+
}
|
|
213
|
+
console.log(` Add another project anytime:
|
|
214
|
+
${chalk.cyan('sentinel add <project-name>')}
|
|
215
|
+
`);
|
|
216
|
+
};
|
|
217
|
+
function readExistingConfig(workspace) {
|
|
218
|
+
const result = {};
|
|
219
|
+
_parsePropsInto(path.join(workspace, 'sentinel.properties'), result);
|
|
220
|
+
if (!result.SLACK_BOT_TOKEN || !result.SLACK_APP_TOKEN) {
|
|
221
|
+
try {
|
|
222
|
+
for (const entry of fs.readdirSync(workspace)) {
|
|
223
|
+
const p = path.join(workspace, entry, 'config', 'sentinel.properties');
|
|
224
|
+
const proj = {};
|
|
225
|
+
_parsePropsInto(p, proj);
|
|
226
|
+
if (!result.SLACK_BOT_TOKEN && proj.SLACK_BOT_TOKEN) result.SLACK_BOT_TOKEN = proj.SLACK_BOT_TOKEN;
|
|
227
|
+
if (!result.SLACK_APP_TOKEN && proj.SLACK_APP_TOKEN) result.SLACK_APP_TOKEN = proj.SLACK_APP_TOKEN;
|
|
228
|
+
if (result.SLACK_BOT_TOKEN && result.SLACK_APP_TOKEN) break;
|
|
229
|
+
}
|
|
230
|
+
} catch (_) {}
|
|
231
|
+
}
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
function _parsePropsInto(propsPath, result) {
|
|
235
|
+
if (!fs.existsSync(propsPath)) return;
|
|
236
|
+
try {
|
|
237
|
+
const lines = fs.readFileSync(propsPath, 'utf8').split(/\r?\n/);
|
|
238
|
+
for (const raw of lines) {
|
|
239
|
+
const line = raw.trim();
|
|
240
|
+
if (!line || line.startsWith('#')) continue;
|
|
241
|
+
const idx = line.indexOf('=');
|
|
242
|
+
if (idx === -1) continue;
|
|
243
|
+
result[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
|
244
|
+
}
|
|
245
|
+
} catch (_) {}
|
|
246
|
+
}
|
|
247
|
+
function findPython() {
|
|
248
|
+
for (const bin of ['python3', 'python']) {
|
|
249
|
+
try {
|
|
250
|
+
execSync(`${bin} --version`, { stdio: 'pipe' });
|
|
251
|
+
return bin;
|
|
252
|
+
} catch (_) {}
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
function run(bin, args) {
|
|
257
|
+
const r = spawnSync(bin, args, { encoding: 'utf8' });
|
|
258
|
+
return (r.stdout || '') + (r.stderr || '');
|
|
259
|
+
}
|
|
260
|
+
function runLive(bin, args) {
|
|
261
|
+
const r = spawnSync(bin, args, { stdio: 'inherit' });
|
|
262
|
+
if (r.status !== 0) {
|
|
263
|
+
console.error(chalk.red(` ✖ Command failed: ${bin} ${args.join(' ')}`));
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function installNpmGlobal(pkg, checkBin) {
|
|
268
|
+
try {
|
|
269
|
+
execSync(`${checkBin} --version`, { stdio: 'pipe' });
|
|
270
|
+
ok(`${pkg} already installed`);
|
|
271
|
+
} catch (_) {
|
|
272
|
+
info(`Installing ${pkg}…`);
|
|
273
|
+
runLive('npm', ['install', '-g', pkg]);
|
|
274
|
+
ok(`${pkg} installed`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function ensureClaudePermissions() {
|
|
278
|
+
const settingsPath = require('path').join(require('os').homedir(), '.claude', 'settings.json');
|
|
279
|
+
const required = ['Read(**)', 'Write(**)', 'Edit(**)', 'Bash(**)'];
|
|
280
|
+
let settings = {};
|
|
281
|
+
try {
|
|
282
|
+
if (fs.existsSync(settingsPath)) {
|
|
283
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
284
|
+
}
|
|
285
|
+
} catch (e) {
|
|
286
|
+
warn('Could not read ' + settingsPath + ': ' + e.message);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (!settings.permissions) settings.permissions = {};
|
|
290
|
+
if (!Array.isArray(settings.permissions.allow)) settings.permissions.allow = [];
|
|
291
|
+
const existing = new Set(settings.permissions.allow);
|
|
292
|
+
const added = [];
|
|
293
|
+
for (const perm of required) {
|
|
294
|
+
if (!existing.has(perm)) {
|
|
295
|
+
settings.permissions.allow.push(perm);
|
|
296
|
+
added.push(perm);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (added.length === 0) {
|
|
300
|
+
ok('Claude Code permissions already configured');
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
fs.ensureDirSync(require('path').dirname(settingsPath));
|
|
305
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
306
|
+
ok('Claude Code permissions patched: ' + added.join(', '));
|
|
307
|
+
} catch (e) {
|
|
308
|
+
warn('Could not write ' + settingsPath + ': ' + e.message);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
function setupSystemd(workspace) {
|
|
312
|
+
const user = os.userInfo().username;
|
|
313
|
+
const svc = `/etc/systemd/system/sentinel.service`;
|
|
314
|
+
const content = `[Unit]
|
|
315
|
+
Description=Sentinel — Autonomous DevOps Agent
|
|
316
|
+
After=network-online.target
|
|
317
|
+
Wants=network-online.target
|
|
318
|
+
[Service]
|
|
319
|
+
Type=forking
|
|
320
|
+
User=${user}
|
|
321
|
+
WorkingDirectory=${workspace}
|
|
322
|
+
ExecStart=${workspace}/startAll.sh
|
|
323
|
+
ExecStop=${workspace}/stopAll.sh
|
|
324
|
+
Restart=on-failure
|
|
325
|
+
RestartSec=10
|
|
326
|
+
[Install]
|
|
327
|
+
WantedBy=multi-user.target
|
|
328
|
+
`;
|
|
329
|
+
try {
|
|
330
|
+
fs.writeFileSync('/tmp/sentinel.service', content);
|
|
331
|
+
execSync(`sudo mv /tmp/sentinel.service ${svc}`);
|
|
332
|
+
execSync('sudo systemctl daemon-reload');
|
|
333
|
+
execSync('sudo systemctl enable sentinel');
|
|
334
|
+
ok('sentinel.service enabled');
|
|
335
|
+
} catch (e) {
|
|
336
|
+
warn(`Could not write systemd service (need sudo): ${e.message}`);
|
|
337
|
+
warn(`Manually create ${svc} to auto-start on reboot`);
|
|
338
|
+
}
|
|
339
|
+
}
|
package/lib/add.js
CHANGED
|
@@ -283,17 +283,17 @@ async function addFromGit(gitUrl, workspace) {
|
|
|
283
283
|
ok(`${repoSlug}: reachable`);
|
|
284
284
|
|
|
285
285
|
// ── 2. Clone primary repo and discover additional repos ────────────────────
|
|
286
|
-
const
|
|
286
|
+
const projectDir = path.join(workspace, name);
|
|
287
287
|
step(`[2/3] Scanning repo-configs in ${repoSlug}…`);
|
|
288
288
|
|
|
289
|
-
if (!fs.existsSync(
|
|
290
|
-
spawnSync(gitBin(), ['clone', '--depth', '1', gitUrl,
|
|
289
|
+
if (!fs.existsSync(projectDir)) {
|
|
290
|
+
spawnSync(gitBin(), ['clone', '--depth', '1', gitUrl, projectDir], {
|
|
291
291
|
stdio: 'inherit',
|
|
292
292
|
env: gitEnv({ GIT_SSH_COMMAND: `ssh -i ${keyFile} -o StrictHostKeyChecking=no -o BatchMode=yes` }),
|
|
293
293
|
});
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
-
const discovered = discoverReposFromClone(
|
|
296
|
+
const discovered = discoverReposFromClone(projectDir);
|
|
297
297
|
|
|
298
298
|
// Classify each discovered repo
|
|
299
299
|
const privateRepos = [];
|
|
@@ -439,8 +439,6 @@ async function addFromGit(gitUrl, workspace) {
|
|
|
439
439
|
}
|
|
440
440
|
|
|
441
441
|
// ── Preview + confirm ──────────────────────────────────────────────────────
|
|
442
|
-
const projectDir = path.join(workspace, name);
|
|
443
|
-
|
|
444
442
|
step('Dry-run preview');
|
|
445
443
|
info(`Will create: ${projectDir}/`);
|
|
446
444
|
if (discovered.length > 0) {
|
|
@@ -457,7 +455,7 @@ async function addFromGit(gitUrl, workspace) {
|
|
|
457
455
|
}, { onCancel: () => process.exit(0) });
|
|
458
456
|
if (!confirm) { info('Aborted.'); return; }
|
|
459
457
|
|
|
460
|
-
if (fs.existsSync(projectDir) && projectDir
|
|
458
|
+
if (fs.existsSync(projectDir) && !fs.existsSync(path.join(projectDir, '.git'))) {
|
|
461
459
|
console.error(chalk.yellow(`Project "${name}" already exists at ${projectDir}`));
|
|
462
460
|
process.exit(1);
|
|
463
461
|
}
|
|
@@ -468,32 +466,29 @@ async function addFromGit(gitUrl, workspace) {
|
|
|
468
466
|
|
|
469
467
|
if (discovered.length > 0) {
|
|
470
468
|
// Config already exists in the cloned repo — just generate scripts
|
|
471
|
-
generateProjectScripts(
|
|
472
|
-
// Write SSH_KEY_FILE for primary repo itself
|
|
473
|
-
const primaryProps = path.join(
|
|
469
|
+
generateProjectScripts(projectDir, codeDir, pythonBin);
|
|
470
|
+
// Write SSH_KEY_FILE for primary repo itself (no LOCAL_PATH — derived automatically)
|
|
471
|
+
const primaryProps = path.join(projectDir, 'config', 'repo-configs', `${repoSlug}.properties`);
|
|
474
472
|
if (!fs.existsSync(primaryProps)) {
|
|
475
473
|
writePropertiesFile(primaryProps, {
|
|
476
474
|
REPO_NAME: repoSlug,
|
|
477
475
|
REPO_URL: gitUrl,
|
|
478
|
-
LOCAL_PATH: localPath,
|
|
479
476
|
BRANCH: 'main',
|
|
480
477
|
AUTO_PUBLISH: autoPublish ? 'true' : 'false',
|
|
481
478
|
SSH_KEY_FILE: keyFile,
|
|
482
479
|
CAIRN_MCP_ENABLED: 'true',
|
|
483
480
|
});
|
|
484
481
|
}
|
|
485
|
-
ok(`Project "${name}" ready at ${
|
|
486
|
-
printNextSteps(
|
|
487
|
-
await offerToStart(
|
|
482
|
+
ok(`Project "${name}" ready at ${projectDir}`);
|
|
483
|
+
printNextSteps(projectDir, autoPublish);
|
|
484
|
+
await offerToStart(projectDir);
|
|
488
485
|
} else {
|
|
489
486
|
// No existing repo-configs — scaffold fresh project
|
|
490
|
-
fs.ensureDirSync(projectDir);
|
|
491
487
|
writeExampleProject(projectDir, codeDir, pythonBin);
|
|
492
488
|
const repoDir = path.join(projectDir, 'config', 'repo-configs');
|
|
493
489
|
writePropertiesFile(path.join(repoDir, `${repoSlug}.properties`), {
|
|
494
490
|
REPO_NAME: repoSlug,
|
|
495
491
|
REPO_URL: gitUrl,
|
|
496
|
-
LOCAL_PATH: localPath,
|
|
497
492
|
BRANCH: 'main',
|
|
498
493
|
AUTO_PUBLISH: autoPublish ? 'true' : 'false',
|
|
499
494
|
SSH_KEY_FILE: keyFile,
|
|
@@ -649,8 +644,6 @@ async function addFromUrl(url, workspace) {
|
|
|
649
644
|
printNextSteps(projectDir);
|
|
650
645
|
}
|
|
651
646
|
|
|
652
|
-
// ── printNextSteps ────────────────────────────────────────────────────────────
|
|
653
|
-
|
|
654
647
|
function printNextSteps(projectDir, autoPublish) {
|
|
655
648
|
const logFile = path.join(projectDir, 'logs', 'sentinel.log');
|
|
656
649
|
const mode = autoPublish === true
|
|
@@ -682,8 +675,6 @@ async function offerToStart(projectDir) {
|
|
|
682
675
|
}
|
|
683
676
|
}
|
|
684
677
|
|
|
685
|
-
// ── entry point ───────────────────────────────────────────────────────────────
|
|
686
|
-
|
|
687
678
|
module.exports = async function add(arg) {
|
|
688
679
|
const type = detectInputType(arg);
|
|
689
680
|
const workspace = await resolveWorkspace();
|
package/lib/generate.js
CHANGED
|
@@ -160,8 +160,10 @@ WORKSPACE="$(cd "$(dirname "$0")" && pwd)"
|
|
|
160
160
|
started=0
|
|
161
161
|
skipped=0
|
|
162
162
|
for project_dir in "$WORKSPACE"/*/; do
|
|
163
|
+
[[ -d "$project_dir" ]] || continue
|
|
163
164
|
name=$(basename "$project_dir")
|
|
164
|
-
[[ "$name" == "code" ]]
|
|
165
|
+
[[ "$name" == "code" ]] && continue
|
|
166
|
+
[[ "$name" == "repos" ]] && continue
|
|
165
167
|
# Auto-generate start.sh / stop.sh if missing (codeDir = $WORKSPACE/code)
|
|
166
168
|
if [[ ! -f "$project_dir/start.sh" ]]; then
|
|
167
169
|
code_dir="$WORKSPACE/code"
|
|
@@ -265,8 +267,10 @@ echo "[sentinel] $started project(s) started, $skipped skipped"
|
|
|
265
267
|
WORKSPACE="$(cd "$(dirname "$0")" && pwd)"
|
|
266
268
|
stopped=0
|
|
267
269
|
for project_dir in "$WORKSPACE"/*/; do
|
|
270
|
+
[[ -d "$project_dir" ]] || continue
|
|
268
271
|
name=$(basename "$project_dir")
|
|
269
|
-
[[ "$name" == "code" ]]
|
|
272
|
+
[[ "$name" == "code" ]] && continue
|
|
273
|
+
[[ "$name" == "repos" ]] && continue
|
|
270
274
|
[[ -f "$project_dir/stop.sh" ]] || continue
|
|
271
275
|
bash "$project_dir/stop.sh"
|
|
272
276
|
stopped=$((stopped + 1))
|
package/lib/init.js
CHANGED
|
@@ -153,9 +153,10 @@ module.exports = async function init() {
|
|
|
153
153
|
// ── Node tools ──────────────────────────────────────────────────────────────
|
|
154
154
|
step('Installing Node tools…');
|
|
155
155
|
installNpmGlobal('@misterhuydo/cairn-mcp', 'cairn');
|
|
156
|
-
|
|
157
|
-
info('Hooking Cairn MCP into Claude Code…');
|
|
156
|
+
step('Hooking Cairn MCP into Claude Code…');
|
|
158
157
|
runLive('cairn', ['install']);
|
|
158
|
+
ok('cairn install complete');
|
|
159
|
+
installNpmGlobal('@anthropic-ai/claude-code', 'claude');
|
|
159
160
|
step('Patching Claude Code permissions…');
|
|
160
161
|
ensureClaudePermissions();
|
|
161
162
|
|
package/package.json
CHANGED
|
@@ -172,7 +172,7 @@ class ConfigLoader:
|
|
|
172
172
|
c.slack_watch_bot_ids = _csv(d.get("SLACK_WATCH_BOT_IDS", ""))
|
|
173
173
|
c.slack_allowed_users = _csv(d.get("SLACK_ALLOWED_USERS", ""))
|
|
174
174
|
c.slack_admin_users = _csv(d.get("SLACK_ADMIN_USERS", ""))
|
|
175
|
-
c.project_name = d.get("PROJECT_NAME", "")
|
|
175
|
+
c.project_name = d.get("PROJECT_NAME", "") or Path(self.config_dir).resolve().parent.name
|
|
176
176
|
c.claude_pro_for_tasks = d.get("CLAUDE_PRO_FOR_TASKS", "true").lower() != "false"
|
|
177
177
|
c.sync_enabled = d.get("SYNC_ENABLED", "true").lower() != "false"
|
|
178
178
|
c.sync_interval_seconds = int(d.get("SYNC_INTERVAL_SECONDS", 300))
|
|
@@ -218,7 +218,7 @@ class ConfigLoader:
|
|
|
218
218
|
r = RepoConfig()
|
|
219
219
|
r.repo_name = path.stem
|
|
220
220
|
r.repo_url = d.get("REPO_URL", "")
|
|
221
|
-
r.local_path =
|
|
221
|
+
r.local_path = str(Path(self.config_dir).parent / "repos" / r.repo_name)
|
|
222
222
|
r.branch = d.get("BRANCH", "main")
|
|
223
223
|
r.auto_publish = d.get("AUTO_PUBLISH", "false").lower() == "true"
|
|
224
224
|
r.cicd_type = d.get("CICD_TYPE", "")
|
|
@@ -135,7 +135,7 @@ def _claude_cmd(bin_path: str, prompt: str) -> list[str]:
|
|
|
135
135
|
return [bin_path, "--print", prompt]
|
|
136
136
|
|
|
137
137
|
|
|
138
|
-
def _run_claude_attempt(bin_path: str, prompt: str, env: dict) -> tuple[str, bool]:
|
|
138
|
+
def _run_claude_attempt(bin_path: str, prompt: str, env: dict, cwd: str | None = None) -> tuple[str, bool]:
|
|
139
139
|
"""
|
|
140
140
|
Run claude CLI with the given env. Returns (output, timed_out).
|
|
141
141
|
Raises FileNotFoundError if binary is missing.
|
|
@@ -144,6 +144,7 @@ def _run_claude_attempt(bin_path: str, prompt: str, env: dict) -> tuple[str, boo
|
|
|
144
144
|
result = subprocess.run(
|
|
145
145
|
_claude_cmd(bin_path, prompt),
|
|
146
146
|
capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT, env=env,
|
|
147
|
+
cwd=cwd or None,
|
|
147
148
|
)
|
|
148
149
|
return (result.stdout or "") + (result.stderr or ""), False
|
|
149
150
|
except subprocess.TimeoutExpired:
|
|
@@ -216,7 +217,7 @@ def generate_fix(
|
|
|
216
217
|
if env is None:
|
|
217
218
|
continue
|
|
218
219
|
logger.info("fix_engine: trying %s for %s", label, event.fingerprint)
|
|
219
|
-
output, timed_out = _run_claude_attempt(cfg.claude_code_bin, prompt, env)
|
|
220
|
+
output, timed_out = _run_claude_attempt(cfg.claude_code_bin, prompt, env, cwd=repo.local_path)
|
|
220
221
|
if timed_out:
|
|
221
222
|
logger.error("Claude Code timed out for %s", event.fingerprint)
|
|
222
223
|
return "error", None, ""
|
|
@@ -19,7 +19,6 @@ from .log_parser import ErrorEvent
|
|
|
19
19
|
logger = logging.getLogger(__name__)
|
|
20
20
|
|
|
21
21
|
GIT_TIMEOUT = 60
|
|
22
|
-
PR_BRANCH_PREFIX = "sentinel/fix-"
|
|
23
22
|
|
|
24
23
|
# Files that must never be modified by Sentinel
|
|
25
24
|
_PROTECTED_PATHS = {".github/", "Jenkinsfile", "pom.xml"}
|
|
@@ -144,6 +143,13 @@ def _append_changelog(repo: RepoConfig, event: ErrorEvent, commit_hash: str):
|
|
|
144
143
|
_git(["commit", "--amend", "--no-edit"], cwd=repo.local_path, env=env)
|
|
145
144
|
|
|
146
145
|
|
|
146
|
+
def remote_fix_exists(repo: RepoConfig, fingerprint: str, cfg: SentinelConfig) -> bool:
|
|
147
|
+
"""Return True if any Sentinel instance already pushed a fix branch for this fingerprint."""
|
|
148
|
+
pattern = f"refs/heads/*/fix-{fingerprint[:8]}"
|
|
149
|
+
r = _git(["ls-remote", "--heads", "origin", pattern], cwd=repo.local_path, env=_git_env(repo))
|
|
150
|
+
return r.returncode == 0 and bool(r.stdout.strip())
|
|
151
|
+
|
|
152
|
+
|
|
147
153
|
def publish(
|
|
148
154
|
event: ErrorEvent,
|
|
149
155
|
repo: RepoConfig,
|
|
@@ -159,13 +165,21 @@ def publish(
|
|
|
159
165
|
env = _git_env(repo)
|
|
160
166
|
local_path = repo.local_path
|
|
161
167
|
|
|
168
|
+
if not repo.auto_publish:
|
|
169
|
+
if remote_fix_exists(repo, event.fingerprint, cfg):
|
|
170
|
+
logger.info(
|
|
171
|
+
"Remote fix branch already exists for %s — skipping duplicate push",
|
|
172
|
+
event.fingerprint[:8],
|
|
173
|
+
)
|
|
174
|
+
return "", ""
|
|
175
|
+
|
|
162
176
|
if repo.auto_publish:
|
|
163
177
|
r = _git(["push", "origin", repo.branch], cwd=local_path, env=env)
|
|
164
178
|
if r.returncode != 0:
|
|
165
179
|
logger.error("git push failed:\n%s", r.stderr)
|
|
166
180
|
return repo.branch, ""
|
|
167
181
|
else:
|
|
168
|
-
branch = f"{
|
|
182
|
+
branch = f"{cfg.project_name or 'sentinel'}/fix-{event.fingerprint[:8]}"
|
|
169
183
|
_git(["checkout", "-B", branch], cwd=local_path, env=env)
|
|
170
184
|
r = _git(["push", "-u", "origin", branch], cwd=local_path, env=env)
|
|
171
185
|
if r.returncode != 0:
|
|
@@ -12,9 +12,6 @@
|
|
|
12
12
|
# SSH clone URL of the GitHub repository
|
|
13
13
|
REPO_URL=git@github.com:<org>/<repo>.git
|
|
14
14
|
|
|
15
|
-
# Absolute path where Sentinel will clone/manage this repo on the local machine
|
|
16
|
-
LOCAL_PATH=/home/<user>/sentinel/repos/<repo-name>
|
|
17
|
-
|
|
18
15
|
# Branch to pull from and push fixes to
|
|
19
16
|
BRANCH=main
|
|
20
17
|
|