@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.
- package/.cairn/.hint-lock +1 -1
- package/.cairn/minify-map.json +13 -1
- package/.cairn/session.json +2 -2
- package/.cairn/views/23edf4_sentinel_boss.py +3664 -0
- package/.cairn/views/5f5141_main.py +1067 -0
- package/.cairn/views/62a614_bundle.js +4 -1
- package/.cairn/views/7802b9_cicd_trigger.py +171 -0
- package/.cairn/views/ac3df4_repo_task_engine.py +351 -0
- package/lib/.cairn/minify-map.json +6 -0
- package/lib/.cairn/views/2a85cc_init.js +380 -0
- package/lib/.cairn/views/e26996_slack-setup.js +97 -0
- package/lib/.cairn/views/fb78ac_upgrade.js +36 -1
- package/lib/.cairn/views/fc4a1a_add.js +164 -51
- package/lib/init.js +54 -0
- package/lib/maven.js +212 -0
- package/lib/slack-setup.js +5 -0
- package/package.json +1 -1
- package/python/requirements.txt +1 -0
- package/python/sentinel/.cairn/.cairn-project +0 -0
- package/python/sentinel/.cairn/.hint-lock +1 -0
- package/python/sentinel/.cairn/session.json +9 -0
- package/python/sentinel/__pycache__/sentinel_boss.cpython-311.pyc +0 -0
- package/python/sentinel/config_loader.py +29 -10
- package/python/sentinel/dependency_manager.py +9 -2
- package/python/sentinel/git_manager.py +23 -0
- package/python/sentinel/issue_watcher.py +7 -1
- package/python/sentinel/main.py +353 -8
- package/python/sentinel/notify.py +44 -12
- package/python/sentinel/repo_task_engine.py +49 -7
- package/python/sentinel/sentinel_boss.py +117 -3
- package/python/sentinel/slack_bot.py +15 -2
- package/python/sentinel/state_store.py +0 -1
- package/python/tests/__init__.py +0 -0
- package/python/tests/test_config_loader.py +138 -0
- package/python/tests/test_log_parser.py +62 -0
- package/python/tests/test_repo_router.py +73 -0
- package/python/tests/test_smoke.py +96 -0
- 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
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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:
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
r
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
360
|
+
ok(`${r.slug}: public, no key needed`);
|
|
303
361
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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 };
|
package/lib/slack-setup.js
CHANGED
|
@@ -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
package/python/requirements.txt
CHANGED
|
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
|
+
}
|
|
Binary file
|