@misterhuydo/sentinel 1.5.4 → 1.5.6

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.
Files changed (38) hide show
  1. package/.cairn/.hint-lock +1 -1
  2. package/.cairn/minify-map.json +13 -1
  3. package/.cairn/session.json +2 -2
  4. package/.cairn/views/23edf4_sentinel_boss.py +3664 -0
  5. package/.cairn/views/5f5141_main.py +1067 -0
  6. package/.cairn/views/62a614_bundle.js +4 -1
  7. package/.cairn/views/7802b9_cicd_trigger.py +171 -0
  8. package/.cairn/views/ac3df4_repo_task_engine.py +351 -0
  9. package/lib/.cairn/minify-map.json +6 -0
  10. package/lib/.cairn/views/2a85cc_init.js +380 -0
  11. package/lib/.cairn/views/e26996_slack-setup.js +97 -0
  12. package/lib/.cairn/views/fb78ac_upgrade.js +36 -1
  13. package/lib/.cairn/views/fc4a1a_add.js +164 -51
  14. package/lib/init.js +54 -0
  15. package/lib/maven.js +212 -0
  16. package/lib/slack-setup.js +5 -0
  17. package/package.json +1 -1
  18. package/python/requirements.txt +1 -0
  19. package/python/sentinel/.cairn/.cairn-project +0 -0
  20. package/python/sentinel/.cairn/.hint-lock +1 -0
  21. package/python/sentinel/.cairn/session.json +9 -0
  22. package/python/sentinel/__pycache__/sentinel_boss.cpython-311.pyc +0 -0
  23. package/python/sentinel/config_loader.py +29 -10
  24. package/python/sentinel/dependency_manager.py +9 -2
  25. package/python/sentinel/git_manager.py +23 -0
  26. package/python/sentinel/issue_watcher.py +7 -1
  27. package/python/sentinel/main.py +353 -8
  28. package/python/sentinel/notify.py +44 -12
  29. package/python/sentinel/repo_task_engine.py +49 -7
  30. package/python/sentinel/sentinel_boss.py +117 -3
  31. package/python/sentinel/slack_bot.py +15 -2
  32. package/python/sentinel/state_store.py +0 -1
  33. package/python/tests/__init__.py +0 -0
  34. package/python/tests/test_config_loader.py +138 -0
  35. package/python/tests/test_log_parser.py +62 -0
  36. package/python/tests/test_repo_router.py +73 -0
  37. package/python/tests/test_smoke.py +96 -0
  38. package/python/tests/test_state_store.py +128 -0
@@ -6,6 +6,7 @@ const { execSync, spawnSync } = require('child_process');
6
6
  const prompts = require('prompts');
7
7
  const chalk = require('chalk');
8
8
  const { writeExampleProject, generateWorkspaceScripts, generateProjectScripts } = require('./generate');
9
+ const { printSlackSetupGuide } = require('./slack-setup');
9
10
  const ok = msg => console.log(chalk.green(' ✔'), msg);
10
11
  const info = msg => console.log(chalk.cyan(' →'), msg);
11
12
  const warn = msg => console.log(chalk.yellow(' ⚠'), msg);
@@ -241,26 +242,64 @@ async function addFromGit(gitUrl, workspace) {
241
242
  }], { onCancel: () => process.exit(0) });
242
243
  step(`[1/3] Setting up SSH access to ${repoSlug}`);
243
244
  ensureKnownHosts();
244
- const { keyFile } = generateDeployKey(repoSlug);
245
- printDeployKeyInstructions(orgRepo, keyFile);
246
- await prompts({
247
- type: 'text', name: '_', format: () => '',
248
- message: chalk.bold(`Press Enter once you've added the deploy key to GitHub…`),
249
- }, { onCancel: () => process.exit(0) });
250
- const primary = validateAccess(gitUrl, keyFile);
251
- if (!primary.ok) {
252
- console.error(chalk.red(' ✖ Cannot reach ' + gitUrl));
253
- if (primary.stderr) console.error(chalk.red(' ' + primary.stderr));
254
- console.error(chalk.yellow(' Check the deploy key has write access, then re-run.'));
255
- process.exit(1);
245
+ const existingAccess = validateAccess(gitUrl, null);
246
+ let keyFile = null;
247
+ if (existingAccess.ok) {
248
+ ok(`${repoSlug}: reachable via existing SSH key skipping deploy key generation`);
249
+ } else {
250
+ const keyAlreadyExisted = fs.existsSync(path.join(os.homedir(), '.ssh', `${repoSlug}.key`));
251
+ const { keyFile: generatedKey } = generateDeployKey(repoSlug);
252
+ keyFile = generatedKey;
253
+ if (keyAlreadyExisted) {
254
+ const primary = validateAccess(gitUrl, keyFile);
255
+ if (primary.ok) {
256
+ ok(`${repoSlug}: reachable (reusing existing deploy key)`);
257
+ } else {
258
+ printDeployKeyInstructions(orgRepo, keyFile);
259
+ await prompts({
260
+ type: 'text', name: '_', format: () => '',
261
+ message: chalk.bold(`Press Enter once you've added the deploy key to GitHub…`),
262
+ }, { onCancel: () => process.exit(0) });
263
+ const retry = validateAccess(gitUrl, keyFile);
264
+ if (!retry.ok) {
265
+ console.error(chalk.red(' ✖ Cannot reach ' + gitUrl));
266
+ if (retry.stderr) console.error(chalk.red(' ' + retry.stderr));
267
+ console.error(chalk.yellow(' Check the deploy key has write access, then re-run.'));
268
+ process.exit(1);
269
+ }
270
+ ok(`${repoSlug}: reachable`);
271
+ }
272
+ } else {
273
+ printDeployKeyInstructions(orgRepo, keyFile);
274
+ await prompts({
275
+ type: 'text', name: '_', format: () => '',
276
+ message: chalk.bold(`Press Enter once you've added the deploy key to GitHub…`),
277
+ }, { onCancel: () => process.exit(0) });
278
+ const primary = validateAccess(gitUrl, keyFile);
279
+ if (!primary.ok) {
280
+ console.error(chalk.red(' ✖ Cannot reach ' + gitUrl));
281
+ if (primary.stderr) console.error(chalk.red(' ' + primary.stderr));
282
+ console.error(chalk.yellow(' Check the deploy key has write access, then re-run.'));
283
+ process.exit(1);
284
+ }
285
+ ok(`${repoSlug}: reachable`);
286
+ }
256
287
  }
257
- ok(`${repoSlug}: reachable`);
258
288
  const projectDir = path.join(workspace, name);
259
289
  step(`[2/3] Scanning repo-configs in ${repoSlug}…`);
290
+ const sshEnv = keyFile
291
+ ? gitEnv({ GIT_SSH_COMMAND: `ssh -i ${keyFile} -o StrictHostKeyChecking=no -o BatchMode=yes` })
292
+ : gitEnv({});
260
293
  if (!fs.existsSync(projectDir)) {
261
294
  spawnSync(gitBin(), ['clone', '--depth', '1', gitUrl, projectDir], {
262
295
  stdio: 'inherit',
263
- env: gitEnv({ GIT_SSH_COMMAND: `ssh -i ${keyFile} -o StrictHostKeyChecking=no -o BatchMode=yes` }),
296
+ env: sshEnv,
297
+ });
298
+ } else {
299
+ spawnSync(gitBin(), ['pull', '--rebase'], {
300
+ cwd: projectDir,
301
+ stdio: 'inherit',
302
+ env: sshEnv,
264
303
  });
265
304
  }
266
305
  const discovered = discoverReposFromClone(projectDir);
@@ -273,6 +312,21 @@ async function addFromGit(gitUrl, workspace) {
273
312
  fs.appendFileSync(gitignorePath, (giContent.endsWith('\n') ? '' : '\n') + giLines.join('\n') + '\n');
274
313
  ok('.gitignore updated: *.key and *.pub excluded from git');
275
314
  }
315
+ const sentinelPropsPath = path.join(projectDir, 'config', 'sentinel.properties');
316
+ let projectGitAccess = '';
317
+ let projectGitSshKey = '';
318
+ if (fs.existsSync(sentinelPropsPath)) {
319
+ const lines = fs.readFileSync(sentinelPropsPath, 'utf8').split('\n');
320
+ for (const line of lines) {
321
+ const trimmed = line.trim();
322
+ if (trimmed.startsWith('#') || !trimmed.includes('=')) continue;
323
+ const [k, ...rest] = trimmed.split('=');
324
+ const v = rest.join('=').split('#')[0].trim();
325
+ if (k.trim() === 'GIT_ACCESS') projectGitAccess = v;
326
+ if (k.trim() === 'GIT_SSH_KEY') projectGitSshKey = v.replace(/^~/, require('os').homedir());
327
+ }
328
+ }
329
+ const useSshUserKey = projectGitAccess === 'ssh_user_key';
276
330
  const privateRepos = [];
277
331
  const publicRepos = [];
278
332
  if (discovered.length === 0) {
@@ -289,40 +343,58 @@ async function addFromGit(gitUrl, workspace) {
289
343
  }
290
344
  }
291
345
  if (privateRepos.length > 0) {
292
- step(`[3/3] Deploy keys needed for ${privateRepos.length} private repo(s)`);
293
- for (const r of privateRepos) {
294
- const { keyFile: rKey } = generateDeployKey(r.slug, projectDir);
295
- r.keyFile = rKey;
296
- const rOrgRepo = gitUrlToOrgRepo(r.url);
297
- printDeployKeyInstructions(rOrgRepo, rKey);
298
- }
299
- if (publicRepos.length > 0) {
300
- console.log(chalk.green(' Public repos (no deploy key needed):'));
346
+ if (useSshUserKey) {
347
+ step(`[3/3] Validating access to ${privateRepos.length} private repo(s) via SSH user key…`);
348
+ const sshKeyForValidation = projectGitSshKey || null;
349
+ for (const r of privateRepos) {
350
+ const v = validateAccess(r.url, sshKeyForValidation);
351
+ if (!v.ok) {
352
+ console.error(chalk.red(` ✖ ${r.slug}: cannot reach ${r.url}`));
353
+ if (v.stderr) console.error(chalk.red(' ' + v.stderr));
354
+ console.error(chalk.yellow(` Check that GIT_SSH_KEY (${projectGitSshKey || 'SSH agent'}) has access, then re-run.`));
355
+ process.exit(1);
356
+ }
357
+ ok(`${r.slug}: reachable`);
358
+ }
301
359
  for (const r of publicRepos) {
302
- console.log(chalk.green(` ${r.slug}`));
360
+ ok(`${r.slug}: public, no key needed`);
303
361
  }
304
- console.log('');
305
- }
306
- await prompts({
307
- type: 'text', name: '_', format: () => '',
308
- message: chalk.bold(`Press Enter once you've added all ${privateRepos.length} deploy key(s) to GitHub…`),
309
- }, { onCancel: () => process.exit(0) });
310
- step('Validating repository access…');
311
- for (const r of privateRepos) {
312
- const v = validateAccess(r.url, r.keyFile);
313
- if (!v.ok) {
314
- console.error(chalk.red(` ✖ ${r.slug}: cannot reach ${r.url}`));
315
- if (v.stderr) console.error(chalk.red(' ' + v.stderr));
316
- console.error(chalk.yellow(' Fix access then re-run sentinel add.'));
317
- process.exit(1);
362
+ } else {
363
+ step(`[3/3] Deploy keys needed for ${privateRepos.length} private repo(s)`);
364
+ for (const r of privateRepos) {
365
+ const { keyFile: rKey } = generateDeployKey(r.slug, projectDir);
366
+ r.keyFile = rKey;
367
+ const rOrgRepo = gitUrlToOrgRepo(r.url);
368
+ printDeployKeyInstructions(rOrgRepo, rKey);
369
+ }
370
+ if (publicRepos.length > 0) {
371
+ console.log(chalk.green(' ✔ Public repos (no deploy key needed):'));
372
+ for (const r of publicRepos) {
373
+ console.log(chalk.green(` ${r.slug}`));
374
+ }
375
+ console.log('');
376
+ }
377
+ await prompts({
378
+ type: 'text', name: '_', format: () => '',
379
+ message: chalk.bold(`Press Enter once you've added all ${privateRepos.length} deploy key(s) to GitHub…`),
380
+ }, { onCancel: () => process.exit(0) });
381
+ step('Validating repository access…');
382
+ for (const r of privateRepos) {
383
+ const v = validateAccess(r.url, r.keyFile);
384
+ if (!v.ok) {
385
+ console.error(chalk.red(` ✖ ${r.slug}: cannot reach ${r.url}`));
386
+ if (v.stderr) console.error(chalk.red(' ' + v.stderr));
387
+ console.error(chalk.yellow(' Fix access then re-run sentinel add.'));
388
+ process.exit(1);
389
+ }
390
+ ok(`${r.slug}: reachable`);
391
+ }
392
+ for (const r of publicRepos) {
393
+ ok(`${r.slug}: public, no key needed`);
394
+ }
395
+ for (const r of privateRepos) {
396
+ info(`Key stored at ${r.keyFile} (auto-discovered, not committed to git)`);
318
397
  }
319
- ok(`${r.slug}: reachable`);
320
- }
321
- for (const r of publicRepos) {
322
- ok(`${r.slug}: public, no key needed`);
323
- }
324
- for (const r of privateRepos) {
325
- info(`Key stored at ${r.keyFile} (auto-discovered, not committed to git)`);
326
398
  }
327
399
  } else if (discovered.length > 0) {
328
400
  step('[3/3] All repos are public — no deploy keys needed');
@@ -386,6 +458,35 @@ async function addFromGit(gitUrl, workspace) {
386
458
  } else if (effectiveToken) {
387
459
  info('GITHUB_TOKEN will be written when project files are created');
388
460
  }
461
+ const workspaceSlackToken = fs.existsSync(workspaceProps)
462
+ ? (fs.readFileSync(workspaceProps, 'utf8').match(/^SLACK_BOT_TOKEN\s*=\s*(.+)$/m) || [])[1]?.trim()
463
+ : '';
464
+ const { ownSlack } = await prompts({
465
+ type: 'confirm', name: 'ownSlack',
466
+ message: workspaceSlackToken
467
+ ? 'Does this project use a different Slack workspace than the default?'
468
+ : 'Does this project use Slack (Sentinel Boss)?',
469
+ initial: false,
470
+ }, { onCancel: () => process.exit(0) });
471
+ let projectSlackBotToken = '';
472
+ let projectSlackAppToken = '';
473
+ if (ownSlack) {
474
+ printSlackSetupGuide(false, 'Setting up project Slack Bot…');
475
+ const slackAnswers = await prompts([
476
+ {
477
+ type: 'password', name: 'botToken',
478
+ message: 'Slack Bot Token (xoxb-...)',
479
+ validate: v => !v || v.startsWith('xoxb-') ? true : 'Should start with xoxb-',
480
+ },
481
+ {
482
+ type: 'password', name: 'appToken',
483
+ message: 'Slack App-Level Token (xapp-...)',
484
+ validate: v => !v || v.startsWith('xapp-') ? true : 'Should start with xapp-',
485
+ },
486
+ ], { onCancel: () => process.exit(0) });
487
+ projectSlackBotToken = slackAnswers.botToken || '';
488
+ projectSlackAppToken = slackAnswers.appToken || '';
489
+ }
389
490
  step('Dry-run preview');
390
491
  info(`Will create: ${projectDir}/`);
391
492
  if (discovered.length > 0) {
@@ -408,9 +509,6 @@ async function addFromGit(gitUrl, workspace) {
408
509
  const pythonBin = path.join(codeDir, '.venv', 'bin', 'python3');
409
510
  if (discovered.length > 0) {
410
511
  generateProjectScripts(projectDir, codeDir, pythonBin);
411
- ok(`Project "${name}" ready at ${projectDir}`);
412
- printNextSteps(projectDir, autoPublish);
413
- await offerToStart(projectDir);
414
512
  } else {
415
513
  writeExampleProject(projectDir, codeDir, pythonBin);
416
514
  const repoDir = path.join(projectDir, 'config', 'repo-configs');
@@ -423,10 +521,25 @@ async function addFromGit(gitUrl, workspace) {
423
521
  const example = path.join(repoDir, '_example.properties');
424
522
  if (fs.existsSync(example)) fs.removeSync(example);
425
523
  generateWorkspaceScripts(workspace, {}, {}, {}, effectiveToken);
426
- ok(`Project "${name}" created at ${projectDir}`);
427
- printNextSteps(projectDir, autoPublish);
428
- await offerToStart(projectDir);
429
524
  }
525
+ if (projectSlackBotToken || projectSlackAppToken) {
526
+ const privateProps = path.join(projectDir, 'private_sentinel.properties');
527
+ const lines = ['# Private credentials for this project — DO NOT COMMIT'];
528
+ if (projectSlackBotToken) lines.push(`SLACK_BOT_TOKEN=${projectSlackBotToken}`);
529
+ if (projectSlackAppToken) lines.push(`SLACK_APP_TOKEN=${projectSlackAppToken}`);
530
+ fs.writeFileSync(privateProps, lines.join('\n') + '\n');
531
+ ok('Private tokens → private_sentinel.properties (local only)');
532
+ const gitignore = path.join(projectDir, '.gitignore');
533
+ const ignoreEntry = 'private_sentinel.properties';
534
+ const existing = fs.existsSync(gitignore) ? fs.readFileSync(gitignore, 'utf8') : '';
535
+ if (!existing.includes(ignoreEntry)) {
536
+ fs.writeFileSync(gitignore, existing.trimEnd() + `\n${ignoreEntry}\n`);
537
+ ok('.gitignore updated — private_sentinel.properties will not be committed');
538
+ }
539
+ }
540
+ ok(`Project "${name}" ${discovered.length > 0 ? 'ready' : 'created'} at ${projectDir}`);
541
+ printNextSteps(projectDir, autoPublish);
542
+ await offerToStart(projectDir);
430
543
  }
431
544
  async function addFromName(nameArg, workspace) {
432
545
  const answers = await prompts([{
package/lib/init.js CHANGED
@@ -8,6 +8,7 @@ const prompts = require('prompts');
8
8
  const chalk = require('chalk');
9
9
  const { generateProjectScripts, generateWorkspaceScripts, writeExampleProject } = require('./generate');
10
10
  const { buildSlackManifest, printSlackSetupGuide } = require('./slack-setup');
11
+ const { generateSettingsXml } = require('./maven');
11
12
 
12
13
  const ok = msg => console.log(chalk.green(' ✔'), msg);
13
14
  const info = msg => console.log(chalk.cyan(' →'), msg);
@@ -95,6 +96,29 @@ module.exports = async function init() {
95
96
  message: 'Set up Slack Bot (Sentinel Boss — conversational AI interface)?',
96
97
  initial: !!(existing.SLACK_BOT_TOKEN),
97
98
  },
99
+ {
100
+ type: 'confirm',
101
+ name: 'setupMaven',
102
+ message: 'Do your projects use Maven with a private Nexus/Artifactory repository?',
103
+ initial: fs.existsSync(path.join(os.homedir(), '.m2', 'settings.xml')),
104
+ },
105
+ {
106
+ type: prev => prev ? 'select' : null,
107
+ name: 'mavenMode',
108
+ message: 'How would you like to configure Maven settings?',
109
+ choices: [
110
+ { title: 'Sentinel will ask me via Slack after startup (recommended)', value: 'detect' },
111
+ { title: 'Copy my existing settings.xml from this machine', value: 'copy' },
112
+ { title: 'Skip — I will configure it manually later', value: 'skip' },
113
+ ],
114
+ },
115
+ {
116
+ type: (_, { mavenMode } = {}) => mavenMode === 'copy' ? 'text' : null,
117
+ name: 'mavenSettingsPath',
118
+ message: 'Path to your settings.xml',
119
+ initial: path.join(os.homedir(), '.m2', 'settings.xml'),
120
+ validate: v => fs.existsSync(v) ? true : 'File not found',
121
+ },
98
122
  ], { onCancel: () => process.exit(0) });
99
123
 
100
124
  // ── Slack app creation helper (shown before token prompts) ───────────────────
@@ -119,6 +143,18 @@ module.exports = async function init() {
119
143
 
120
144
  Object.assign(answers, tokenAnswers);
121
145
 
146
+ // ── Maven / Nexus ─────────────────────────────────────────────────────────────
147
+ // Credentials are configured via Slack after startup — no prompts needed here.
148
+ // On first boot, Sentinel will scan pom.xml files, detect Nexus servers, and
149
+ // DM the first admin user with instructions to send:
150
+ // "nexus settings <paste settings.xml content>" — or —
151
+ // "nexus creds <host> <username> <password>"
152
+ if (answers.mavenMode === 'detect') {
153
+ info('Nexus credentials will be configured via Slack after Sentinel starts.');
154
+ info('Sentinel will DM you with instructions when it detects missing credentials.');
155
+ }
156
+ const nexusServers = [];
157
+
122
158
  const { workspace, authMode, anthropicKey, example, systemd, smtpUser, smtpPassword, smtpHost, setupSlack, slackBotToken, slackAppToken } = answers;
123
159
  const effectiveAnthropicKey = anthropicKey || existing.ANTHROPIC_API_KEY || '';
124
160
  const effectiveSmtpPassword = smtpPassword || existing.SMTP_PASSWORD || '';
@@ -205,6 +241,23 @@ module.exports = async function init() {
205
241
  info('Re-run sentinel init after creating the app to fill in the tokens');
206
242
  }
207
243
 
244
+ // ── Maven settings.xml ──────────────────────────────────────────────────────
245
+ if (answers.mavenMode === 'copy' && answers.mavenSettingsPath) {
246
+ step('Configuring Maven…');
247
+ const m2Dir = path.join(os.homedir(), '.m2');
248
+ fs.ensureDirSync(m2Dir);
249
+ fs.copySync(answers.mavenSettingsPath, path.join(m2Dir, 'settings.xml'), { overwrite: true });
250
+ ok(`settings.xml copied from ${answers.mavenSettingsPath}`);
251
+ } else if (answers.mavenMode === 'detect' && nexusServers.length) {
252
+ step('Writing Maven settings.xml…');
253
+ const m2Dir = path.join(os.homedir(), '.m2');
254
+ fs.ensureDirSync(m2Dir);
255
+ fs.writeFileSync(path.join(m2Dir, 'settings.xml'), generateSettingsXml(nexusServers));
256
+ const totalIds = nexusServers.reduce((n, s) => n + s.repoIds.length, 0);
257
+ ok(`settings.xml written — ${nexusServers.length} server(s), ${totalIds} repo ID(s)`);
258
+ nexusServers.forEach(s => info(` ${s.host} → [${s.repoIds.join(', ')}]`));
259
+ }
260
+
208
261
  // ── Workspace structure ─────────────────────────────────────────────────────
209
262
  step('Creating workspace…');
210
263
  fs.ensureDirSync(workspace);
@@ -403,6 +456,7 @@ function ensureNpmUserPrefix() {
403
456
  }
404
457
 
405
458
 
459
+
406
460
  function setupSystemd(workspace) {
407
461
  const user = os.userInfo().username;
408
462
  const svc = `/etc/systemd/system/sentinel.service`;
package/lib/maven.js ADDED
@@ -0,0 +1,212 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs-extra');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const { spawnSync } = require('child_process');
7
+ const prompts = require('prompts');
8
+ const chalk = require('chalk');
9
+
10
+ const ok = msg => console.log(chalk.green(' ✔'), msg);
11
+ const info = msg => console.log(chalk.cyan(' →'), msg);
12
+ const warn = msg => console.log(chalk.yellow(' ⚠'), msg);
13
+
14
+ const PUBLIC_REPOS = /repo1\.maven\.org|central\.maven\.org|repo\.maven\.apache\.org/i;
15
+ const REPO_RE = /<(?:repository|snapshotRepository|pluginRepository)>\s*<id>([^<]+)<\/id>\s*<url>([^<]+)<\/url>/gs;
16
+ const SERVER_RE = /<server>\s*<id>([^<]+)<\/id>/g;
17
+
18
+ /** Recursively find all pom.xml files (skips node_modules / .git / target). */
19
+ function findPoms(dir) {
20
+ const results = [];
21
+ const SKIP = new Set(['node_modules', '.git', 'target', '.mvn']);
22
+ try {
23
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
24
+ if (SKIP.has(entry.name)) continue;
25
+ const full = path.join(dir, entry.name);
26
+ if (entry.isDirectory()) results.push(...findPoms(full));
27
+ else if (entry.name === 'pom.xml') results.push(full);
28
+ }
29
+ } catch (_) {}
30
+ return results;
31
+ }
32
+
33
+ /**
34
+ * Scan pom.xml files in repoPaths and return:
35
+ * Map<host, { urls: Set<string>, ids: Set<string> }>
36
+ * Only includes non-central repositories.
37
+ */
38
+ function scanMavenRepos(repoPaths) {
39
+ const nexusMap = new Map();
40
+ for (const repoPath of repoPaths) {
41
+ for (const pom of findPoms(repoPath)) {
42
+ let text;
43
+ try { text = fs.readFileSync(pom, 'utf8'); } catch (_) { continue; }
44
+ for (const m of text.matchAll(REPO_RE)) {
45
+ const id = m[1].trim();
46
+ const url = m[2].trim();
47
+ if (PUBLIC_REPOS.test(url)) continue;
48
+ let host;
49
+ try { host = new URL(url).hostname; } catch (_) { continue; }
50
+ if (!nexusMap.has(host)) nexusMap.set(host, { urls: new Set(), ids: new Set() });
51
+ nexusMap.get(host).urls.add(url);
52
+ nexusMap.get(host).ids.add(id);
53
+ }
54
+ }
55
+ }
56
+ return nexusMap;
57
+ }
58
+
59
+ /** Return Set of server IDs already in ~/.m2/settings.xml. */
60
+ function readConfiguredServerIds() {
61
+ const settingsPath = path.join(os.homedir(), '.m2', 'settings.xml');
62
+ const ids = new Set();
63
+ if (!fs.existsSync(settingsPath)) return ids;
64
+ try {
65
+ const text = fs.readFileSync(settingsPath, 'utf8');
66
+ for (const m of text.matchAll(SERVER_RE)) ids.add(m[1].trim());
67
+ } catch (_) {}
68
+ return ids;
69
+ }
70
+
71
+ /** Merge new server entries into ~/.m2/settings.xml (creates it if missing). */
72
+ function mergeSettingsXml(servers) {
73
+ const settingsPath = path.join(os.homedir(), '.m2', 'settings.xml');
74
+ fs.ensureDirSync(path.join(os.homedir(), '.m2'));
75
+
76
+ let existing = '';
77
+ if (fs.existsSync(settingsPath)) {
78
+ existing = fs.readFileSync(settingsPath, 'utf8');
79
+ }
80
+
81
+ const newBlocks = servers.flatMap(s =>
82
+ s.repoIds.map(id => `
83
+ <server>
84
+ <id>${id}</id>
85
+ <username>${s.user}</username>
86
+ <password>${s.password}</password>
87
+ </server>`)
88
+ ).join('');
89
+
90
+ if (existing.includes('<servers>')) {
91
+ // Inject before closing </servers>
92
+ existing = existing.replace('</servers>', `${newBlocks}\n </servers>`);
93
+ } else if (existing.includes('<settings>')) {
94
+ existing = existing.replace('<settings>', `<settings>\n\n <servers>${newBlocks}\n </servers>`);
95
+ } else {
96
+ // Write fresh
97
+ existing = generateSettingsXml(servers);
98
+ }
99
+
100
+ fs.writeFileSync(settingsPath, existing);
101
+ }
102
+
103
+ function generateSettingsXml(servers) {
104
+ const serverBlocks = servers.flatMap(s =>
105
+ s.repoIds.map(id => `
106
+ <server>
107
+ <id>${id}</id>
108
+ <username>${s.user}</username>
109
+ <password>${s.password}</password>
110
+ </server>`)
111
+ ).join('');
112
+
113
+ return `<?xml version="1.0" encoding="UTF-8"?>
114
+ <settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
115
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
116
+ xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
117
+ http://maven.apache.org/xsd/settings-1.0.0.xsd">
118
+
119
+ <servers>${serverBlocks}
120
+ </servers>
121
+
122
+ </settings>
123
+ `;
124
+ }
125
+
126
+ /**
127
+ * Shallow-clone a repo to a temp dir, scan its pom.xml files, then delete the clone.
128
+ * Returns the nexusMap for that repo.
129
+ */
130
+ function scanRepoUrl(repoUrl, sshEnv) {
131
+ const tmp = path.join(os.tmpdir(), `sentinel-maven-scan-${Date.now()}`);
132
+ try {
133
+ const r = spawnSync(
134
+ 'git', ['clone', '--depth', '1', '--quiet', repoUrl, tmp],
135
+ { env: sshEnv || process.env, stdio: 'pipe' },
136
+ );
137
+ if (r.status !== 0) return new Map();
138
+ return scanMavenRepos([tmp]);
139
+ } finally {
140
+ try { fs.removeSync(tmp); } catch (_) {}
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Main entry point for add.js:
146
+ * Scan repos, detect missing Nexus credentials, prompt and update settings.xml.
147
+ *
148
+ * @param {Array<{url: string}>} repos — list of discovered repos
149
+ * @param {object} sshEnv — git SSH environment
150
+ */
151
+ async function checkAndConfigureMaven(repos, sshEnv) {
152
+ if (repos.length === 0) return;
153
+
154
+ info('Scanning repos for Maven/Nexus configuration…');
155
+ const configuredIds = readConfiguredServerIds();
156
+ const nexusMap = new Map(); // host → {urls, ids}
157
+
158
+ for (const repo of repos) {
159
+ const repoMap = scanRepoUrl(repo.url, sshEnv);
160
+ for (const [host, data] of repoMap) {
161
+ if (!nexusMap.has(host)) nexusMap.set(host, { urls: new Set(), ids: new Set() });
162
+ for (const u of data.urls) nexusMap.get(host).urls.add(u);
163
+ for (const id of data.ids) nexusMap.get(host).ids.add(id);
164
+ }
165
+ }
166
+
167
+ if (nexusMap.size === 0) return; // No private Maven repos found
168
+
169
+ // Filter to only hosts that have at least one unconfigured repo ID
170
+ const missing = new Map();
171
+ for (const [host, data] of nexusMap) {
172
+ const unconfigured = [...data.ids].filter(id => !configuredIds.has(id));
173
+ if (unconfigured.length > 0) missing.set(host, { ...data, ids: new Set(unconfigured) });
174
+ }
175
+
176
+ if (missing.size === 0) {
177
+ ok('Maven settings up to date — all Nexus repos already configured');
178
+ return;
179
+ }
180
+
181
+ console.log('');
182
+ warn(`Found ${missing.size} Nexus server(s) missing from ~/.m2/settings.xml:`);
183
+ for (const [host, data] of missing)
184
+ info(` ${chalk.cyan(host)} → ${[...data.ids].join(', ')}`);
185
+ console.log('');
186
+
187
+ const { configure } = await prompts({
188
+ type: 'confirm', name: 'configure',
189
+ message: 'Configure Nexus credentials now?',
190
+ initial: true,
191
+ }, { onCancel: () => process.exit(0) });
192
+
193
+ if (!configure) {
194
+ warn('Skipped — add credentials to ~/.m2/settings.xml before starting Sentinel');
195
+ return;
196
+ }
197
+
198
+ const servers = [];
199
+ for (const [host, data] of missing) {
200
+ const creds = await prompts([
201
+ { type: 'text', name: 'user', message: `${chalk.cyan(host)} username` },
202
+ { type: 'password', name: 'password', message: `${chalk.cyan(host)} password` },
203
+ ], { onCancel: () => process.exit(0) });
204
+ servers.push({ host, user: creds.user, password: creds.password, repoIds: [...data.ids] });
205
+ }
206
+
207
+ mergeSettingsXml(servers);
208
+ const totalIds = servers.reduce((n, s) => n + s.repoIds.length, 0);
209
+ ok(`~/.m2/settings.xml updated — ${servers.length} server(s), ${totalIds} repo ID(s) added`);
210
+ }
211
+
212
+ module.exports = { scanMavenRepos, scanRepoUrl, checkAndConfigureMaven, mergeSettingsXml, generateSettingsXml, readConfiguredServerIds };
@@ -11,6 +11,11 @@ function buildSlackManifest() {
11
11
  background_color: '#1a1a2e',
12
12
  },
13
13
  features: {
14
+ app_home: {
15
+ home_tab_enabled: false,
16
+ messages_tab_enabled: true, // enables DMs to Sentinel
17
+ messages_tab_read_only_mode_enabled: false, // allows users to send messages
18
+ },
14
19
  bot_user: {
15
20
  display_name: 'Sentinel',
16
21
  always_online: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.5.4",
3
+ "version": "1.5.6",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -6,3 +6,4 @@ jinja2>=3.1
6
6
  anthropic>=0.27
7
7
  slack-bolt>=1.18
8
8
  aiohttp>=3.9
9
+ tzdata>=2024.1
File without changes
@@ -0,0 +1 @@
1
+ 2026-04-08T10:22:06.173Z
@@ -0,0 +1,9 @@
1
+ {
2
+ "message": "Auto-checkpoint at 2026-04-08T10:21:21.356Z",
3
+ "checkpoint_at": "2026-04-08T10:21:21.443Z",
4
+ "active_files": [],
5
+ "notes": [
6
+ "[2026-04-08] git-snapshot: .cairn/session.json | 28 ++++-\n .claude/settings.local.json | 47 ++++++-\n cli/.cairn/.hint-lock | 2 +-\n cli/.cairn/minify-map.json | 14 ++-\n cli/.cairn/session.json | 4 +-\n cli/.cairn/views/62a614_bundle.js | 5 +-\n cli/lib/.cairn/minify-map.json | 6 +\n cli/lib/.cairn/views/fb78ac_upgrade.js | 37 +++++-\n cli/lib/.cairn/views/fc4a1a_add.js | 215 +++++++++++++++++++++++++--------\n cli/package.json | 2 +-\n 10 files changed, 296 insertions(+), 64 deletions(-) | status: M ../../../.cairn/session.json\n M ../../../.claude/settings.local.json\n M ../../.cairn/.hint-lock\n M ../../.cairn/minify-map.json\n M ../../.cairn/session.json\n M ../../.cairn/views/62a614_bundle.js\n M ../../lib/.cairn/minify-map.json\n M ../../lib/.cairn/views/fb78ac_upgrade.js\n M ../../lib/.cairn/views/fc4a1a_add.js\n M ../../package.json\n?? ../../../.cairn/.cairn-project\n?? ../../../.cairn/memory/\n?? ../../../.cairn/minify-map.json\n?? ../../../.cairn/views/\n?? ../../.cairn/views/23edf4_sentinel_boss.py\n?? ../../.cairn/views/5f5141_main.py\n?? ../../.cairn/views/7802b9_cicd_trigger.py\n?? ../../.cairn/views/ac3df4_repo_task_engine.py\n?? ../../lib/.cairn/views/2a85cc_init.js\n?? ../../lib/.cairn/views/e26996_slack-setup.js\n?? ../../../scripts/fix_ask_codebase_context.py\n?? ../../../scripts/fix_ask_codebase_stdin.py\n?? ../../../scripts/fix_chain_slack.py\n?? ../../../scripts/fix_fstring.py\n?? ../../../scripts/fix_knowledge_cache.py\n?? ../../../scripts/fix_knowledge_cache_staleness.py\n?? ../../../scripts/fix_merge_confirm.py\n?? ../../../scripts/fix_permission_messages.py\n?? ../../../scripts/fix_pr_check_head_detect.py\n?? ../../../scripts/fix_pr_msg_newlines.py\n?? ../../../scripts/fix_pr_tracking_boss.py\n?? ../../../scripts/fix_pr_tracking_db.py\n?? ../../../scripts/fix_pr_tracking_main.py\n?? ../../../scripts/fix_project_isolation.py\n?? ../../../scripts/fix_system_prompt.py\n?? ../../../scripts/fix_two_bugs.py\n?? ../../../scripts/patch_chain_release.py"
7
+ ],
8
+ "mtime_snapshot": {}
9
+ }