@misterhuydo/sentinel 1.3.6 → 1.3.8

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 CHANGED
@@ -1 +1 @@
1
- 2026-03-23T18:20:09.151Z
1
+ 2026-03-24T06:20:19.272Z
@@ -4,5 +4,11 @@
4
4
  "state": "compressed",
5
5
  "minifiedAt": 1774252515044.4768,
6
6
  "readCount": 1
7
+ },
8
+ "J:\\Projects\\Sentinel\\cli\\lib\\add.js": {
9
+ "tempPath": "J:\\Projects\\Sentinel\\cli\\.cairn\\views\\fc4a1a_add.js",
10
+ "state": "compressed",
11
+ "minifiedAt": 1774333679398.312,
12
+ "readCount": 1
7
13
  }
8
14
  }
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-23T18:43:04.420Z",
3
- "checkpoint_at": "2026-03-23T18:43:04.421Z",
2
+ "message": "Auto-checkpoint at 2026-03-24T06:46:37.910Z",
3
+ "checkpoint_at": "2026-03-24T06:46:37.911Z",
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 ${DIR}/config" 2>/dev/null || true
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
- const replaced = props.replace(/^#?\s*SLACK_BOT_TOKEN=.*/m, 'SLACK_BOT_TOKEN=' + slackConfig.botToken);
93
- props = replaced !== props ? replaced : props.trimEnd() + '\nSLACK_BOT_TOKEN=' + slackConfig.botToken + '\n';
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
- const replaced = props.replace(/^#?\s*SLACK_APP_TOKEN=.*/m, 'SLACK_APP_TOKEN=' + slackConfig.appToken);
97
- props = replaced !== props ? replaced : props.trimEnd() + '\nSLACK_APP_TOKEN=' + slackConfig.appToken + '\n';
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; do
104
- name=$(basename "$project_dir")
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" ]] && continue
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
+ }