@misterhuydo/sentinel 1.4.69 → 1.4.71
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/lib/.cairn/minify-map.json +0 -12
- package/lib/add.js +103 -53
- package/package.json +1 -1
- package/python/sentinel/config_loader.py +13 -2
- package/templates/sentinel.properties +10 -0
- package/lib/.cairn/views/244a09_generate.js +0 -274
package/.cairn/.hint-lock
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-03-
|
|
1
|
+
2026-03-26T18:52:08.498Z
|
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-26T18:49:49.152Z",
|
|
3
|
+
"checkpoint_at": "2026-03-26T18:49:49.153Z",
|
|
4
4
|
"active_files": [],
|
|
5
5
|
"notes": [],
|
|
6
6
|
"mtime_snapshot": {}
|
|
@@ -1,20 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"J:\\Projects\\Sentinel\\cli\\lib\\add.js": {
|
|
3
|
-
"tempPath": "J:\\Projects\\Sentinel\\cli\\lib\\.cairn\\views\\fc4a1a_add.js",
|
|
4
|
-
"state": "edit-ready",
|
|
5
|
-
"minifiedAt": 1774403831187.837,
|
|
6
|
-
"readCount": 1
|
|
7
|
-
},
|
|
8
2
|
"J:\\Projects\\Sentinel\\cli\\lib\\upgrade.js": {
|
|
9
3
|
"tempPath": "J:\\Projects\\Sentinel\\cli\\lib\\.cairn\\views\\fb78ac_upgrade.js",
|
|
10
4
|
"state": "edit-ready",
|
|
11
5
|
"minifiedAt": 1774409075884.3267,
|
|
12
6
|
"readCount": 1
|
|
13
|
-
},
|
|
14
|
-
"J:\\Projects\\Sentinel\\cli\\lib\\generate.js": {
|
|
15
|
-
"tempPath": "J:\\Projects\\Sentinel\\cli\\lib\\.cairn\\views\\244a09_generate.js",
|
|
16
|
-
"state": "compressed",
|
|
17
|
-
"minifiedAt": 1774454098784.183,
|
|
18
|
-
"readCount": 1
|
|
19
7
|
}
|
|
20
8
|
}
|
package/lib/add.js
CHANGED
|
@@ -277,35 +277,49 @@ async function addFromGit(gitUrl, workspace) {
|
|
|
277
277
|
validate: v => VALID_NAME.test(v) || 'Use letters, numbers, hyphens only',
|
|
278
278
|
}], { onCancel: () => process.exit(0) });
|
|
279
279
|
|
|
280
|
-
// ── 1.
|
|
280
|
+
// ── 1. Set up SSH access to the primary (config) repo ─────────────────────
|
|
281
281
|
step(`[1/3] Setting up SSH access to ${repoSlug}`);
|
|
282
282
|
ensureKnownHosts();
|
|
283
|
-
const { keyFile } = generateDeployKey(repoSlug);
|
|
284
|
-
printDeployKeyInstructions(orgRepo, keyFile);
|
|
285
283
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
284
|
+
// Check if the repo is already accessible via the existing SSH key/agent.
|
|
285
|
+
// This is the case when GIT_ACCESS=ssh_user_key is configured — no deploy key needed.
|
|
286
|
+
const existingAccess = validateAccess(gitUrl, null);
|
|
287
|
+
let keyFile = null;
|
|
290
288
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
289
|
+
if (existingAccess.ok) {
|
|
290
|
+
ok(`${repoSlug}: reachable via existing SSH key — skipping deploy key generation`);
|
|
291
|
+
} else {
|
|
292
|
+
const { keyFile: generatedKey } = generateDeployKey(repoSlug);
|
|
293
|
+
keyFile = generatedKey;
|
|
294
|
+
printDeployKeyInstructions(orgRepo, keyFile);
|
|
295
|
+
|
|
296
|
+
await prompts({
|
|
297
|
+
type: 'text', name: '_', format: () => '',
|
|
298
|
+
message: chalk.bold(`Press Enter once you've added the deploy key to GitHub…`),
|
|
299
|
+
}, { onCancel: () => process.exit(0) });
|
|
300
|
+
|
|
301
|
+
// Validate primary repo with deploy key
|
|
302
|
+
const primary = validateAccess(gitUrl, keyFile);
|
|
303
|
+
if (!primary.ok) {
|
|
304
|
+
console.error(chalk.red(' ✖ Cannot reach ' + gitUrl));
|
|
305
|
+
if (primary.stderr) console.error(chalk.red(' ' + primary.stderr));
|
|
306
|
+
console.error(chalk.yellow(' Check the deploy key has write access, then re-run.'));
|
|
307
|
+
process.exit(1);
|
|
308
|
+
}
|
|
309
|
+
ok(`${repoSlug}: reachable`);
|
|
298
310
|
}
|
|
299
|
-
ok(`${repoSlug}: reachable`);
|
|
300
311
|
|
|
301
312
|
// ── 2. Clone primary repo and discover additional repos ────────────────────
|
|
302
313
|
const projectDir = path.join(workspace, name);
|
|
303
314
|
step(`[2/3] Scanning repo-configs in ${repoSlug}…`);
|
|
304
315
|
|
|
305
316
|
if (!fs.existsSync(projectDir)) {
|
|
317
|
+
const cloneEnv = keyFile
|
|
318
|
+
? gitEnv({ GIT_SSH_COMMAND: `ssh -i ${keyFile} -o StrictHostKeyChecking=no -o BatchMode=yes` })
|
|
319
|
+
: gitEnv({});
|
|
306
320
|
spawnSync(gitBin(), ['clone', '--depth', '1', gitUrl, projectDir], {
|
|
307
321
|
stdio: 'inherit',
|
|
308
|
-
env:
|
|
322
|
+
env: cloneEnv,
|
|
309
323
|
});
|
|
310
324
|
}
|
|
311
325
|
|
|
@@ -323,6 +337,23 @@ async function addFromGit(gitUrl, workspace) {
|
|
|
323
337
|
}
|
|
324
338
|
|
|
325
339
|
|
|
340
|
+
// Read project-level GIT_ACCESS from the cloned sentinel.properties
|
|
341
|
+
const sentinelPropsPath = path.join(projectDir, 'config', 'sentinel.properties');
|
|
342
|
+
let projectGitAccess = '';
|
|
343
|
+
let projectGitSshKey = '';
|
|
344
|
+
if (fs.existsSync(sentinelPropsPath)) {
|
|
345
|
+
const lines = fs.readFileSync(sentinelPropsPath, 'utf8').split('\n');
|
|
346
|
+
for (const line of lines) {
|
|
347
|
+
const trimmed = line.trim();
|
|
348
|
+
if (trimmed.startsWith('#') || !trimmed.includes('=')) continue;
|
|
349
|
+
const [k, ...rest] = trimmed.split('=');
|
|
350
|
+
const v = rest.join('=').split('#')[0].trim();
|
|
351
|
+
if (k.trim() === 'GIT_ACCESS') projectGitAccess = v;
|
|
352
|
+
if (k.trim() === 'GIT_SSH_KEY') projectGitSshKey = v.replace(/^~/, require('os').homedir());
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const useSshUserKey = projectGitAccess === 'ssh_user_key';
|
|
356
|
+
|
|
326
357
|
// Classify each discovered repo
|
|
327
358
|
const privateRepos = [];
|
|
328
359
|
const publicRepos = [];
|
|
@@ -341,50 +372,69 @@ async function addFromGit(gitUrl, workspace) {
|
|
|
341
372
|
}
|
|
342
373
|
}
|
|
343
374
|
|
|
344
|
-
// ── 3.
|
|
375
|
+
// ── 3. Set up access for all private repos ─────────────────────────────────
|
|
345
376
|
if (privateRepos.length > 0) {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
r
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
377
|
+
if (useSshUserKey) {
|
|
378
|
+
// Project uses a personal SSH key — validate access, no deploy keys needed
|
|
379
|
+
step(`[3/3] Validating access to ${privateRepos.length} private repo(s) via SSH user key…`);
|
|
380
|
+
const sshKeyForValidation = projectGitSshKey || null;
|
|
381
|
+
for (const r of privateRepos) {
|
|
382
|
+
const v = validateAccess(r.url, sshKeyForValidation);
|
|
383
|
+
if (!v.ok) {
|
|
384
|
+
console.error(chalk.red(` ✖ ${r.slug}: cannot reach ${r.url}`));
|
|
385
|
+
if (v.stderr) console.error(chalk.red(' ' + v.stderr));
|
|
386
|
+
console.error(chalk.yellow(` Check that GIT_SSH_KEY (${projectGitSshKey || 'SSH agent'}) has access, then re-run.`));
|
|
387
|
+
process.exit(1);
|
|
388
|
+
}
|
|
389
|
+
ok(`${r.slug}: reachable`);
|
|
390
|
+
}
|
|
357
391
|
for (const r of publicRepos) {
|
|
358
|
-
|
|
392
|
+
ok(`${r.slug}: public, no key needed`);
|
|
393
|
+
}
|
|
394
|
+
} else {
|
|
395
|
+
// Default: generate a deploy key per private repo
|
|
396
|
+
step(`[3/3] Deploy keys needed for ${privateRepos.length} private repo(s)`);
|
|
397
|
+
|
|
398
|
+
for (const r of privateRepos) {
|
|
399
|
+
const { keyFile: rKey } = generateDeployKey(r.slug, projectDir);
|
|
400
|
+
r.keyFile = rKey;
|
|
401
|
+
const rOrgRepo = gitUrlToOrgRepo(r.url);
|
|
402
|
+
printDeployKeyInstructions(rOrgRepo, rKey);
|
|
359
403
|
}
|
|
360
|
-
console.log('');
|
|
361
|
-
}
|
|
362
404
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
405
|
+
if (publicRepos.length > 0) {
|
|
406
|
+
console.log(chalk.green(' ✔ Public repos (no deploy key needed):'));
|
|
407
|
+
for (const r of publicRepos) {
|
|
408
|
+
console.log(chalk.green(` ${r.slug}`));
|
|
409
|
+
}
|
|
410
|
+
console.log('');
|
|
411
|
+
}
|
|
367
412
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
413
|
+
await prompts({
|
|
414
|
+
type: 'text', name: '_', format: () => '',
|
|
415
|
+
message: chalk.bold(`Press Enter once you've added all ${privateRepos.length} deploy key(s) to GitHub…`),
|
|
416
|
+
}, { onCancel: () => process.exit(0) });
|
|
417
|
+
|
|
418
|
+
// Validate each private repo
|
|
419
|
+
step('Validating repository access…');
|
|
420
|
+
for (const r of privateRepos) {
|
|
421
|
+
const v = validateAccess(r.url, r.keyFile);
|
|
422
|
+
if (!v.ok) {
|
|
423
|
+
console.error(chalk.red(` ✖ ${r.slug}: cannot reach ${r.url}`));
|
|
424
|
+
if (v.stderr) console.error(chalk.red(' ' + v.stderr));
|
|
425
|
+
console.error(chalk.yellow(' Fix access then re-run sentinel add.'));
|
|
426
|
+
process.exit(1);
|
|
427
|
+
}
|
|
428
|
+
ok(`${r.slug}: reachable`);
|
|
429
|
+
}
|
|
430
|
+
for (const r of publicRepos) {
|
|
431
|
+
ok(`${r.slug}: public, no key needed`);
|
|
377
432
|
}
|
|
378
|
-
ok(`${r.slug}: reachable`);
|
|
379
|
-
}
|
|
380
|
-
for (const r of publicRepos) {
|
|
381
|
-
ok(`${r.slug}: public, no key needed`);
|
|
382
|
-
}
|
|
383
433
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
434
|
+
// Keys stored at <projectDir>/<slug>.key — config_loader.py auto-discovers them.
|
|
435
|
+
for (const r of privateRepos) {
|
|
436
|
+
info(`Key stored at ${r.keyFile} (auto-discovered, not committed to git)`);
|
|
437
|
+
}
|
|
388
438
|
}
|
|
389
439
|
} else if (discovered.length > 0) {
|
|
390
440
|
step('[3/3] All repos are public — no deploy keys needed');
|
package/package.json
CHANGED
|
@@ -109,6 +109,7 @@ class RepoConfig:
|
|
|
109
109
|
cicd_user: str = "" # Jenkins username for Basic auth (defaults to "sentinel")
|
|
110
110
|
health_url: str = "" # optional: HTTP endpoint returning {"Status": "true"}
|
|
111
111
|
cicd_token: str = ""
|
|
112
|
+
git_access: str = "" # ssh_user_key | ssh_deploy_key | https_token | https_pat
|
|
112
113
|
ssh_key_file: str = "" # path to SSH private key; sets GIT_SSH_COMMAND when present
|
|
113
114
|
|
|
114
115
|
|
|
@@ -226,6 +227,13 @@ class ConfigLoader:
|
|
|
226
227
|
repos_dir = self.config_dir / "repo-configs"
|
|
227
228
|
if not repos_dir.exists():
|
|
228
229
|
return
|
|
230
|
+
|
|
231
|
+
# Project-level defaults from sentinel.properties
|
|
232
|
+
sentinel_path = self.config_dir / "sentinel.properties"
|
|
233
|
+
proj_d: dict[str, str] = _parse_properties(str(sentinel_path)) if sentinel_path.exists() else {}
|
|
234
|
+
default_git_access = proj_d.get("GIT_ACCESS", "")
|
|
235
|
+
default_git_ssh_key = os.path.expanduser(proj_d.get("GIT_SSH_KEY", ""))
|
|
236
|
+
|
|
229
237
|
self.repos = {}
|
|
230
238
|
for path in sorted(repos_dir.glob("*.properties")):
|
|
231
239
|
if path.name.startswith("_"):
|
|
@@ -242,8 +250,11 @@ class ConfigLoader:
|
|
|
242
250
|
r.cicd_user = d.get("CICD_USER", "")
|
|
243
251
|
r.cicd_token = d.get("CICD_TOKEN", "")
|
|
244
252
|
r.health_url = d.get("HEALTH_URL", "")
|
|
245
|
-
r.
|
|
246
|
-
#
|
|
253
|
+
r.git_access = d.get("GIT_ACCESS", default_git_access)
|
|
254
|
+
# GIT_SSH_KEY (preferred) or legacy SSH_KEY_FILE, then project default
|
|
255
|
+
raw_key = d.get("GIT_SSH_KEY", "") or d.get("SSH_KEY_FILE", "")
|
|
256
|
+
r.ssh_key_file = os.path.expanduser(raw_key) if raw_key else default_git_ssh_key
|
|
257
|
+
# Auto-discover deploy key in project dir if no key configured
|
|
247
258
|
if not r.ssh_key_file:
|
|
248
259
|
auto_key = Path(self.config_dir).parent / f"{r.repo_name}.key"
|
|
249
260
|
if auto_key.exists():
|
|
@@ -18,6 +18,16 @@ MAILS=you@yourdomain.com
|
|
|
18
18
|
# Uncomment here only if this project needs a different token.
|
|
19
19
|
# GITHUB_TOKEN=<github-pat>
|
|
20
20
|
|
|
21
|
+
# Default SSH access method for all repos in this project.
|
|
22
|
+
# Per-repo configs (config/repo-configs/*.properties) can override these.
|
|
23
|
+
#
|
|
24
|
+
# Options:
|
|
25
|
+
# ssh_user_key — personal key at GIT_SSH_KEY path (full account access, recommended)
|
|
26
|
+
# ssh_deploy_key — per-repo deploy key generated by sentinel add (default if not set)
|
|
27
|
+
#
|
|
28
|
+
# GIT_ACCESS=ssh_user_key
|
|
29
|
+
# GIT_SSH_KEY=~/.ssh/your-key
|
|
30
|
+
|
|
21
31
|
# Fix confirmation: hours of silence after a fix marker appears in production logs before
|
|
22
32
|
# the fix is declared confirmed. Increase for services that deploy infrequently.
|
|
23
33
|
# MARKER_CONFIRM_HOURS=24
|
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
const fs = require('fs-extra');
|
|
3
|
-
const path = require('path');
|
|
4
|
-
function writeExampleProject(projectDir, codeDir, pythonBin, anthropicKey = '', slackTokens = {}) {
|
|
5
|
-
const configDir = path.join(projectDir, 'config', 'log-configs');
|
|
6
|
-
const repoDir = path.join(projectDir, 'config', 'repo-configs');
|
|
7
|
-
fs.ensureDirSync(configDir);
|
|
8
|
-
fs.ensureDirSync(repoDir);
|
|
9
|
-
const tplDir = path.join(__dirname, '..', 'templates');
|
|
10
|
-
let sentinelProps = fs.readFileSync(path.join(tplDir, 'sentinel.properties'), 'utf8');
|
|
11
|
-
if (anthropicKey) {
|
|
12
|
-
sentinelProps = sentinelProps.replace(/^# ANTHROPIC_API_KEY=.*/m, `ANTHROPIC_API_KEY=${anthropicKey}`);
|
|
13
|
-
}
|
|
14
|
-
if (slackTokens.botToken) {
|
|
15
|
-
sentinelProps = sentinelProps.replace(/^# SLACK_BOT_TOKEN=.*/m, `SLACK_BOT_TOKEN=${slackTokens.botToken}`);
|
|
16
|
-
}
|
|
17
|
-
if (slackTokens.appToken) {
|
|
18
|
-
sentinelProps = sentinelProps.replace(/^# SLACK_APP_TOKEN=.*/m, `SLACK_APP_TOKEN=${slackTokens.appToken}`);
|
|
19
|
-
}
|
|
20
|
-
fs.writeFileSync(path.join(projectDir, 'config', 'sentinel.properties'), sentinelProps);
|
|
21
|
-
fs.copySync(path.join(tplDir, 'log-configs', '_example.properties'), path.join(configDir, '_example.properties'));
|
|
22
|
-
fs.copySync(path.join(tplDir, 'repo-configs', '_example.properties'), path.join(repoDir, '_example.properties'));
|
|
23
|
-
generateProjectScripts(projectDir, codeDir, pythonBin);
|
|
24
|
-
}
|
|
25
|
-
function generateProjectScripts(projectDir, codeDir, pythonBin) {
|
|
26
|
-
const name = path.basename(projectDir);
|
|
27
|
-
fs.writeFileSync(path.join(projectDir, 'start.sh'), `#!/usr/bin/env bash
|
|
28
|
-
# Start this Sentinel instance
|
|
29
|
-
set -euo pipefail
|
|
30
|
-
DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
31
|
-
PID_FILE="$DIR/sentinel.pid"
|
|
32
|
-
if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
|
33
|
-
echo "[sentinel] ${name} already running (PID $(cat "$PID_FILE"))"
|
|
34
|
-
exit 0
|
|
35
|
-
fi
|
|
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
|
|
38
|
-
rm -f "$PID_FILE"
|
|
39
|
-
WORKSPACE="$(dirname "$DIR")"
|
|
40
|
-
# Check Claude Code authentication — skip if CLAUDE_PRO_FOR_TASKS=false in either config
|
|
41
|
-
_claude_pro=true
|
|
42
|
-
for _conf in "$WORKSPACE/sentinel.properties" "$DIR/config/sentinel.properties"; do
|
|
43
|
-
if [[ -f "$_conf" ]]; then
|
|
44
|
-
_val=$(grep -iE "^CLAUDE_PRO_FOR_TASKS[[:space:]]*=" "$_conf" 2>/dev/null | tail -1 | cut -d= -f2- | tr -d ' ' | tr '[:upper:]' '[:lower:]' || true)
|
|
45
|
-
[[ -n "$_val" ]] && _claude_pro="$_val"
|
|
46
|
-
fi
|
|
47
|
-
done
|
|
48
|
-
if [[ "$_claude_pro" != "false" ]]; then
|
|
49
|
-
AUTH_OUT=$(claude --print \"hi\" 2>&1 || true)
|
|
50
|
-
if echo "$AUTH_OUT" | grep -Eqi "not logged in|/login"; then
|
|
51
|
-
echo ""
|
|
52
|
-
echo "[sentinel] Claude Code is not authenticated."
|
|
53
|
-
echo " 1. Open a new terminal and run: claude"
|
|
54
|
-
echo " 2. Type /login at the prompt"
|
|
55
|
-
echo " 3. Open the URL in any browser and log in"
|
|
56
|
-
echo " 4. Type /exit when done"
|
|
57
|
-
echo " 5. Re-run this script"
|
|
58
|
-
echo ""
|
|
59
|
-
exit 1
|
|
60
|
-
fi
|
|
61
|
-
fi
|
|
62
|
-
mkdir -p "$DIR/logs" "$WORKSPACE/logs" "$DIR/workspace/fetched" "$DIR/workspace/patches" "$DIR/issues"
|
|
63
|
-
cd "$DIR"
|
|
64
|
-
# Ensure npm-global bin (cairn-mcp, claude) and ~/.local/bin (auto-installed tools) are on PATH
|
|
65
|
-
export PATH="$HOME/.npm-global/bin:$HOME/.local/bin:$PATH"
|
|
66
|
-
PYTHONPATH="${codeDir}" "${codeDir}/.venv/bin/python3" -m sentinel.main --config ./config \\
|
|
67
|
-
>> "$DIR/logs/sentinel.log" 2>&1 &
|
|
68
|
-
echo $! > "$PID_FILE"
|
|
69
|
-
echo "[sentinel] ${name} started (PID $!)"
|
|
70
|
-
echo " project log : $DIR/logs/sentinel.log"
|
|
71
|
-
echo " workspace log: $WORKSPACE/logs/sentinel.log"
|
|
72
|
-
`, { mode: 0o755 });
|
|
73
|
-
fs.writeFileSync(path.join(projectDir, 'stop.sh'), `#!/usr/bin/env bash
|
|
74
|
-
# Stop this Sentinel instance
|
|
75
|
-
set -euo pipefail
|
|
76
|
-
DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
77
|
-
PID_FILE="$DIR/sentinel.pid"
|
|
78
|
-
if [[ ! -f "$PID_FILE" ]]; then
|
|
79
|
-
echo "[sentinel] ${name} — no PID file, not running"
|
|
80
|
-
exit 0
|
|
81
|
-
fi
|
|
82
|
-
PID=$(cat "$PID_FILE")
|
|
83
|
-
if kill -0 "$PID" 2>/dev/null; then
|
|
84
|
-
kill "$PID"
|
|
85
|
-
echo "[sentinel] ${name} stopped (PID $PID)"
|
|
86
|
-
else
|
|
87
|
-
echo "[sentinel] ${name} — PID $PID not running"
|
|
88
|
-
fi
|
|
89
|
-
rm -f "$PID_FILE"
|
|
90
|
-
`, { mode: 0o755 });
|
|
91
|
-
}
|
|
92
|
-
function generateWorkspaceScripts(workspace, smtpConfig = {}, slackConfig = {}, authConfig = {}, githubToken = '') {
|
|
93
|
-
const workspaceProps = path.join(workspace, 'sentinel.properties');
|
|
94
|
-
if (!fs.existsSync(workspaceProps)) {
|
|
95
|
-
const tplDir = path.join(__dirname, '..', 'templates');
|
|
96
|
-
let tpl = fs.readFileSync(path.join(tplDir, 'workspace-sentinel.properties'), 'utf8');
|
|
97
|
-
if (smtpConfig.host) tpl = tpl.replace('SMTP_HOST=smtp.gmail.com', 'SMTP_HOST=' + smtpConfig.host);
|
|
98
|
-
if (smtpConfig.port) tpl = tpl.replace('SMTP_PORT=587', 'SMTP_PORT=' + smtpConfig.port);
|
|
99
|
-
if (smtpConfig.user) tpl = tpl.replace('SMTP_USER=sentinel@yourdomain.com', 'SMTP_USER=' + smtpConfig.user);
|
|
100
|
-
if (smtpConfig.password) tpl = tpl.replace('SMTP_PASSWORD=<app-password>', 'SMTP_PASSWORD=' + smtpConfig.password);
|
|
101
|
-
fs.writeFileSync(workspaceProps, tpl);
|
|
102
|
-
}
|
|
103
|
-
if (authConfig.apiKey || authConfig.claudeProForTasks !== undefined) {
|
|
104
|
-
let props = fs.readFileSync(workspaceProps, 'utf8');
|
|
105
|
-
if (authConfig.apiKey) {
|
|
106
|
-
if (/^#?\s*ANTHROPIC_API_KEY=/m.test(props))
|
|
107
|
-
props = props.replace(/^#?\s*ANTHROPIC_API_KEY=.*/mg, 'ANTHROPIC_API_KEY=' + authConfig.apiKey);
|
|
108
|
-
else
|
|
109
|
-
props = props.trimEnd() + '\nANTHROPIC_API_KEY=' + authConfig.apiKey + '\n';
|
|
110
|
-
}
|
|
111
|
-
if (authConfig.claudeProForTasks !== undefined) {
|
|
112
|
-
const val = authConfig.claudeProForTasks ? 'true' : 'false';
|
|
113
|
-
if (/^#?\s*CLAUDE_PRO_FOR_TASKS=/m.test(props))
|
|
114
|
-
props = props.replace(/^#?\s*CLAUDE_PRO_FOR_TASKS=.*/mg, 'CLAUDE_PRO_FOR_TASKS=' + val);
|
|
115
|
-
else
|
|
116
|
-
props = props.trimEnd() + '\nCLAUDE_PRO_FOR_TASKS=' + val + '\n';
|
|
117
|
-
}
|
|
118
|
-
fs.writeFileSync(workspaceProps, props);
|
|
119
|
-
}
|
|
120
|
-
if (githubToken) {
|
|
121
|
-
let props = fs.readFileSync(workspaceProps, 'utf8');
|
|
122
|
-
if (/^#?\s*GITHUB_TOKEN=/m.test(props))
|
|
123
|
-
props = props.replace(/^#?\s*GITHUB_TOKEN=.*/mg, 'GITHUB_TOKEN=' + githubToken);
|
|
124
|
-
else
|
|
125
|
-
props = props.trimEnd() + '\nGITHUB_TOKEN=' + githubToken + '\n';
|
|
126
|
-
fs.writeFileSync(workspaceProps, props);
|
|
127
|
-
}
|
|
128
|
-
if (slackConfig.botToken || slackConfig.appToken) {
|
|
129
|
-
let props = fs.readFileSync(workspaceProps, 'utf8');
|
|
130
|
-
if (slackConfig.botToken) {
|
|
131
|
-
if (/^#?\s*SLACK_BOT_TOKEN=/m.test(props))
|
|
132
|
-
props = props.replace(/^#?\s*SLACK_BOT_TOKEN=.*/mg, 'SLACK_BOT_TOKEN=' + slackConfig.botToken);
|
|
133
|
-
else
|
|
134
|
-
props = props.trimEnd() + '\nSLACK_BOT_TOKEN=' + slackConfig.botToken + '\n';
|
|
135
|
-
}
|
|
136
|
-
if (slackConfig.appToken) {
|
|
137
|
-
if (/^#?\s*SLACK_APP_TOKEN=/m.test(props))
|
|
138
|
-
props = props.replace(/^#?\s*SLACK_APP_TOKEN=.*/mg, 'SLACK_APP_TOKEN=' + slackConfig.appToken);
|
|
139
|
-
else
|
|
140
|
-
props = props.trimEnd() + '\nSLACK_APP_TOKEN=' + slackConfig.appToken + '\n';
|
|
141
|
-
}
|
|
142
|
-
fs.writeFileSync(workspaceProps, props);
|
|
143
|
-
}
|
|
144
|
-
fs.writeFileSync(path.join(workspace, 'startAll.sh'), `#!/usr/bin/env bash
|
|
145
|
-
# Start all valid Sentinel project instances.
|
|
146
|
-
# A valid project must have config/repo-configs; do
|
|
147
|
-
[[ -d "$project_dir" ]] || continue
|
|
148
|
-
name=$(basename "$project_dir")
|
|
149
|
-
echo " $NON_PROJECT " | grep -qw "$name" && continue
|
|
150
|
-
# Auto-generate start.sh / stop.sh if missing (codeDir = $WORKSPACE/code)
|
|
151
|
-
if [[ ! -f "$project_dir/start.sh" ]]; then
|
|
152
|
-
code_dir="$WORKSPACE/code"
|
|
153
|
-
python_bin="$code_dir/.venv/bin/python3"
|
|
154
|
-
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"
|
|
155
|
-
#!/usr/bin/env bash
|
|
156
|
-
set -euo pipefail
|
|
157
|
-
DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
158
|
-
PID_FILE="$DIR/sentinel.pid"
|
|
159
|
-
if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
|
160
|
-
echo "[sentinel] __NAME__ already running (PID $(cat "$PID_FILE"))"
|
|
161
|
-
exit 0
|
|
162
|
-
fi
|
|
163
|
-
pkill -f "sentinel.main --config $DIR/config" 2>/dev/null || true
|
|
164
|
-
rm -f "$PID_FILE"
|
|
165
|
-
WORKSPACE="$(dirname "$DIR")"
|
|
166
|
-
_claude_pro=true
|
|
167
|
-
for _conf in "$WORKSPACE/sentinel.properties" "$DIR/config/sentinel.properties"; do
|
|
168
|
-
if [[ -f "$_conf" ]]; then
|
|
169
|
-
_val=$(grep -iE "^CLAUDE_PRO_FOR_TASKS[[:space:]]*=" "$_conf" 2>/dev/null | tail -1 | cut -d= -f2- | tr -d ' ' | tr '[:upper:]' '[:lower:]' || true)
|
|
170
|
-
[[ -n "$_val" ]] && _claude_pro="$_val"
|
|
171
|
-
fi
|
|
172
|
-
done
|
|
173
|
-
if [[ "$_claude_pro" != "false" ]]; then
|
|
174
|
-
AUTH_OUT=$(claude --print \"hi\" 2>&1 || true)
|
|
175
|
-
if echo "$AUTH_OUT" | grep -Eqi "not logged in|/login"; then
|
|
176
|
-
echo "[sentinel] Claude Code is not authenticated. Run: claude then /login"
|
|
177
|
-
exit 1
|
|
178
|
-
fi
|
|
179
|
-
fi
|
|
180
|
-
mkdir -p "$DIR/logs" "$WORKSPACE/logs" "$DIR/workspace/fetched" "$DIR/workspace/patches" "$DIR/issues"
|
|
181
|
-
cd "$DIR"
|
|
182
|
-
PYTHONPATH="__CODE_DIR__" "__PYTHON_BIN__" -m sentinel.main --config ./config \
|
|
183
|
-
> "$DIR/logs/sentinel.log" 2>&1 &
|
|
184
|
-
echo $! > "$PID_FILE"
|
|
185
|
-
echo "[sentinel] __NAME__ started (PID $!)"
|
|
186
|
-
echo " project log : $DIR/logs/sentinel.log"
|
|
187
|
-
echo " workspace log: $WORKSPACE/logs/sentinel.log"
|
|
188
|
-
STARTSH
|
|
189
|
-
chmod +x "$project_dir/start.sh"
|
|
190
|
-
echo "[sentinel] Auto-generated start.sh for $name"
|
|
191
|
-
fi
|
|
192
|
-
if [[ ! -f "$project_dir/stop.sh" ]]; then
|
|
193
|
-
sed -e "s|__NAME__|$name|g" << 'STOPSH' > "$project_dir/stop.sh"
|
|
194
|
-
#!/usr/bin/env bash
|
|
195
|
-
set -euo pipefail
|
|
196
|
-
DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
197
|
-
PID_FILE="$DIR/sentinel.pid"
|
|
198
|
-
if [[ ! -f "$PID_FILE" ]]; then
|
|
199
|
-
echo "[sentinel] __NAME__ — no PID file, not running"
|
|
200
|
-
exit 0
|
|
201
|
-
fi
|
|
202
|
-
PID=$(cat "$PID_FILE")
|
|
203
|
-
if kill -0 "$PID" 2>/dev/null; then
|
|
204
|
-
kill "$PID"
|
|
205
|
-
echo "[sentinel] __NAME__ stopped (PID $PID)"
|
|
206
|
-
else
|
|
207
|
-
echo "[sentinel] __NAME__ — PID $PID not running"
|
|
208
|
-
fi
|
|
209
|
-
rm -f "$PID_FILE"
|
|
210
|
-
STOPSH
|
|
211
|
-
chmod +x "$project_dir/stop.sh"
|
|
212
|
-
echo "[sentinel] Auto-generated stop.sh for $name"
|
|
213
|
-
fi
|
|
214
|
-
# Must have at least one repo-config with a valid GitHub REPO_URL
|
|
215
|
-
repo_configs_dir="$project_dir/config/repo-configs"
|
|
216
|
-
if [[ ! -d "$repo_configs_dir" ]]; then
|
|
217
|
-
echo "[sentinel] Skipping $name — config/repo-configs/ directory not found"
|
|
218
|
-
skipped=$((skipped + 1))
|
|
219
|
-
continue
|
|
220
|
-
fi
|
|
221
|
-
has_config=false
|
|
222
|
-
valid_repo=false
|
|
223
|
-
for props in "$repo_configs_dir/"*.properties; do
|
|
224
|
-
[[ -f "$props" ]] || continue
|
|
225
|
-
[[ "$(basename "$props")" == _* ]] && continue
|
|
226
|
-
has_config=true
|
|
227
|
-
if grep -qE "^REPO_URL[[:space:]]*=[[:space:]]*(git@github\.com:|https://github\.com/)" "$props"; then
|
|
228
|
-
valid_repo=true
|
|
229
|
-
break
|
|
230
|
-
else
|
|
231
|
-
repo_url=$(grep -E "^REPO_URL[[:space:]]*=" "$props" | head -1 | cut -d= -f2- | xargs 2>/dev/null || true)
|
|
232
|
-
if [[ -z "$repo_url" ]]; then
|
|
233
|
-
echo "[sentinel] Skipping $name — REPO_URL not set in $(basename \"$props\")"
|
|
234
|
-
else
|
|
235
|
-
echo "[sentinel] Skipping $name — REPO_URL in $(basename \"$props\") is not a GitHub URL: $repo_url"
|
|
236
|
-
fi
|
|
237
|
-
fi
|
|
238
|
-
done
|
|
239
|
-
if [[ "$has_config" == "false" ]]; then
|
|
240
|
-
echo "[sentinel] Skipping $name — no .properties files in config/repo-configs/ (only _example?)"
|
|
241
|
-
skipped=$((skipped + 1))
|
|
242
|
-
continue
|
|
243
|
-
fi
|
|
244
|
-
if [[ "$valid_repo" == "false" ]]; then
|
|
245
|
-
skipped=$((skipped + 1))
|
|
246
|
-
continue
|
|
247
|
-
fi
|
|
248
|
-
if bash "$project_dir/start.sh"; then
|
|
249
|
-
started=$((started + 1))
|
|
250
|
-
else
|
|
251
|
-
echo "[sentinel] Failed to start $name"
|
|
252
|
-
skipped=$((skipped + 1))
|
|
253
|
-
fi
|
|
254
|
-
done
|
|
255
|
-
echo "[sentinel] $started project(s) started, $skipped skipped"
|
|
256
|
-
`, { mode: 0o755 });
|
|
257
|
-
// stopAll.sh
|
|
258
|
-
fs.writeFileSync(path.join(workspace, 'stopAll.sh'), `#!/usr/bin/env bash
|
|
259
|
-
# Stop all Sentinel project instances
|
|
260
|
-
WORKSPACE="$(cd "$(dirname "$0")" && pwd)"
|
|
261
|
-
stopped=0
|
|
262
|
-
NON_PROJECT="code repos logs issues workspace"
|
|
263
|
-
for project_dir in "$WORKSPACE"/*/; do
|
|
264
|
-
[[ -d "$project_dir" ]] || continue
|
|
265
|
-
name=$(basename "$project_dir")
|
|
266
|
-
echo " $NON_PROJECT " | grep -qw "$name" && continue
|
|
267
|
-
[[ -f "$project_dir/stop.sh" ]] || continue
|
|
268
|
-
bash "$project_dir/stop.sh"
|
|
269
|
-
stopped=$((stopped + 1))
|
|
270
|
-
done
|
|
271
|
-
echo "[sentinel] $stopped project(s) stopped"
|
|
272
|
-
`, { mode: 0o755 });
|
|
273
|
-
}
|
|
274
|
-
module.exports = { writeExampleProject, generateProjectScripts, generateWorkspaceScripts };
|