@misterhuydo/sentinel 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.
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const chalk = require('chalk');
5
+ const [,, command = 'help', ...args] = process.argv;
6
+
7
+ const BANNER = `
8
+ ${chalk.cyan('███████╗███████╗███╗ ██╗████████╗██╗███╗ ██╗███████╗██╗')}
9
+ ${chalk.cyan('██╔════╝██╔════╝████╗ ██║╚══██╔══╝██║████╗ ██║██╔════╝██║')}
10
+ ${chalk.cyan('███████╗█████╗ ██╔██╗ ██║ ██║ ██║██╔██╗ ██║█████╗ ██║')}
11
+ ${chalk.cyan('╚════██║██╔══╝ ██║╚██╗██║ ██║ ██║██║╚██╗██║██╔══╝ ██║')}
12
+ ${chalk.cyan('███████║███████╗██║ ╚████║ ██║ ██║██║ ╚████║███████╗███████╗')}
13
+ ${chalk.cyan('╚══════╝╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚══════╝')}
14
+ ${chalk.gray(' Autonomous DevOps Agent')}
15
+ `;
16
+
17
+ async function main() {
18
+ console.log(BANNER);
19
+
20
+ switch (command) {
21
+ case 'init':
22
+ await require('../lib/init')();
23
+ break;
24
+ case 'add':
25
+ await require('../lib/add')(args[0]);
26
+ break;
27
+ case 'help':
28
+ default:
29
+ console.log(`${chalk.bold('Usage:')}
30
+ sentinel init Interactive setup — install everything and create workspace
31
+ sentinel add <name> Add a new project instance to an existing workspace
32
+ `);
33
+ }
34
+ }
35
+
36
+ main().catch(err => {
37
+ console.error(chalk.red('Error:'), err.message);
38
+ process.exit(1);
39
+ });
package/lib/add.js ADDED
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs-extra');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const prompts = require('prompts');
7
+ const chalk = require('chalk');
8
+ const { writeExampleProject, generateWorkspaceScripts } = require('./generate');
9
+
10
+ module.exports = async function add(nameArg) {
11
+ const answers = await prompts([
12
+ {
13
+ type: 'text',
14
+ name: 'workspace',
15
+ message: 'Workspace directory',
16
+ initial: path.join(os.homedir(), 'sentinel'),
17
+ format: v => v.replace(/^~/, os.homedir()),
18
+ },
19
+ {
20
+ type: 'text',
21
+ name: 'name',
22
+ message: 'Project name',
23
+ initial: nameArg || 'my-project',
24
+ validate: v => /^[a-z0-9_-]+$/i.test(v) || 'Use letters, numbers, hyphens only',
25
+ },
26
+ ], { onCancel: () => process.exit(0) });
27
+
28
+ const { workspace, name } = answers;
29
+ const codeDir = path.join(workspace, 'code');
30
+ const pythonBin = path.join(codeDir, '.venv', 'bin', 'python3');
31
+ const projectDir = path.join(workspace, name);
32
+
33
+ if (!fs.existsSync(codeDir)) {
34
+ console.error(chalk.red(`Sentinel code not found at ${codeDir}`));
35
+ console.error(chalk.red('Run "sentinel init" first.'));
36
+ process.exit(1);
37
+ }
38
+
39
+ if (fs.existsSync(projectDir)) {
40
+ console.error(chalk.yellow(`Project "${name}" already exists at ${projectDir}`));
41
+ process.exit(1);
42
+ }
43
+
44
+ writeExampleProject(projectDir, codeDir, pythonBin);
45
+ generateWorkspaceScripts(workspace);
46
+
47
+ console.log('\n' + chalk.green('✔'), `Project "${name}" created at ${projectDir}`);
48
+ console.log(`
49
+ Next steps:
50
+ 1. Edit config files in:
51
+ ${chalk.cyan(`${projectDir}/config/`)}
52
+ 2. Run first-time init:
53
+ ${chalk.cyan(`${projectDir}/init.sh`)}
54
+ 3. Start this project:
55
+ ${chalk.cyan(`${projectDir}/start.sh`)}
56
+ `);
57
+ };
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs-extra');
4
+ const path = require('path');
5
+
6
+ // ── Per-project files ─────────────────────────────────────────────────────────
7
+
8
+ function writeExampleProject(projectDir, codeDir, pythonBin) {
9
+ const configDir = path.join(projectDir, 'config', 'log-configs');
10
+ const repoDir = path.join(projectDir, 'config', 'repo-configs');
11
+ fs.ensureDirSync(configDir);
12
+ fs.ensureDirSync(repoDir);
13
+
14
+ // Copy example config templates from bundled python/
15
+ const tplDir = path.join(__dirname, '..', 'templates');
16
+ fs.copySync(path.join(tplDir, 'sentinel.properties'), path.join(projectDir, 'config', 'sentinel.properties'));
17
+ fs.copySync(path.join(tplDir, 'log-configs', '_example.properties'), path.join(configDir, '_example.properties'));
18
+ fs.copySync(path.join(tplDir, 'repo-configs', '_example.properties'), path.join(repoDir, '_example.properties'));
19
+
20
+ generateProjectScripts(projectDir, codeDir, pythonBin);
21
+ }
22
+
23
+ function generateProjectScripts(projectDir, codeDir, pythonBin) {
24
+ const name = path.basename(projectDir);
25
+
26
+ // init.sh
27
+ fs.writeFileSync(path.join(projectDir, 'init.sh'), `#!/usr/bin/env bash
28
+ # First-time setup: clone repos, test SSH, send test email
29
+ set -euo pipefail
30
+ cd "$(dirname "$0")"
31
+ PYTHONPATH="${codeDir}" "${pythonBin}" -m sentinel.main --config ./config --init
32
+ `, { mode: 0o755 });
33
+
34
+ // start.sh
35
+ fs.writeFileSync(path.join(projectDir, 'start.sh'), `#!/usr/bin/env bash
36
+ # Start this Sentinel instance
37
+ set -euo pipefail
38
+ DIR="$(cd "$(dirname "$0")" && pwd)"
39
+ PID_FILE="$DIR/sentinel.pid"
40
+
41
+ if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
42
+ echo "[sentinel] ${name} already running (PID $(cat "$PID_FILE"))"
43
+ exit 0
44
+ fi
45
+
46
+ mkdir -p "$DIR/logs" "$DIR/workspace/fetched" "$DIR/workspace/patches"
47
+ cd "$DIR"
48
+ PYTHONPATH="${codeDir}" "${pythonBin}" -m sentinel.main --config ./config \\
49
+ >> "$DIR/logs/sentinel.log" 2>&1 &
50
+ echo $! > "$PID_FILE"
51
+ echo "[sentinel] ${name} started (PID $!)"
52
+ `, { mode: 0o755 });
53
+
54
+ // stop.sh
55
+ fs.writeFileSync(path.join(projectDir, 'stop.sh'), `#!/usr/bin/env bash
56
+ # Stop this Sentinel instance
57
+ set -euo pipefail
58
+ DIR="$(cd "$(dirname "$0")" && pwd)"
59
+ PID_FILE="$DIR/sentinel.pid"
60
+
61
+ if [[ ! -f "$PID_FILE" ]]; then
62
+ echo "[sentinel] ${name} — no PID file, not running"
63
+ exit 0
64
+ fi
65
+
66
+ PID=$(cat "$PID_FILE")
67
+ if kill -0 "$PID" 2>/dev/null; then
68
+ kill "$PID"
69
+ echo "[sentinel] ${name} stopped (PID $PID)"
70
+ else
71
+ echo "[sentinel] ${name} — PID $PID not running"
72
+ fi
73
+ rm -f "$PID_FILE"
74
+ `, { mode: 0o755 });
75
+ }
76
+
77
+ // ── Workspace-level startAll / stopAll ────────────────────────────────────────
78
+
79
+ function generateWorkspaceScripts(workspace) {
80
+ // startAll.sh
81
+ fs.writeFileSync(path.join(workspace, 'startAll.sh'), `#!/usr/bin/env bash
82
+ # Start all Sentinel project instances
83
+ WORKSPACE="$(cd "$(dirname "$0")" && pwd)"
84
+ started=0
85
+ for project_dir in "$WORKSPACE"/*/; do
86
+ name=$(basename "$project_dir")
87
+ [[ "$name" == "code" ]] && continue
88
+ [[ -f "$project_dir/start.sh" ]] || continue
89
+ bash "$project_dir/start.sh"
90
+ started=$((started + 1))
91
+ done
92
+ echo "[sentinel] $started project(s) started"
93
+ `, { mode: 0o755 });
94
+
95
+ // stopAll.sh
96
+ fs.writeFileSync(path.join(workspace, 'stopAll.sh'), `#!/usr/bin/env bash
97
+ # Stop all Sentinel project instances
98
+ WORKSPACE="$(cd "$(dirname "$0")" && pwd)"
99
+ stopped=0
100
+ for project_dir in "$WORKSPACE"/*/; do
101
+ name=$(basename "$project_dir")
102
+ [[ "$name" == "code" ]] && continue
103
+ [[ -f "$project_dir/stop.sh" ]] || continue
104
+ bash "$project_dir/stop.sh"
105
+ stopped=$((stopped + 1))
106
+ done
107
+ echo "[sentinel] $stopped project(s) stopped"
108
+ `, { mode: 0o755 });
109
+ }
110
+
111
+ module.exports = { writeExampleProject, generateProjectScripts, generateWorkspaceScripts };
package/lib/init.js ADDED
@@ -0,0 +1,206 @@
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
+ // ── Prompts ─────────────────────────────────────────────────────────────────
18
+ const answers = await prompts([
19
+ {
20
+ type: 'text',
21
+ name: 'workspace',
22
+ message: 'Workspace directory (each project lives here as a subdirectory)',
23
+ initial: path.join(os.homedir(), 'sentinel'),
24
+ format: v => v.replace(/^~/, os.homedir()),
25
+ },
26
+ {
27
+ type: 'confirm',
28
+ name: 'example',
29
+ message: 'Create an example project to show how to configure?',
30
+ initial: true,
31
+ },
32
+ {
33
+ type: 'confirm',
34
+ name: 'systemd',
35
+ message: 'Set up systemd service for auto-start on reboot?',
36
+ initial: process.platform === 'linux',
37
+ },
38
+ ], { onCancel: () => process.exit(0) });
39
+
40
+ const { workspace, example, systemd } = answers;
41
+ const codeDir = path.join(workspace, 'code');
42
+
43
+ // ── Python ──────────────────────────────────────────────────────────────────
44
+ step('Checking Python…');
45
+ const python = findPython();
46
+ if (!python) {
47
+ console.error(chalk.red(' ✖ python3 not found. Install it first:'));
48
+ console.error(' sudo apt-get install -y python3 python3-pip python3-venv');
49
+ process.exit(1);
50
+ }
51
+ ok(`Python: ${run(python, ['--version']).trim()}`);
52
+
53
+ // ── Copy Sentinel Python source ─────────────────────────────────────────────
54
+ step('Installing Sentinel code…');
55
+ const bundledPython = path.join(__dirname, '..', 'python');
56
+ if (!fs.existsSync(bundledPython)) {
57
+ console.error(chalk.red(' ✖ Bundled Python source not found (run npm publish from the Sentinel repo)'));
58
+ process.exit(1);
59
+ }
60
+ fs.ensureDirSync(codeDir);
61
+ fs.copySync(bundledPython, codeDir, { overwrite: true });
62
+ ok(`Sentinel code → ${codeDir}`);
63
+
64
+ // ── Python venv ─────────────────────────────────────────────────────────────
65
+ step('Setting up Python environment…');
66
+ const venv = path.join(codeDir, '.venv');
67
+ if (!fs.existsSync(venv)) {
68
+ info('Creating virtual environment…');
69
+ runLive(python, ['-m', 'venv', venv]);
70
+ }
71
+ const pip = path.join(venv, 'bin', 'pip3');
72
+ const pythonBin = path.join(venv, 'bin', 'python3');
73
+ info('Installing Python packages…');
74
+ runLive(pip, ['install', '--quiet', '--upgrade', 'pip']);
75
+ runLive(pip, ['install', '--quiet', '-r', path.join(codeDir, 'requirements.txt')]);
76
+ ok('Python packages installed');
77
+
78
+ // ── Node tools ──────────────────────────────────────────────────────────────
79
+ step('Installing Node tools…');
80
+ installNpmGlobal('@misterhuydo/cairn-mcp', 'cairn');
81
+ installNpmGlobal('@anthropic-ai/claude-code', 'claude');
82
+
83
+ // ── Workspace structure ─────────────────────────────────────────────────────
84
+ step('Creating workspace…');
85
+ fs.ensureDirSync(workspace);
86
+ ok(`Workspace: ${workspace}`);
87
+
88
+ // ── Example project ─────────────────────────────────────────────────────────
89
+ if (example) {
90
+ step('Creating example project…');
91
+ const exampleDir = path.join(workspace, 'my-project');
92
+ writeExampleProject(exampleDir, codeDir, pythonBin);
93
+ ok(`Example project: ${exampleDir}`);
94
+ }
95
+
96
+ // ── Workspace start/stop scripts ─────────────────────────────────────────────
97
+ step('Generating scripts…');
98
+ generateWorkspaceScripts(workspace);
99
+ ok(`${workspace}/startAll.sh`);
100
+ ok(`${workspace}/stopAll.sh`);
101
+
102
+ // ── systemd ──────────────────────────────────────────────────────────────────
103
+ if (systemd) {
104
+ step('Setting up systemd…');
105
+ setupSystemd(workspace);
106
+ }
107
+
108
+ // ── Done ─────────────────────────────────────────────────────────────────────
109
+ console.log('\n' + chalk.bold.green('══ Done! ══') + '\n');
110
+ console.log(`${chalk.bold('Next steps:')}`);
111
+ if (example) {
112
+ console.log(`
113
+ 1. Configure your first project:
114
+ ${chalk.cyan(`${workspace}/my-project/config/sentinel.properties`)}
115
+ ${chalk.cyan(`${workspace}/my-project/config/log-configs/`)}
116
+ ${chalk.cyan(`${workspace}/my-project/config/repo-configs/`)}
117
+
118
+ 2. First-time init (clones repos, tests SSH, sends test email):
119
+ ${chalk.cyan(`${workspace}/my-project/init.sh`)}
120
+
121
+ 3. Start all projects:
122
+ ${chalk.cyan(`${workspace}/startAll.sh`)}
123
+
124
+ 4. Stop all projects:
125
+ ${chalk.cyan(`${workspace}/stopAll.sh`)}
126
+ `);
127
+ }
128
+ if (systemd) {
129
+ console.log(` Auto-start is enabled. To manage:
130
+ ${chalk.cyan('sudo systemctl start sentinel')}
131
+ ${chalk.cyan('sudo systemctl status sentinel')}
132
+ ${chalk.cyan('journalctl -u sentinel -f')}
133
+ `);
134
+ }
135
+ console.log(` Add another project anytime:
136
+ ${chalk.cyan('sentinel add <project-name>')}
137
+ `);
138
+ };
139
+
140
+ // ── Helpers ──────────────────────────────────────────────────────────────────
141
+
142
+ function findPython() {
143
+ for (const bin of ['python3', 'python']) {
144
+ try {
145
+ execSync(`${bin} --version`, { stdio: 'pipe' });
146
+ return bin;
147
+ } catch (_) {}
148
+ }
149
+ return null;
150
+ }
151
+
152
+ function run(bin, args) {
153
+ const r = spawnSync(bin, args, { encoding: 'utf8' });
154
+ return (r.stdout || '') + (r.stderr || '');
155
+ }
156
+
157
+ function runLive(bin, args) {
158
+ const r = spawnSync(bin, args, { stdio: 'inherit' });
159
+ if (r.status !== 0) {
160
+ console.error(chalk.red(` ✖ Command failed: ${bin} ${args.join(' ')}`));
161
+ process.exit(1);
162
+ }
163
+ }
164
+
165
+ function installNpmGlobal(pkg, checkBin) {
166
+ try {
167
+ execSync(`${checkBin} --version`, { stdio: 'pipe' });
168
+ ok(`${pkg} already installed`);
169
+ } catch (_) {
170
+ info(`Installing ${pkg}…`);
171
+ runLive('npm', ['install', '-g', pkg]);
172
+ ok(`${pkg} installed`);
173
+ }
174
+ }
175
+
176
+ function setupSystemd(workspace) {
177
+ const user = os.userInfo().username;
178
+ const svc = `/etc/systemd/system/sentinel.service`;
179
+ const content = `[Unit]
180
+ Description=Sentinel — Autonomous DevOps Agent
181
+ After=network-online.target
182
+ Wants=network-online.target
183
+
184
+ [Service]
185
+ Type=forking
186
+ User=${user}
187
+ WorkingDirectory=${workspace}
188
+ ExecStart=${workspace}/startAll.sh
189
+ ExecStop=${workspace}/stopAll.sh
190
+ Restart=on-failure
191
+ RestartSec=10
192
+
193
+ [Install]
194
+ WantedBy=multi-user.target
195
+ `;
196
+ try {
197
+ fs.writeFileSync('/tmp/sentinel.service', content);
198
+ execSync(`sudo mv /tmp/sentinel.service ${svc}`);
199
+ execSync('sudo systemctl daemon-reload');
200
+ execSync('sudo systemctl enable sentinel');
201
+ ok('sentinel.service enabled');
202
+ } catch (e) {
203
+ warn(`Could not write systemd service (need sudo): ${e.message}`);
204
+ warn(`Manually create ${svc} to auto-start on reboot`);
205
+ }
206
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@misterhuydo/sentinel",
3
+ "version": "1.0.0",
4
+ "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
+ "bin": {
6
+ "sentinel": "./bin/sentinel.js"
7
+ },
8
+ "scripts": {
9
+ "prepublishOnly": "node scripts/bundle.js"
10
+ },
11
+ "dependencies": {
12
+ "chalk": "^4.1.2",
13
+ "fs-extra": "^11.2.0",
14
+ "prompts": "^2.4.2"
15
+ },
16
+ "engines": {
17
+ "node": ">=16"
18
+ },
19
+ "author": "misterhuydo",
20
+ "license": "MIT"
21
+ }
@@ -0,0 +1,5 @@
1
+ paramiko>=3.4
2
+ schedule>=1.2
3
+ python-dotenv>=1.0
4
+ requests>=2.31
5
+ jinja2>=3.1
File without changes
@@ -0,0 +1,45 @@
1
+ """
2
+ cairn_client.py — Cairn MCP integration.
3
+
4
+ Cairn is a passive MCP tool: when properly installed and configured in Claude
5
+ Code's MCP settings, it is used automatically by Claude during fix generation.
6
+ Sentinel's only responsibility is to pre-index repos at startup so the index
7
+ exists before the first fix attempt.
8
+
9
+ Install: npm install -g @misterhuydo/cairn-mcp
10
+ """
11
+
12
+ import logging
13
+ import subprocess
14
+
15
+ from .config_loader import RepoConfig
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ CAIRN_BIN = "cairn"
20
+
21
+
22
+ def ensure_installed() -> bool:
23
+ try:
24
+ r = subprocess.run([CAIRN_BIN, "--version"], capture_output=True, text=True, timeout=10)
25
+ if r.returncode == 0:
26
+ logger.debug("Cairn version: %s", r.stdout.strip())
27
+ return True
28
+ except FileNotFoundError:
29
+ pass
30
+ logger.error("Cairn not found. Run: npm install -g @misterhuydo/cairn-mcp")
31
+ return False
32
+
33
+
34
+ def index_repo(repo: RepoConfig) -> bool:
35
+ """Index a repo so Cairn context is available for Claude Code. Run at --init."""
36
+ logger.info("Indexing %s with Cairn...", repo.repo_name)
37
+ r = subprocess.run(
38
+ [CAIRN_BIN, "index", "--path", repo.local_path],
39
+ capture_output=True, text=True, timeout=300,
40
+ )
41
+ if r.returncode != 0:
42
+ logger.error("Cairn index failed for %s:\n%s", repo.repo_name, r.stderr)
43
+ return False
44
+ logger.info("Cairn index complete for %s", repo.repo_name)
45
+ return True
@@ -0,0 +1,66 @@
1
+ """
2
+ cicd_trigger.py — Trigger CI/CD pipelines after a successful push.
3
+
4
+ Only used when AUTO_PUBLISH=true. For AUTO_PUBLISH=false, CI/CD is
5
+ triggered by the normal GitHub merge webhook.
6
+ """
7
+
8
+ import logging
9
+
10
+ import requests
11
+
12
+ from .config_loader import RepoConfig
13
+ from .state_store import StateStore
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def trigger(repo: RepoConfig, store: StateStore, fingerprint: str) -> bool:
19
+ if not repo.cicd_type or not repo.cicd_job_url:
20
+ return True
21
+
22
+ cicd_type = repo.cicd_type.lower()
23
+ if cicd_type == "jenkins":
24
+ return _trigger_jenkins(repo)
25
+ elif cicd_type in ("github_actions", "github-actions"):
26
+ return _trigger_github_actions(repo, fingerprint)
27
+ else:
28
+ logger.warning("Unknown CICD_TYPE '%s' for %s", repo.cicd_type, repo.repo_name)
29
+ return False
30
+
31
+
32
+ def _trigger_jenkins(repo: RepoConfig) -> bool:
33
+ url = f"{repo.cicd_job_url.rstrip('/')}/build"
34
+ resp = requests.post(url, auth=("sentinel", repo.cicd_token), timeout=15)
35
+ success = resp.status_code in (200, 201, 204)
36
+ if success:
37
+ logger.info("Jenkins triggered: %s", repo.cicd_job_url)
38
+ else:
39
+ logger.error("Jenkins trigger failed (%s): %s", resp.status_code, resp.text[:200])
40
+ return success
41
+
42
+
43
+ def _trigger_github_actions(repo: RepoConfig, fingerprint: str) -> bool:
44
+ owner_repo = _owner_repo(repo.repo_url)
45
+ url = f"https://api.github.com/repos/{owner_repo}/dispatches"
46
+ resp = requests.post(
47
+ url,
48
+ json={"event_type": "sentinel-deploy", "client_payload": {"fingerprint": fingerprint}},
49
+ headers={
50
+ "Authorization": f"Bearer {repo.cicd_token}",
51
+ "Accept": "application/vnd.github+json",
52
+ },
53
+ timeout=15,
54
+ )
55
+ success = resp.status_code == 204
56
+ if success:
57
+ logger.info("GitHub Actions dispatch sent for %s", owner_repo)
58
+ else:
59
+ logger.error("GH Actions dispatch failed (%s): %s", resp.status_code, resp.text[:200])
60
+ return success
61
+
62
+
63
+ def _owner_repo(repo_url: str) -> str:
64
+ if repo_url.startswith("git@"):
65
+ return repo_url.split(":")[-1].removesuffix(".git")
66
+ return "/".join(repo_url.rstrip("/").split("/")[-2:]).removesuffix(".git")