@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 CHANGED
@@ -1 +1 @@
1
- 2026-03-25T16:26:53.611Z
1
+ 2026-03-26T18:52:08.498Z
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-25T16:15:37.097Z",
3
- "checkpoint_at": "2026-03-25T16:15:37.098Z",
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. Generate deploy key for the primary (config) repo ───────────────────
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
- await prompts({
287
- type: 'text', name: '_', format: () => '',
288
- message: chalk.bold(`Press Enter once you've added the deploy key to GitHub…`),
289
- }, { onCancel: () => process.exit(0) });
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
- // Validate primary repo
292
- const primary = validateAccess(gitUrl, keyFile);
293
- if (!primary.ok) {
294
- console.error(chalk.red(' ✖ Cannot reach ' + gitUrl));
295
- if (primary.stderr) console.error(chalk.red(' ' + primary.stderr));
296
- console.error(chalk.yellow(' Check the deploy key has write access, then re-run.'));
297
- process.exit(1);
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: gitEnv({ GIT_SSH_COMMAND: `ssh -i ${keyFile} -o StrictHostKeyChecking=no -o BatchMode=yes` }),
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. Generate deploy keys for all private repos (batch) ─────────────────
375
+ // ── 3. Set up access for all private repos ─────────────────────────────────
345
376
  if (privateRepos.length > 0) {
346
- step(`[3/3] Deploy keys needed for ${privateRepos.length} private repo(s)`);
347
-
348
- for (const r of privateRepos) {
349
- const { keyFile: rKey } = generateDeployKey(r.slug, projectDir);
350
- r.keyFile = rKey;
351
- const rOrgRepo = gitUrlToOrgRepo(r.url);
352
- printDeployKeyInstructions(rOrgRepo, rKey);
353
- }
354
-
355
- if (publicRepos.length > 0) {
356
- console.log(chalk.green(' ✔ Public repos (no deploy key needed):'));
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
- console.log(chalk.green(` ${r.slug}`));
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
- await prompts({
364
- type: 'text', name: '_', format: () => '',
365
- message: chalk.bold(`Press Enter once you've added all ${privateRepos.length} deploy key(s) to GitHub…`),
366
- }, { onCancel: () => process.exit(0) });
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
- // Validate each private repo
369
- step('Validating repository access…');
370
- for (const r of privateRepos) {
371
- const v = validateAccess(r.url, r.keyFile);
372
- if (!v.ok) {
373
- console.error(chalk.red(` ✖ ${r.slug}: cannot reach ${r.url}`));
374
- if (v.stderr) console.error(chalk.red(' ' + v.stderr));
375
- console.error(chalk.yellow(' Fix access then re-run sentinel add.'));
376
- process.exit(1);
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
- // Keys stored at <projectDir>/<slug>.key — config_loader.py auto-discovers them.
385
- // SSH_KEY_FILE is NOT written to git-tracked .properties files.
386
- for (const r of privateRepos) {
387
- info(`Key stored at ${r.keyFile} (auto-discovered, not committed to git)`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.4.69",
3
+ "version": "1.4.71",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -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.ssh_key_file = os.path.expanduser(d.get("SSH_KEY_FILE", ""))
246
- # Auto-discover key in project dir if not set in properties
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 };