@misterhuydo/sentinel 1.0.76 → 1.0.82

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/lib/init.js CHANGED
@@ -1,319 +1,356 @@
1
- 'use strict';
2
-
3
- const fs = require('fs-extra');
4
- const path = require('path');
5
- const os = require('os');
6
- const { execSync, spawnSync } = require('child_process');
7
- const prompts = require('prompts');
8
- const chalk = require('chalk');
9
- const { generateProjectScripts, generateWorkspaceScripts, writeExampleProject } = require('./generate');
10
-
11
- const ok = msg => console.log(chalk.green(' ✔'), msg);
12
- const info = msg => console.log(chalk.cyan(' →'), msg);
13
- const warn = msg => console.log(chalk.yellow(' ⚠'), msg);
14
- const step = msg => console.log('\n' + chalk.bold.white(msg));
15
-
16
- module.exports = async function init() {
17
- // Pre-read existing workspace config so prompts can show current values
18
- const defaultWorkspace = path.join(os.homedir(), 'sentinel');
19
- const existing = readExistingConfig(defaultWorkspace);
20
- if (Object.keys(existing).length) {
21
- console.log(chalk.cyan('\n → Existing workspace config found — showing current values as defaults\n'));
22
- }
23
-
24
- // ── Prompts ─────────────────────────────────────────────────────────────────
25
- const answers = await prompts([
26
- {
27
- type: 'text',
28
- name: 'workspace',
29
- message: 'Workspace directory (each project lives here as a subdirectory)',
30
- initial: path.join(os.homedir(), 'sentinel'),
31
- format: v => v.replace(/^~/, os.homedir()),
32
- },
33
- {
34
- type: 'select',
35
- name: 'authMode',
36
- message: 'How will Claude Code authenticate?',
37
- choices: [
38
- { title: 'API key (Anthropic API account — recommended for servers)', value: 'apikey' },
39
- { title: 'Claude Pro / OAuth (will give you a URL to open in any browser)', value: 'oauth' },
40
- { title: 'Skip (I will configure this later)', value: 'skip' },
41
- ],
42
- },
43
- {
44
- type: prev => prev === 'apikey' ? 'password' : null,
45
- name: 'anthropicKey',
46
- message: 'Anthropic API key (sk-ant-...)',
47
- validate: v => v.startsWith('sk-ant-') ? true : 'Key should start with sk-ant-',
48
- },
49
- {
50
- type: 'confirm',
51
- name: 'example',
52
- message: 'Create an example project to show how to configure?',
53
- initial: true,
54
- },
55
- {
56
- type: 'confirm',
57
- name: 'systemd',
58
- message: 'Set up systemd service for auto-start on reboot?',
59
- initial: process.platform === 'linux',
60
- },
61
- {
62
- type: 'text',
63
- name: 'smtpUser',
64
- message: 'SMTP sender address',
65
- initial: existing.SMTP_USER || 'sentinel@yourdomain.com',
66
- },
67
- {
68
- type: prev => prev ? 'password' : null,
69
- name: 'smtpPassword',
70
- message: existing.SMTP_PASSWORD ? 'SMTP password / app password (press Enter to keep current)' : 'SMTP password / app password',
71
- },
72
- {
73
- type: prev => prev ? 'text' : null,
74
- name: 'smtpHost',
75
- message: 'SMTP host',
76
- initial: existing.SMTP_HOST || 'smtp.gmail.com',
77
- },
78
- {
79
- type: 'confirm',
80
- name: 'setupSlack',
81
- message: 'Set up Slack Bot (Sentinel Boss — conversational AI interface)?',
82
- initial: !!(existing.SLACK_BOT_TOKEN),
83
- },
84
- {
85
- type: prev => prev ? 'password' : null,
86
- name: 'slackBotToken',
87
- message: existing.SLACK_BOT_TOKEN ? 'Slack Bot Token (press Enter to keep current)' : 'Slack Bot Token (xoxb-...)',
88
- validate: v => !v || v.startsWith('xoxb-') ? true : 'Should start with xoxb-',
89
- },
90
- {
91
- type: (_, { setupSlack }) => setupSlack ? 'password' : null,
92
- name: 'slackAppToken',
93
- message: existing.SLACK_APP_TOKEN ? 'Slack App-Level Token (press Enter to keep current)' : 'Slack App-Level Token (xapp-...)',
94
- validate: v => !v || v.startsWith('xapp-') ? true : 'Should start with xapp-',
95
- },
96
- ], { onCancel: () => process.exit(0) });
97
-
98
- const { workspace, authMode, anthropicKey, example, systemd, smtpUser, smtpPassword, smtpHost, setupSlack, slackBotToken, slackAppToken } = answers;
99
- const effectiveSmtpPassword = smtpPassword || existing.SMTP_PASSWORD || '';
100
- const effectiveSlackBotToken = slackBotToken || existing.SLACK_BOT_TOKEN || '';
101
- const effectiveSlackAppToken = slackAppToken || existing.SLACK_APP_TOKEN || '';
102
- const codeDir = path.join(workspace, 'code');
103
-
104
- // ── Python ──────────────────────────────────────────────────────────────────
105
- step('Checking Python…');
106
- const python = findPython();
107
- if (!python) {
108
- console.error(chalk.red(' ✖ python3 not found. Install it first:'));
109
- console.error(' sudo apt-get install -y python3 python3-pip python3-venv');
110
- process.exit(1);
111
- }
112
- ok(`Python: ${run(python, ['--version']).trim()}`);
113
-
114
- // ── Copy Sentinel Python source ─────────────────────────────────────────────
115
- step('Installing Sentinel code…');
116
- const bundledPython = path.join(__dirname, '..', 'python');
117
- if (!fs.existsSync(bundledPython)) {
118
- console.error(chalk.red(' ✖ Bundled Python source not found (run npm publish from the Sentinel repo)'));
119
- process.exit(1);
120
- }
121
- fs.ensureDirSync(codeDir);
122
- fs.copySync(bundledPython, codeDir, { overwrite: true });
123
- ok(`Sentinel code → ${codeDir}`);
124
-
125
- // ── Python venv ─────────────────────────────────────────────────────────────
126
- step('Setting up Python environment…');
127
- const venv = path.join(codeDir, '.venv');
128
- if (!fs.existsSync(venv)) {
129
- info('Creating virtual environment…');
130
- runLive(python, ['-m', 'venv', venv]);
131
- }
132
- const pip = path.join(venv, 'bin', 'pip3');
133
- const pythonBin = path.join(venv, 'bin', 'python3');
134
- info('Installing Python packages…');
135
- runLive(pip, ['install', '--quiet', '--upgrade', 'pip']);
136
- runLive(pip, ['install', '--quiet', '-r', path.join(codeDir, 'requirements.txt')]);
137
- ok('Python packages installed');
138
-
139
- // ── Node tools ──────────────────────────────────────────────────────────────
140
- step('Installing Node tools…');
141
- installNpmGlobal('@misterhuydo/cairn-mcp', 'cairn');
142
- installNpmGlobal('@anthropic-ai/claude-code', 'claude');
143
- info('Hooking Cairn MCP into Claude Code…');
144
- runLive('cairn', ['install']);
145
-
146
- // ── Claude Code auth ─────────────────────────────────────────────────────────
147
- step('Claude Code authentication…');
148
- if (authMode === 'apikey' && anthropicKey) {
149
- ok('API key will be written to each project\'s sentinel.properties');
150
- } else if (authMode === 'oauth') {
151
- info('OAuth selected — start.sh will prompt for login if not yet authenticated');
152
- } else {
153
- info('Skipping auth — start.sh will prompt for login if needed');
154
- }
155
-
156
- // ── Slack Bot ────────────────────────────────────────────────────────────────
157
- if (effectiveSlackBotToken && effectiveSlackAppToken) {
158
- step('Slack Bot (Sentinel Boss)…');
159
- ok('Tokens will be written to workspace sentinel.properties');
160
- info('Sentinel Boss starts automatically when the project starts');
161
- } else if (setupSlack) {
162
- warn('Slack tokens not provided add them to config/sentinel.properties later');
163
- }
164
-
165
- // ── Workspace structure ─────────────────────────────────────────────────────
166
- step('Creating workspace…');
167
- fs.ensureDirSync(workspace);
168
- ok(`Workspace: ${workspace}`);
169
-
170
- // ── Example project ─────────────────────────────────────────────────────────
171
- if (example) {
172
- step('Creating example project…');
173
- const exampleDir = path.join(workspace, 'my-project');
174
- writeExampleProject(exampleDir, codeDir, pythonBin, anthropicKey || '', { botToken: effectiveSlackBotToken, appToken: effectiveSlackAppToken });
175
- ok(`Example project: ${exampleDir}`);
176
- }
177
-
178
- // ── Workspace start/stop scripts ─────────────────────────────────────────────
179
- step('Generating scripts…');
180
- generateWorkspaceScripts(workspace, { host: smtpHost, user: smtpUser, password: effectiveSmtpPassword }, { botToken: effectiveSlackBotToken, appToken: effectiveSlackAppToken });
181
- ok(`${workspace}/startAll.sh`);
182
- ok(`${workspace}/stopAll.sh`);
183
-
184
- // ── systemd ──────────────────────────────────────────────────────────────────
185
- if (systemd) {
186
- step('Setting up systemd…');
187
- setupSystemd(workspace);
188
- }
189
-
190
- // ── Done ─────────────────────────────────────────────────────────────────────
191
- console.log('\n' + chalk.bold.green('══ Done! ══') + '\n');
192
- console.log(`${chalk.bold('Next steps:')}`);
193
- if (example) {
194
- console.log(`
195
- 1. Configure your first project:
196
- ${chalk.cyan(`${workspace}/my-project/config/sentinel.properties`)}
197
- ${chalk.cyan(`${workspace}/my-project/config/log-configs/`)}
198
- ${chalk.cyan(`${workspace}/my-project/config/repo-configs/`)}
199
-
200
- 2. Start all projects (Sentinel clones repos, indexes, and monitors automatically):
201
- ${chalk.cyan(`${workspace}/startAll.sh`)}
202
-
203
- 3. Stop all projects:
204
- ${chalk.cyan(`${workspace}/stopAll.sh`)}
205
- `);
206
- }
207
- if (systemd) {
208
- console.log(` Auto-start is enabled. To manage:
209
- ${chalk.cyan('sudo systemctl start sentinel')}
210
- ${chalk.cyan('sudo systemctl status sentinel')}
211
- ${chalk.cyan('journalctl -u sentinel -f')}
212
- `);
213
- }
214
-
215
- console.log(` Add another project anytime:
216
- ${chalk.cyan('sentinel add <project-name>')}
217
- `);
218
- };
219
-
220
- // ── Helpers ──────────────────────────────────────────────────────────────────
221
-
222
- function readExistingConfig(workspace) {
223
- const result = {};
224
- _parsePropsInto(path.join(workspace, 'sentinel.properties'), result);
225
- // Migrate: if Slack tokens not in workspace config, scan project configs as fallback
226
- if (!result.SLACK_BOT_TOKEN || !result.SLACK_APP_TOKEN) {
227
- try {
228
- for (const entry of fs.readdirSync(workspace)) {
229
- const p = path.join(workspace, entry, 'config', 'sentinel.properties');
230
- const proj = {};
231
- _parsePropsInto(p, proj);
232
- if (!result.SLACK_BOT_TOKEN && proj.SLACK_BOT_TOKEN) result.SLACK_BOT_TOKEN = proj.SLACK_BOT_TOKEN;
233
- if (!result.SLACK_APP_TOKEN && proj.SLACK_APP_TOKEN) result.SLACK_APP_TOKEN = proj.SLACK_APP_TOKEN;
234
- if (result.SLACK_BOT_TOKEN && result.SLACK_APP_TOKEN) break;
235
- }
236
- } catch (_) {}
237
- }
238
- return result;
239
- }
240
-
241
- function _parsePropsInto(propsPath, result) {
242
- if (!fs.existsSync(propsPath)) return;
243
- try {
244
- const lines = fs.readFileSync(propsPath, 'utf8').split(/\r?\n/);
245
- for (const raw of lines) {
246
- const line = raw.trim();
247
- if (!line || line.startsWith('#')) continue;
248
- const idx = line.indexOf('=');
249
- if (idx === -1) continue;
250
- result[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
251
- }
252
- } catch (_) {}
253
- }
254
-
255
- function findPython() {
256
- for (const bin of ['python3', 'python']) {
257
- try {
258
- execSync(`${bin} --version`, { stdio: 'pipe' });
259
- return bin;
260
- } catch (_) {}
261
- }
262
- return null;
263
- }
264
-
265
- function run(bin, args) {
266
- const r = spawnSync(bin, args, { encoding: 'utf8' });
267
- return (r.stdout || '') + (r.stderr || '');
268
- }
269
-
270
- function runLive(bin, args) {
271
- const r = spawnSync(bin, args, { stdio: 'inherit' });
272
- if (r.status !== 0) {
273
- console.error(chalk.red(` Command failed: ${bin} ${args.join(' ')}`));
274
- process.exit(1);
275
- }
276
- }
277
-
278
- function installNpmGlobal(pkg, checkBin) {
279
- try {
280
- execSync(`${checkBin} --version`, { stdio: 'pipe' });
281
- ok(`${pkg} already installed`);
282
- } catch (_) {
283
- info(`Installing ${pkg}…`);
284
- runLive('npm', ['install', '-g', pkg]);
285
- ok(`${pkg} installed`);
286
- }
287
- }
288
-
289
- function setupSystemd(workspace) {
290
- const user = os.userInfo().username;
291
- const svc = `/etc/systemd/system/sentinel.service`;
292
- const content = `[Unit]
293
- Description=Sentinel Autonomous DevOps Agent
294
- After=network-online.target
295
- Wants=network-online.target
296
-
297
- [Service]
298
- Type=forking
299
- User=${user}
300
- WorkingDirectory=${workspace}
301
- ExecStart=${workspace}/startAll.sh
302
- ExecStop=${workspace}/stopAll.sh
303
- Restart=on-failure
304
- RestartSec=10
305
-
306
- [Install]
307
- WantedBy=multi-user.target
308
- `;
309
- try {
310
- fs.writeFileSync('/tmp/sentinel.service', content);
311
- execSync(`sudo mv /tmp/sentinel.service ${svc}`);
312
- execSync('sudo systemctl daemon-reload');
313
- execSync('sudo systemctl enable sentinel');
314
- ok('sentinel.service enabled');
315
- } catch (e) {
316
- warn(`Could not write systemd service (need sudo): ${e.message}`);
317
- warn(`Manually create ${svc} to auto-start on reboot`);
318
- }
319
- }
1
+ 'use strict';
2
+
3
+ const fs = require('fs-extra');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const { execSync, spawnSync } = require('child_process');
7
+ const prompts = require('prompts');
8
+ const chalk = require('chalk');
9
+ const { generateProjectScripts, generateWorkspaceScripts, writeExampleProject } = require('./generate');
10
+
11
+ const ok = msg => console.log(chalk.green(' ✔'), msg);
12
+ const info = msg => console.log(chalk.cyan(' →'), msg);
13
+ const warn = msg => console.log(chalk.yellow(' ⚠'), msg);
14
+ const step = msg => console.log('\n' + chalk.bold.white(msg));
15
+
16
+ module.exports = async function init() {
17
+ // Pre-read existing workspace config so prompts can show current values
18
+ const defaultWorkspace = path.join(os.homedir(), 'sentinel');
19
+ const existing = readExistingConfig(defaultWorkspace);
20
+ if (Object.keys(existing).length) {
21
+ console.log(chalk.cyan('\n → Existing workspace config found — showing current values as defaults\n'));
22
+ }
23
+
24
+ // ── Prompts ─────────────────────────────────────────────────────────────────
25
+ const answers = await prompts([
26
+ {
27
+ type: 'text',
28
+ name: 'workspace',
29
+ message: 'Workspace directory (each project lives here as a subdirectory)',
30
+ initial: path.join(os.homedir(), 'sentinel'),
31
+ format: v => v.replace(/^~/, os.homedir()),
32
+ },
33
+ {
34
+ type: 'select',
35
+ name: 'authMode',
36
+ message: 'How will Claude Code authenticate?',
37
+ choices: [
38
+ { title: 'API key (Anthropic API account — recommended for servers)', value: 'apikey' },
39
+ { title: 'Claude Pro / OAuth (will give you a URL to open in any browser)', value: 'oauth' },
40
+ { title: 'Skip (I will configure this later)', value: 'skip' },
41
+ ],
42
+ },
43
+ {
44
+ type: prev => prev === 'apikey' ? 'password' : null,
45
+ name: 'anthropicKey',
46
+ message: 'Anthropic API key (sk-ant-...)',
47
+ validate: v => v.startsWith('sk-ant-') ? true : 'Key should start with sk-ant-',
48
+ },
49
+ {
50
+ type: 'confirm',
51
+ name: 'example',
52
+ message: 'Create an example project to show how to configure?',
53
+ initial: true,
54
+ },
55
+ {
56
+ type: 'confirm',
57
+ name: 'systemd',
58
+ message: 'Set up systemd service for auto-start on reboot?',
59
+ initial: process.platform === 'linux',
60
+ },
61
+ {
62
+ type: 'text',
63
+ name: 'smtpUser',
64
+ message: 'SMTP sender address',
65
+ initial: existing.SMTP_USER || 'sentinel@yourdomain.com',
66
+ },
67
+ {
68
+ type: prev => prev ? 'password' : null,
69
+ name: 'smtpPassword',
70
+ message: existing.SMTP_PASSWORD ? 'SMTP password / app password (press Enter to keep current)' : 'SMTP password / app password',
71
+ },
72
+ {
73
+ type: prev => prev ? 'text' : null,
74
+ name: 'smtpHost',
75
+ message: 'SMTP host',
76
+ initial: existing.SMTP_HOST || 'smtp.gmail.com',
77
+ },
78
+ {
79
+ type: 'confirm',
80
+ name: 'setupSlack',
81
+ message: 'Set up Slack Bot (Sentinel Boss — conversational AI interface)?',
82
+ initial: !!(existing.SLACK_BOT_TOKEN),
83
+ },
84
+ {
85
+ type: prev => prev ? 'password' : null,
86
+ name: 'slackBotToken',
87
+ message: existing.SLACK_BOT_TOKEN ? 'Slack Bot Token (press Enter to keep current)' : 'Slack Bot Token (xoxb-...)',
88
+ validate: v => !v || v.startsWith('xoxb-') ? true : 'Should start with xoxb-',
89
+ },
90
+ {
91
+ type: (_, { setupSlack }) => setupSlack ? 'password' : null,
92
+ name: 'slackAppToken',
93
+ message: existing.SLACK_APP_TOKEN ? 'Slack App-Level Token (press Enter to keep current)' : 'Slack App-Level Token (xapp-...)',
94
+ validate: v => !v || v.startsWith('xapp-') ? true : 'Should start with xapp-',
95
+ },
96
+ ], { onCancel: () => process.exit(0) });
97
+
98
+ const { workspace, authMode, anthropicKey, example, systemd, smtpUser, smtpPassword, smtpHost, setupSlack, slackBotToken, slackAppToken } = answers;
99
+ const effectiveSmtpPassword = smtpPassword || existing.SMTP_PASSWORD || '';
100
+ const effectiveSlackBotToken = slackBotToken || existing.SLACK_BOT_TOKEN || '';
101
+ const effectiveSlackAppToken = slackAppToken || existing.SLACK_APP_TOKEN || '';
102
+ const codeDir = path.join(workspace, 'code');
103
+
104
+ // ── Python ──────────────────────────────────────────────────────────────────
105
+ step('Checking Python…');
106
+ const python = findPython();
107
+ if (!python) {
108
+ console.error(chalk.red(' ✖ python3 not found. Install it first:'));
109
+ console.error(' sudo apt-get install -y python3 python3-pip python3-venv');
110
+ process.exit(1);
111
+ }
112
+ ok(`Python: ${run(python, ['--version']).trim()}`);
113
+
114
+ // ── Copy Sentinel Python source ─────────────────────────────────────────────
115
+ step('Installing Sentinel code…');
116
+ const bundledPython = path.join(__dirname, '..', 'python');
117
+ if (!fs.existsSync(bundledPython)) {
118
+ console.error(chalk.red(' ✖ Bundled Python source not found (run npm publish from the Sentinel repo)'));
119
+ process.exit(1);
120
+ }
121
+ fs.ensureDirSync(codeDir);
122
+ fs.copySync(bundledPython, codeDir, { overwrite: true });
123
+ ok(`Sentinel code → ${codeDir}`);
124
+
125
+ // ── Python venv ─────────────────────────────────────────────────────────────
126
+ step('Setting up Python environment…');
127
+ const venv = path.join(codeDir, '.venv');
128
+ if (!fs.existsSync(venv)) {
129
+ info('Creating virtual environment…');
130
+ runLive(python, ['-m', 'venv', venv]);
131
+ }
132
+ const pip = path.join(venv, 'bin', 'pip3');
133
+ const pythonBin = path.join(venv, 'bin', 'python3');
134
+ info('Installing Python packages…');
135
+ runLive(pip, ['install', '--quiet', '--upgrade', 'pip']);
136
+ runLive(pip, ['install', '--quiet', '-r', path.join(codeDir, 'requirements.txt')]);
137
+ ok('Python packages installed');
138
+
139
+ // ── Node tools ──────────────────────────────────────────────────────────────
140
+ step('Installing Node tools…');
141
+ installNpmGlobal('@misterhuydo/cairn-mcp', 'cairn');
142
+ installNpmGlobal('@anthropic-ai/claude-code', 'claude');
143
+ info('Hooking Cairn MCP into Claude Code…');
144
+ runLive('cairn', ['install']);
145
+ step('Patching Claude Code permissions…');
146
+ ensureClaudePermissions();
147
+
148
+ // ── Claude Code auth ─────────────────────────────────────────────────────────
149
+ step('Claude Code authentication…');
150
+ if (authMode === 'apikey' && anthropicKey) {
151
+ ok('API key will be written to each project\'s sentinel.properties');
152
+ } else if (authMode === 'oauth') {
153
+ info('OAuth selected — start.sh will prompt for login if not yet authenticated');
154
+ } else {
155
+ info('Skipping auth — start.sh will prompt for login if needed');
156
+ }
157
+
158
+ // ── Slack Bot ────────────────────────────────────────────────────────────────
159
+ if (effectiveSlackBotToken && effectiveSlackAppToken) {
160
+ step('Slack Bot (Sentinel Boss)…');
161
+ ok('Tokens will be written to workspace sentinel.properties');
162
+ info('Sentinel Boss starts automatically when the project starts');
163
+ } else if (setupSlack) {
164
+ warn('Slack tokens not provided — add them to config/sentinel.properties later');
165
+ }
166
+
167
+ // ── Workspace structure ─────────────────────────────────────────────────────
168
+ step('Creating workspace…');
169
+ fs.ensureDirSync(workspace);
170
+ ok(`Workspace: ${workspace}`);
171
+
172
+ // ── Example project ─────────────────────────────────────────────────────────
173
+ if (example) {
174
+ step('Creating example project…');
175
+ const exampleDir = path.join(workspace, 'my-project');
176
+ writeExampleProject(exampleDir, codeDir, pythonBin, anthropicKey || '', { botToken: effectiveSlackBotToken, appToken: effectiveSlackAppToken });
177
+ ok(`Example project: ${exampleDir}`);
178
+ }
179
+
180
+ // ── Workspace start/stop scripts ─────────────────────────────────────────────
181
+ step('Generating scripts…');
182
+ generateWorkspaceScripts(workspace, { host: smtpHost, user: smtpUser, password: effectiveSmtpPassword }, { botToken: effectiveSlackBotToken, appToken: effectiveSlackAppToken });
183
+ ok(`${workspace}/startAll.sh`);
184
+ ok(`${workspace}/stopAll.sh`);
185
+
186
+ // ── systemd ──────────────────────────────────────────────────────────────────
187
+ if (systemd) {
188
+ step('Setting up systemd…');
189
+ setupSystemd(workspace);
190
+ }
191
+
192
+ // ── Done ─────────────────────────────────────────────────────────────────────
193
+ console.log('\n' + chalk.bold.green('══ Done! ══') + '\n');
194
+ console.log(`${chalk.bold('Next steps:')}`);
195
+ if (example) {
196
+ console.log(`
197
+ 1. Configure your first project:
198
+ ${chalk.cyan(`${workspace}/my-project/config/sentinel.properties`)}
199
+ ${chalk.cyan(`${workspace}/my-project/config/log-configs/`)}
200
+ ${chalk.cyan(`${workspace}/my-project/config/repo-configs/`)}
201
+
202
+ 2. Start all projects (Sentinel clones repos, indexes, and monitors automatically):
203
+ ${chalk.cyan(`${workspace}/startAll.sh`)}
204
+
205
+ 3. Stop all projects:
206
+ ${chalk.cyan(`${workspace}/stopAll.sh`)}
207
+ `);
208
+ }
209
+ if (systemd) {
210
+ console.log(` Auto-start is enabled. To manage:
211
+ ${chalk.cyan('sudo systemctl start sentinel')}
212
+ ${chalk.cyan('sudo systemctl status sentinel')}
213
+ ${chalk.cyan('journalctl -u sentinel -f')}
214
+ `);
215
+ }
216
+
217
+ console.log(` Add another project anytime:
218
+ ${chalk.cyan('sentinel add <project-name>')}
219
+ `);
220
+ };
221
+
222
+ // ── Helpers ──────────────────────────────────────────────────────────────────
223
+
224
+ function readExistingConfig(workspace) {
225
+ const result = {};
226
+ _parsePropsInto(path.join(workspace, 'sentinel.properties'), result);
227
+ // Migrate: if Slack tokens not in workspace config, scan project configs as fallback
228
+ if (!result.SLACK_BOT_TOKEN || !result.SLACK_APP_TOKEN) {
229
+ try {
230
+ for (const entry of fs.readdirSync(workspace)) {
231
+ const p = path.join(workspace, entry, 'config', 'sentinel.properties');
232
+ const proj = {};
233
+ _parsePropsInto(p, proj);
234
+ if (!result.SLACK_BOT_TOKEN && proj.SLACK_BOT_TOKEN) result.SLACK_BOT_TOKEN = proj.SLACK_BOT_TOKEN;
235
+ if (!result.SLACK_APP_TOKEN && proj.SLACK_APP_TOKEN) result.SLACK_APP_TOKEN = proj.SLACK_APP_TOKEN;
236
+ if (result.SLACK_BOT_TOKEN && result.SLACK_APP_TOKEN) break;
237
+ }
238
+ } catch (_) {}
239
+ }
240
+ return result;
241
+ }
242
+
243
+ function _parsePropsInto(propsPath, result) {
244
+ if (!fs.existsSync(propsPath)) return;
245
+ try {
246
+ const lines = fs.readFileSync(propsPath, 'utf8').split(/\r?\n/);
247
+ for (const raw of lines) {
248
+ const line = raw.trim();
249
+ if (!line || line.startsWith('#')) continue;
250
+ const idx = line.indexOf('=');
251
+ if (idx === -1) continue;
252
+ result[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
253
+ }
254
+ } catch (_) {}
255
+ }
256
+
257
+ function findPython() {
258
+ for (const bin of ['python3', 'python']) {
259
+ try {
260
+ execSync(`${bin} --version`, { stdio: 'pipe' });
261
+ return bin;
262
+ } catch (_) {}
263
+ }
264
+ return null;
265
+ }
266
+
267
+ function run(bin, args) {
268
+ const r = spawnSync(bin, args, { encoding: 'utf8' });
269
+ return (r.stdout || '') + (r.stderr || '');
270
+ }
271
+
272
+ function runLive(bin, args) {
273
+ const r = spawnSync(bin, args, { stdio: 'inherit' });
274
+ if (r.status !== 0) {
275
+ console.error(chalk.red(` ✖ Command failed: ${bin} ${args.join(' ')}`));
276
+ process.exit(1);
277
+ }
278
+ }
279
+
280
+ function installNpmGlobal(pkg, checkBin) {
281
+ try {
282
+ execSync(`${checkBin} --version`, { stdio: 'pipe' });
283
+ ok(`${pkg} already installed`);
284
+ } catch (_) {
285
+ info(`Installing ${pkg}…`);
286
+ runLive('npm', ['install', '-g', pkg]);
287
+ ok(`${pkg} installed`);
288
+ }
289
+ }
290
+
291
+ function ensureClaudePermissions() {
292
+ const settingsPath = require('path').join(require('os').homedir(), '.claude', 'settings.json');
293
+ const required = ['Read(**)', 'Write(**)', 'Edit(**)', 'Bash(**)'];
294
+ let settings = {};
295
+ try {
296
+ if (fs.existsSync(settingsPath)) {
297
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
298
+ }
299
+ } catch (e) {
300
+ warn('Could not read ' + settingsPath + ': ' + e.message);
301
+ return;
302
+ }
303
+ if (!settings.permissions) settings.permissions = {};
304
+ if (!Array.isArray(settings.permissions.allow)) settings.permissions.allow = [];
305
+ const existing = new Set(settings.permissions.allow);
306
+ const added = [];
307
+ for (const perm of required) {
308
+ if (!existing.has(perm)) {
309
+ settings.permissions.allow.push(perm);
310
+ added.push(perm);
311
+ }
312
+ }
313
+ if (added.length === 0) {
314
+ ok('Claude Code permissions already configured');
315
+ return;
316
+ }
317
+ try {
318
+ fs.ensureDirSync(require('path').dirname(settingsPath));
319
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '
320
+ ');
321
+ ok('Claude Code permissions patched: ' + added.join(', '));
322
+ } catch (e) {
323
+ warn('Could not write ' + settingsPath + ': ' + e.message);
324
+ }
325
+ }
326
+ function setupSystemd(workspace) {
327
+ const user = os.userInfo().username;
328
+ const svc = `/etc/systemd/system/sentinel.service`;
329
+ const content = `[Unit]
330
+ Description=Sentinel — Autonomous DevOps Agent
331
+ After=network-online.target
332
+ Wants=network-online.target
333
+
334
+ [Service]
335
+ Type=forking
336
+ User=${user}
337
+ WorkingDirectory=${workspace}
338
+ ExecStart=${workspace}/startAll.sh
339
+ ExecStop=${workspace}/stopAll.sh
340
+ Restart=on-failure
341
+ RestartSec=10
342
+
343
+ [Install]
344
+ WantedBy=multi-user.target
345
+ `;
346
+ try {
347
+ fs.writeFileSync('/tmp/sentinel.service', content);
348
+ execSync(`sudo mv /tmp/sentinel.service ${svc}`);
349
+ execSync('sudo systemctl daemon-reload');
350
+ execSync('sudo systemctl enable sentinel');
351
+ ok('sentinel.service enabled');
352
+ } catch (e) {
353
+ warn(`Could not write systemd service (need sudo): ${e.message}`);
354
+ warn(`Manually create ${svc} to auto-start on reboot`);
355
+ }
356
+ }