@misterhuydo/sentinel 1.0.6 → 1.0.10
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/bin/sentinel.js +42 -39
- package/lib/add.js +415 -57
- package/lib/generate.js +14 -23
- package/lib/init.js +21 -7
- package/package.json +1 -1
- package/python/sentinel/__pycache__/issue_watcher.cpython-313.pyc +0 -0
- package/python/sentinel/config_loader.py +15 -3
- package/python/sentinel/fix_engine.py +49 -14
- package/python/sentinel/issue_watcher.py +146 -131
- package/python/sentinel/log_parser.py +175 -149
- package/python/sentinel/main.py +110 -32
- package/python/sentinel/reporter.py +159 -0
- package/python/sentinel/state_store.py +275 -164
- package/templates/sentinel.properties +20 -32
- package/templates/workspace-sentinel.properties +20 -0
package/bin/sentinel.js
CHANGED
|
@@ -1,39 +1,42 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
const chalk = require('chalk');
|
|
5
|
-
const [,, command = 'help', ...args] = process.argv;
|
|
6
|
-
|
|
7
|
-
const BANNER = `
|
|
8
|
-
${chalk.cyan('███████╗███████╗███╗ ██╗████████╗██╗███╗ ██╗███████╗██╗')}
|
|
9
|
-
${chalk.cyan('██╔════╝██╔════╝████╗ ██║╚══██╔══╝██║████╗ ██║██╔════╝██║')}
|
|
10
|
-
${chalk.cyan('███████╗█████╗ ██╔██╗ ██║ ██║ ██║██╔██╗ ██║█████╗ ██║')}
|
|
11
|
-
${chalk.cyan('╚════██║██╔══╝ ██║╚██╗██║ ██║ ██║██║╚██╗██║██╔══╝ ██║')}
|
|
12
|
-
${chalk.cyan('███████║███████╗██║ ╚████║ ██║ ██║██║ ╚████║███████╗███████╗')}
|
|
13
|
-
${chalk.cyan('╚══════╝╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚══════╝')}
|
|
14
|
-
${chalk.gray(' Autonomous DevOps Agent')}
|
|
15
|
-
`;
|
|
16
|
-
|
|
17
|
-
async function main() {
|
|
18
|
-
console.log(BANNER);
|
|
19
|
-
|
|
20
|
-
switch (command) {
|
|
21
|
-
case 'init':
|
|
22
|
-
await require('../lib/init')();
|
|
23
|
-
break;
|
|
24
|
-
case 'add':
|
|
25
|
-
await require('../lib/add')(args[0]);
|
|
26
|
-
break;
|
|
27
|
-
case 'help':
|
|
28
|
-
default:
|
|
29
|
-
console.log(`${chalk.bold('Usage:')}
|
|
30
|
-
sentinel init
|
|
31
|
-
sentinel add <name>
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
const [,, command = 'help', ...args] = process.argv;
|
|
6
|
+
|
|
7
|
+
const BANNER = `
|
|
8
|
+
${chalk.cyan('███████╗███████╗███╗ ██╗████████╗██╗███╗ ██╗███████╗██╗')}
|
|
9
|
+
${chalk.cyan('██╔════╝██╔════╝████╗ ██║╚══██╔══╝██║████╗ ██║██╔════╝██║')}
|
|
10
|
+
${chalk.cyan('███████╗█████╗ ██╔██╗ ██║ ██║ ██║██╔██╗ ██║█████╗ ██║')}
|
|
11
|
+
${chalk.cyan('╚════██║██╔══╝ ██║╚██╗██║ ██║ ██║██║╚██╗██║██╔══╝ ██║')}
|
|
12
|
+
${chalk.cyan('███████║███████╗██║ ╚████║ ██║ ██║██║ ╚████║███████╗███████╗')}
|
|
13
|
+
${chalk.cyan('╚══════╝╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚══════╝')}
|
|
14
|
+
${chalk.gray(' Autonomous DevOps Agent')}
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
async function main() {
|
|
18
|
+
console.log(BANNER);
|
|
19
|
+
|
|
20
|
+
switch (command) {
|
|
21
|
+
case 'init':
|
|
22
|
+
await require('../lib/init')();
|
|
23
|
+
break;
|
|
24
|
+
case 'add':
|
|
25
|
+
await require('../lib/add')(args[0]);
|
|
26
|
+
break;
|
|
27
|
+
case 'help':
|
|
28
|
+
default:
|
|
29
|
+
console.log(`${chalk.bold('Usage:')}
|
|
30
|
+
sentinel init Interactive setup — install everything and create workspace
|
|
31
|
+
sentinel add <name> Add a blank project (fill config manually)
|
|
32
|
+
sentinel add <git-url> Add a project pre-configured for a GitHub repo
|
|
33
|
+
sentinel add <project.json> Add a project from a local JSON config file
|
|
34
|
+
sentinel add <https://host/cfg.json> Add a project from a remote JSON config URL
|
|
35
|
+
`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
main().catch(err => {
|
|
40
|
+
console.error(chalk.red('Error:'), err.message);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
});
|
package/lib/add.js
CHANGED
|
@@ -1,57 +1,415 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('fs-extra');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const os = require('os');
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const { execSync, spawnSync } = require('child_process');
|
|
7
|
+
const prompts = require('prompts');
|
|
8
|
+
const chalk = require('chalk');
|
|
9
|
+
const { writeExampleProject, generateWorkspaceScripts, generateProjectScripts } = require('./generate');
|
|
10
|
+
|
|
11
|
+
const ok = msg => console.log(chalk.green(' ✔'), msg);
|
|
12
|
+
const info = msg => console.log(chalk.cyan(' →'), msg);
|
|
13
|
+
const warn = msg => console.log(chalk.yellow(' ⚠'), msg);
|
|
14
|
+
const step = msg => console.log('\n' + chalk.bold.white(msg));
|
|
15
|
+
|
|
16
|
+
// ── Input type detection ───────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function detectInputType(arg) {
|
|
19
|
+
if (!arg) return 'name';
|
|
20
|
+
if (/^git@/.test(arg) || (/^https?:\/\/github\.com\//.test(arg) && arg.endsWith('.git'))) return 'git';
|
|
21
|
+
if (/^https?:\/\//.test(arg)) return 'url';
|
|
22
|
+
if (arg.toLowerCase().endsWith('.json') || arg.includes('/') || arg.includes('\\')) return 'json';
|
|
23
|
+
return 'name';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Workspace resolver ─────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
async function resolveWorkspace(initial) {
|
|
29
|
+
const ans = await prompts([{
|
|
30
|
+
type: 'text',
|
|
31
|
+
name: 'workspace',
|
|
32
|
+
message: 'Workspace directory',
|
|
33
|
+
initial: initial || path.join(os.homedir(), 'sentinel'),
|
|
34
|
+
format: v => v.replace(/^~/, os.homedir()),
|
|
35
|
+
}], { onCancel: () => process.exit(0) });
|
|
36
|
+
return ans.workspace;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function requireCodeDir(workspace) {
|
|
40
|
+
const codeDir = path.join(workspace, 'code');
|
|
41
|
+
if (!fs.existsSync(codeDir)) {
|
|
42
|
+
console.error(chalk.red(`Sentinel code not found at ${codeDir}`));
|
|
43
|
+
console.error(chalk.red('Run "sentinel init" first.'));
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
return codeDir;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Validation ─────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
const VALID_NAME = /^[a-z0-9_-]+$/i;
|
|
52
|
+
const GITHUB_URL = /^(git@github\.com:|https:\/\/github\.com\/).+\.git$/;
|
|
53
|
+
|
|
54
|
+
function validateProjectJson(obj) {
|
|
55
|
+
const errors = [];
|
|
56
|
+
if (!obj.name || !VALID_NAME.test(obj.name)) {
|
|
57
|
+
errors.push('name must be letters, numbers, hyphens only');
|
|
58
|
+
}
|
|
59
|
+
if (!Array.isArray(obj.repos) || obj.repos.length === 0) {
|
|
60
|
+
errors.push('repos array is required and must be non-empty');
|
|
61
|
+
} else {
|
|
62
|
+
obj.repos.forEach((r, i) => {
|
|
63
|
+
if (!r.REPO_URL || !GITHUB_URL.test(r.REPO_URL)) {
|
|
64
|
+
errors.push(`repos[${i}].REPO_URL must be a valid GitHub URL`);
|
|
65
|
+
}
|
|
66
|
+
if (!r.name) errors.push(`repos[${i}].name is required`);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return errors;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Properties file writer ─────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
function writePropertiesFile(filePath, obj) {
|
|
75
|
+
const lines = Object.entries(obj).map(([k, v]) => `${k}=${v}`);
|
|
76
|
+
fs.writeFileSync(filePath, lines.join('\n') + '\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Apply JSON config to a project directory ───────────────────────────────
|
|
80
|
+
|
|
81
|
+
function applyJsonToProject(projectDir, obj) {
|
|
82
|
+
const configDir = path.join(projectDir, 'config');
|
|
83
|
+
const repoDir = path.join(projectDir, 'config', 'repo-configs');
|
|
84
|
+
const logDir = path.join(projectDir, 'config', 'log-configs');
|
|
85
|
+
fs.ensureDirSync(repoDir);
|
|
86
|
+
fs.ensureDirSync(logDir);
|
|
87
|
+
|
|
88
|
+
// sentinel.properties overrides
|
|
89
|
+
if (obj.sentinel) {
|
|
90
|
+
const propsPath = path.join(configDir, 'sentinel.properties');
|
|
91
|
+
const existing = fs.existsSync(propsPath) ? fs.readFileSync(propsPath, 'utf8') : '';
|
|
92
|
+
let updated = existing;
|
|
93
|
+
Object.entries(obj.sentinel).forEach(([k, v]) => {
|
|
94
|
+
const re = new RegExp(`^#?\\s*${k}\\s*=.*$`, 'm');
|
|
95
|
+
if (re.test(updated)) {
|
|
96
|
+
updated = updated.replace(re, `${k}=${v}`);
|
|
97
|
+
} else {
|
|
98
|
+
updated += `\n${k}=${v}`;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
fs.writeFileSync(propsPath, updated);
|
|
102
|
+
ok('Updated sentinel.properties');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// repo-configs
|
|
106
|
+
if (Array.isArray(obj.repos)) {
|
|
107
|
+
obj.repos.forEach(repo => {
|
|
108
|
+
const { name, ...props } = repo;
|
|
109
|
+
writePropertiesFile(path.join(repoDir, `${name}.properties`), props);
|
|
110
|
+
ok(`Created repo-configs/${name}.properties`);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// log-configs (optional)
|
|
115
|
+
if (Array.isArray(obj.log_sources)) {
|
|
116
|
+
obj.log_sources.forEach(src => {
|
|
117
|
+
const { name, ...props } = src;
|
|
118
|
+
writePropertiesFile(path.join(logDir, `${name}.properties`), props);
|
|
119
|
+
ok(`Created log-configs/${name}.properties`);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── URL fetcher ────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
function fetchUrl(url) {
|
|
127
|
+
try {
|
|
128
|
+
const result = spawnSync('curl', ['-fsSL', '--max-time', '10', url], { encoding: 'utf8' });
|
|
129
|
+
if (result.status !== 0) throw new Error(result.stderr || 'curl failed');
|
|
130
|
+
return result.stdout;
|
|
131
|
+
} catch (_) {
|
|
132
|
+
// fallback: node https
|
|
133
|
+
const https = require('https');
|
|
134
|
+
const http = require('http');
|
|
135
|
+
const lib = url.startsWith('https') ? https : http;
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
lib.get(url, res => {
|
|
138
|
+
let data = '';
|
|
139
|
+
res.on('data', c => (data += c));
|
|
140
|
+
res.on('end', () => resolve(data));
|
|
141
|
+
}).on('error', reject);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Mode: name (template) ──────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
async function addFromName(nameArg, workspace) {
|
|
149
|
+
const answers = await prompts([{
|
|
150
|
+
type: 'text',
|
|
151
|
+
name: 'name',
|
|
152
|
+
message: 'Project name',
|
|
153
|
+
initial: nameArg || 'my-project',
|
|
154
|
+
validate: v => VALID_NAME.test(v) || 'Use letters, numbers, hyphens only',
|
|
155
|
+
}], { onCancel: () => process.exit(0) });
|
|
156
|
+
|
|
157
|
+
const { name } = answers;
|
|
158
|
+
const projectDir = path.join(workspace, name);
|
|
159
|
+
|
|
160
|
+
step('Dry-run preview');
|
|
161
|
+
info(`Will create: ${projectDir}/`);
|
|
162
|
+
info(' config/sentinel.properties');
|
|
163
|
+
info(' config/repo-configs/_example.properties');
|
|
164
|
+
info(' config/log-configs/_example.properties');
|
|
165
|
+
info(' init.sh, start.sh, stop.sh');
|
|
166
|
+
|
|
167
|
+
const { confirm } = await prompts({
|
|
168
|
+
type: 'confirm', name: 'confirm',
|
|
169
|
+
message: `Create project "${name}"?`, initial: true,
|
|
170
|
+
}, { onCancel: () => process.exit(0) });
|
|
171
|
+
if (!confirm) { info('Aborted.'); return; }
|
|
172
|
+
|
|
173
|
+
if (fs.existsSync(projectDir)) {
|
|
174
|
+
console.error(chalk.yellow(`Project "${name}" already exists at ${projectDir}`));
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const codeDir = requireCodeDir(workspace);
|
|
179
|
+
const pythonBin = path.join(codeDir, '.venv', 'bin', 'python3');
|
|
180
|
+
writeExampleProject(projectDir, codeDir, pythonBin);
|
|
181
|
+
generateWorkspaceScripts(workspace);
|
|
182
|
+
|
|
183
|
+
ok(`Project "${name}" created at ${projectDir}`);
|
|
184
|
+
printNextSteps(projectDir);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Mode: git URL ──────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
async function addFromGit(gitUrl, workspace) {
|
|
190
|
+
// Derive project name from repo name
|
|
191
|
+
const repoSlug = gitUrl.replace(/\.git$/, '').split(/[:/]/).pop();
|
|
192
|
+
const answers = await prompts([{
|
|
193
|
+
type: 'text',
|
|
194
|
+
name: 'name',
|
|
195
|
+
message: 'Project name',
|
|
196
|
+
initial: repoSlug,
|
|
197
|
+
validate: v => VALID_NAME.test(v) || 'Use letters, numbers, hyphens only',
|
|
198
|
+
}], { onCancel: () => process.exit(0) });
|
|
199
|
+
|
|
200
|
+
const { name } = answers;
|
|
201
|
+
const projectDir = path.join(workspace, name);
|
|
202
|
+
const localPath = path.join(workspace, 'repos', repoSlug);
|
|
203
|
+
|
|
204
|
+
step('Dry-run: validating git URL');
|
|
205
|
+
info(`Running: git ls-remote ${gitUrl}`);
|
|
206
|
+
const result = spawnSync('git', ['ls-remote', '--heads', gitUrl], { encoding: 'utf8', timeout: 15000 });
|
|
207
|
+
if (result.status !== 0) {
|
|
208
|
+
console.error(chalk.red(` ✖ Cannot reach repository: ${gitUrl}`));
|
|
209
|
+
console.error(chalk.red(` ${result.stderr || 'git ls-remote failed'}`));
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
ok('Repository is reachable');
|
|
213
|
+
|
|
214
|
+
const branches = (result.stdout || '').split('\n')
|
|
215
|
+
.filter(Boolean).map(l => l.split('\t')[1].replace('refs/heads/', ''));
|
|
216
|
+
const defaultBranch = branches.includes('main') ? 'main' : (branches[0] || 'main');
|
|
217
|
+
info(`Detected default branch: ${defaultBranch}`);
|
|
218
|
+
|
|
219
|
+
step('Dry-run preview');
|
|
220
|
+
info(`Will create: ${projectDir}/`);
|
|
221
|
+
info(` config/repo-configs/${repoSlug}.properties`);
|
|
222
|
+
info(` REPO_URL=${gitUrl}`);
|
|
223
|
+
info(` LOCAL_PATH=${localPath}`);
|
|
224
|
+
info(` BRANCH=${defaultBranch}`);
|
|
225
|
+
info(' init.sh, start.sh, stop.sh');
|
|
226
|
+
|
|
227
|
+
const { confirm } = await prompts({
|
|
228
|
+
type: 'confirm', name: 'confirm',
|
|
229
|
+
message: `Create project "${name}" for ${gitUrl}?`, initial: true,
|
|
230
|
+
}, { onCancel: () => process.exit(0) });
|
|
231
|
+
if (!confirm) { info('Aborted.'); return; }
|
|
232
|
+
|
|
233
|
+
if (fs.existsSync(projectDir)) {
|
|
234
|
+
console.error(chalk.yellow(`Project "${name}" already exists at ${projectDir}`));
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const codeDir = requireCodeDir(workspace);
|
|
239
|
+
const pythonBin = path.join(codeDir, '.venv', 'bin', 'python3');
|
|
240
|
+
|
|
241
|
+
writeExampleProject(projectDir, codeDir, pythonBin);
|
|
242
|
+
|
|
243
|
+
// Write the actual repo config (overwrite the example)
|
|
244
|
+
const repoDir = path.join(projectDir, 'config', 'repo-configs');
|
|
245
|
+
writePropertiesFile(path.join(repoDir, `${repoSlug}.properties`), {
|
|
246
|
+
REPO_NAME: repoSlug,
|
|
247
|
+
REPO_URL: gitUrl,
|
|
248
|
+
LOCAL_PATH: localPath,
|
|
249
|
+
BRANCH: defaultBranch,
|
|
250
|
+
AUTO_PUBLISH: 'false',
|
|
251
|
+
CAIRN_MCP_ENABLED: 'true',
|
|
252
|
+
});
|
|
253
|
+
// Remove the _example placeholder
|
|
254
|
+
const example = path.join(repoDir, '_example.properties');
|
|
255
|
+
if (fs.existsSync(example)) fs.removeSync(example);
|
|
256
|
+
|
|
257
|
+
generateWorkspaceScripts(workspace);
|
|
258
|
+
ok(`Project "${name}" created at ${projectDir}`);
|
|
259
|
+
printNextSteps(projectDir);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── Mode: local JSON ───────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
async function addFromJson(jsonPath, workspace) {
|
|
265
|
+
step(`Reading ${jsonPath}`);
|
|
266
|
+
let obj;
|
|
267
|
+
try {
|
|
268
|
+
obj = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
|
269
|
+
} catch (e) {
|
|
270
|
+
console.error(chalk.red(` ✖ Cannot parse ${jsonPath}: ${e.message}`));
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const errors = validateProjectJson(obj);
|
|
275
|
+
if (errors.length) {
|
|
276
|
+
console.error(chalk.red(' ✖ Invalid project JSON:'));
|
|
277
|
+
errors.forEach(e => console.error(chalk.red(` - ${e}`)));
|
|
278
|
+
process.exit(1);
|
|
279
|
+
}
|
|
280
|
+
ok('JSON is valid');
|
|
281
|
+
|
|
282
|
+
const { name } = obj;
|
|
283
|
+
const projectDir = path.join(workspace, name);
|
|
284
|
+
|
|
285
|
+
step('Dry-run preview');
|
|
286
|
+
info(`Will create: ${projectDir}/`);
|
|
287
|
+
(obj.repos || []).forEach(r => info(` config/repo-configs/${r.name}.properties (${r.REPO_URL})`));
|
|
288
|
+
(obj.log_sources || []).forEach(s => info(` config/log-configs/${s.name}.properties (${s.SOURCE_TYPE})`));
|
|
289
|
+
if (obj.sentinel) {
|
|
290
|
+
Object.entries(obj.sentinel).forEach(([k, v]) => info(` sentinel.properties: ${k}=${v}`));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const { confirm } = await prompts({
|
|
294
|
+
type: 'confirm', name: 'confirm',
|
|
295
|
+
message: `Create project "${name}" from ${path.basename(jsonPath)}?`, initial: true,
|
|
296
|
+
}, { onCancel: () => process.exit(0) });
|
|
297
|
+
if (!confirm) { info('Aborted.'); return; }
|
|
298
|
+
|
|
299
|
+
if (fs.existsSync(projectDir)) {
|
|
300
|
+
console.error(chalk.yellow(`Project "${name}" already exists at ${projectDir}`));
|
|
301
|
+
process.exit(1);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const codeDir = requireCodeDir(workspace);
|
|
305
|
+
const pythonBin = path.join(codeDir, '.venv', 'bin', 'python3');
|
|
306
|
+
|
|
307
|
+
writeExampleProject(projectDir, codeDir, pythonBin);
|
|
308
|
+
|
|
309
|
+
// Remove example placeholders before writing real configs
|
|
310
|
+
const repoDir = path.join(projectDir, 'config', 'repo-configs');
|
|
311
|
+
const logDir = path.join(projectDir, 'config', 'log-configs');
|
|
312
|
+
const exampleRepo = path.join(repoDir, '_example.properties');
|
|
313
|
+
const exampleLog = path.join(logDir, '_example.properties');
|
|
314
|
+
if (fs.existsSync(exampleRepo)) fs.removeSync(exampleRepo);
|
|
315
|
+
if (fs.existsSync(exampleLog)) fs.removeSync(exampleLog);
|
|
316
|
+
|
|
317
|
+
applyJsonToProject(projectDir, obj);
|
|
318
|
+
generateWorkspaceScripts(workspace);
|
|
319
|
+
ok(`Project "${name}" created at ${projectDir}`);
|
|
320
|
+
printNextSteps(projectDir);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── Mode: remote URL ───────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
async function addFromUrl(url, workspace) {
|
|
326
|
+
step(`Fetching ${url}`);
|
|
327
|
+
let raw;
|
|
328
|
+
try {
|
|
329
|
+
raw = fetchUrl(url);
|
|
330
|
+
if (raw && typeof raw.then === 'function') raw = await raw;
|
|
331
|
+
} catch (e) {
|
|
332
|
+
console.error(chalk.red(` ✖ Cannot fetch ${url}: ${e.message}`));
|
|
333
|
+
process.exit(1);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
let obj;
|
|
337
|
+
try {
|
|
338
|
+
obj = JSON.parse(raw);
|
|
339
|
+
} catch (e) {
|
|
340
|
+
console.error(chalk.red(` ✖ Response is not valid JSON: ${e.message}`));
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const errors = validateProjectJson(obj);
|
|
345
|
+
if (errors.length) {
|
|
346
|
+
console.error(chalk.red(' ✖ Invalid project JSON at URL:'));
|
|
347
|
+
errors.forEach(e => console.error(chalk.red(` - ${e}`)));
|
|
348
|
+
process.exit(1);
|
|
349
|
+
}
|
|
350
|
+
ok('JSON is valid');
|
|
351
|
+
|
|
352
|
+
const { name } = obj;
|
|
353
|
+
const projectDir = path.join(workspace, name);
|
|
354
|
+
|
|
355
|
+
step('Dry-run preview');
|
|
356
|
+
info(`Will create: ${projectDir}/`);
|
|
357
|
+
(obj.repos || []).forEach(r => info(` config/repo-configs/${r.name}.properties (${r.REPO_URL})`));
|
|
358
|
+
(obj.log_sources || []).forEach(s => info(` config/log-configs/${s.name}.properties (${s.SOURCE_TYPE})`));
|
|
359
|
+
if (obj.sentinel) {
|
|
360
|
+
Object.entries(obj.sentinel).forEach(([k, v]) => info(` sentinel.properties: ${k}=${v}`));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const { confirm } = await prompts({
|
|
364
|
+
type: 'confirm', name: 'confirm',
|
|
365
|
+
message: `Create project "${name}" from ${url}?`, initial: true,
|
|
366
|
+
}, { onCancel: () => process.exit(0) });
|
|
367
|
+
if (!confirm) { info('Aborted.'); return; }
|
|
368
|
+
|
|
369
|
+
if (fs.existsSync(projectDir)) {
|
|
370
|
+
console.error(chalk.yellow(`Project "${name}" already exists at ${projectDir}`));
|
|
371
|
+
process.exit(1);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const codeDir = requireCodeDir(workspace);
|
|
375
|
+
const pythonBin = path.join(codeDir, '.venv', 'bin', 'python3');
|
|
376
|
+
|
|
377
|
+
writeExampleProject(projectDir, codeDir, pythonBin);
|
|
378
|
+
|
|
379
|
+
const repoDir = path.join(projectDir, 'config', 'repo-configs');
|
|
380
|
+
const logDir = path.join(projectDir, 'config', 'log-configs');
|
|
381
|
+
const exampleRepo = path.join(repoDir, '_example.properties');
|
|
382
|
+
const exampleLog = path.join(logDir, '_example.properties');
|
|
383
|
+
if (fs.existsSync(exampleRepo)) fs.removeSync(exampleRepo);
|
|
384
|
+
if (fs.existsSync(exampleLog)) fs.removeSync(exampleLog);
|
|
385
|
+
|
|
386
|
+
applyJsonToProject(projectDir, obj);
|
|
387
|
+
generateWorkspaceScripts(workspace);
|
|
388
|
+
ok(`Project "${name}" created at ${projectDir}`);
|
|
389
|
+
printNextSteps(projectDir);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ── Helper ─────────────────────────────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
function printNextSteps(projectDir) {
|
|
395
|
+
console.log(`
|
|
396
|
+
Next steps:
|
|
397
|
+
1. Edit config files in:
|
|
398
|
+
${chalk.cyan(`${projectDir}/config/`)}
|
|
399
|
+
2. Start (Sentinel clones, indexes, and monitors automatically):
|
|
400
|
+
${chalk.cyan(`${projectDir}/start.sh`)}
|
|
401
|
+
`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── Entry point ────────────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
module.exports = async function add(arg) {
|
|
407
|
+
const type = detectInputType(arg);
|
|
408
|
+
|
|
409
|
+
const workspace = await resolveWorkspace();
|
|
410
|
+
|
|
411
|
+
if (type === 'git') return addFromGit(arg, workspace);
|
|
412
|
+
if (type === 'url') return addFromUrl(arg, workspace);
|
|
413
|
+
if (type === 'json') return addFromJson(arg, workspace);
|
|
414
|
+
return addFromName(arg, workspace);
|
|
415
|
+
};
|
package/lib/generate.js
CHANGED
|
@@ -12,12 +12,10 @@ function writeExampleProject(projectDir, codeDir, pythonBin, anthropicKey = '')
|
|
|
12
12
|
fs.ensureDirSync(repoDir);
|
|
13
13
|
|
|
14
14
|
const tplDir = path.join(__dirname, '..', 'templates');
|
|
15
|
-
//
|
|
15
|
+
// Per-project sentinel.properties: MAILS, GITHUB_TOKEN, optional API key
|
|
16
16
|
let sentinelProps = fs.readFileSync(path.join(tplDir, 'sentinel.properties'), 'utf8');
|
|
17
17
|
if (anthropicKey) {
|
|
18
|
-
sentinelProps
|
|
19
|
-
} else {
|
|
20
|
-
sentinelProps += `\n# Anthropic API key — set this if using API key auth, or leave blank for OAuth\n# ANTHROPIC_API_KEY=sk-ant-...\n`;
|
|
18
|
+
sentinelProps = sentinelProps.replace(/^# ANTHROPIC_API_KEY=.*/m, `ANTHROPIC_API_KEY=${anthropicKey}`);
|
|
21
19
|
}
|
|
22
20
|
fs.writeFileSync(path.join(projectDir, 'config', 'sentinel.properties'), sentinelProps);
|
|
23
21
|
fs.copySync(path.join(tplDir, 'log-configs', '_example.properties'), path.join(configDir, '_example.properties'));
|
|
@@ -29,24 +27,6 @@ function writeExampleProject(projectDir, codeDir, pythonBin, anthropicKey = '')
|
|
|
29
27
|
function generateProjectScripts(projectDir, codeDir, pythonBin) {
|
|
30
28
|
const name = path.basename(projectDir);
|
|
31
29
|
|
|
32
|
-
// init.sh
|
|
33
|
-
fs.writeFileSync(path.join(projectDir, 'init.sh'), `#!/usr/bin/env bash
|
|
34
|
-
# First-time setup for this Sentinel project instance.
|
|
35
|
-
#
|
|
36
|
-
# What this does:
|
|
37
|
-
# - Clones any repos defined in config/repo-configs/ that don't exist locally yet
|
|
38
|
-
# (skips repos that are already cloned — safe to run multiple times)
|
|
39
|
-
# - Indexes each repo with Cairn MCP for codebase context
|
|
40
|
-
# - Tests SSH connectivity to each configured log source
|
|
41
|
-
# - Sends a test email to verify SMTP settings
|
|
42
|
-
#
|
|
43
|
-
# Note: ongoing repo management (git pull, conflict resolution) is handled
|
|
44
|
-
# automatically by Sentinel on each fix cycle — you don't need to do it manually.
|
|
45
|
-
set -euo pipefail
|
|
46
|
-
cd "$(dirname "$0")"
|
|
47
|
-
PYTHONPATH="${codeDir}" "${pythonBin}" -m sentinel.main --config ./config --init
|
|
48
|
-
`, { mode: 0o755 });
|
|
49
|
-
|
|
50
30
|
// start.sh
|
|
51
31
|
fs.writeFileSync(path.join(projectDir, 'start.sh'), `#!/usr/bin/env bash
|
|
52
32
|
# Start this Sentinel instance
|
|
@@ -106,7 +86,18 @@ rm -f "$PID_FILE"
|
|
|
106
86
|
|
|
107
87
|
// ── Workspace-level startAll / stopAll ────────────────────────────────────────
|
|
108
88
|
|
|
109
|
-
function generateWorkspaceScripts(workspace) {
|
|
89
|
+
function generateWorkspaceScripts(workspace, smtpConfig = {}) {
|
|
90
|
+
// Write shared sentinel.properties once (never overwrite existing)
|
|
91
|
+
const workspaceProps = path.join(workspace, 'sentinel.properties');
|
|
92
|
+
if (!fs.existsSync(workspaceProps)) {
|
|
93
|
+
const tplDir = path.join(__dirname, '..', 'templates');
|
|
94
|
+
let tpl = fs.readFileSync(path.join(tplDir, 'workspace-sentinel.properties'), 'utf8');
|
|
95
|
+
if (smtpConfig.host) tpl = tpl.replace('SMTP_HOST=smtp.gmail.com', 'SMTP_HOST=' + smtpConfig.host);
|
|
96
|
+
if (smtpConfig.port) tpl = tpl.replace('SMTP_PORT=587', 'SMTP_PORT=' + smtpConfig.port);
|
|
97
|
+
if (smtpConfig.user) tpl = tpl.replace('SMTP_USER=sentinel@yourdomain.com', 'SMTP_USER=' + smtpConfig.user);
|
|
98
|
+
if (smtpConfig.password) tpl = tpl.replace('SMTP_PASSWORD=<app-password>', 'SMTP_PASSWORD=' + smtpConfig.password);
|
|
99
|
+
fs.writeFileSync(workspaceProps, tpl);
|
|
100
|
+
}
|
|
110
101
|
// startAll.sh
|
|
111
102
|
fs.writeFileSync(path.join(workspace, 'startAll.sh'), `#!/usr/bin/env bash
|
|
112
103
|
# Start all valid Sentinel project instances.
|
package/lib/init.js
CHANGED
|
@@ -51,9 +51,26 @@ module.exports = async function init() {
|
|
|
51
51
|
message: 'Set up systemd service for auto-start on reboot?',
|
|
52
52
|
initial: process.platform === 'linux',
|
|
53
53
|
},
|
|
54
|
+
{
|
|
55
|
+
type: 'text',
|
|
56
|
+
name: 'smtpUser',
|
|
57
|
+
message: 'SMTP sender address (e.g. sentinel@yourdomain.com)',
|
|
58
|
+
initial: '',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
type: prev => prev ? 'password' : null,
|
|
62
|
+
name: 'smtpPassword',
|
|
63
|
+
message: 'SMTP password / app password',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
type: prev => prev ? 'text' : null,
|
|
67
|
+
name: 'smtpHost',
|
|
68
|
+
message: 'SMTP host',
|
|
69
|
+
initial: 'smtp.gmail.com',
|
|
70
|
+
},
|
|
54
71
|
], { onCancel: () => process.exit(0) });
|
|
55
72
|
|
|
56
|
-
const { workspace, authMode, anthropicKey, example, systemd } = answers;
|
|
73
|
+
const { workspace, authMode, anthropicKey, example, systemd, smtpUser, smtpPassword, smtpHost } = answers;
|
|
57
74
|
const codeDir = path.join(workspace, 'code');
|
|
58
75
|
|
|
59
76
|
// ── Python ──────────────────────────────────────────────────────────────────
|
|
@@ -121,7 +138,7 @@ module.exports = async function init() {
|
|
|
121
138
|
|
|
122
139
|
// ── Workspace start/stop scripts ─────────────────────────────────────────────
|
|
123
140
|
step('Generating scripts…');
|
|
124
|
-
generateWorkspaceScripts(workspace);
|
|
141
|
+
generateWorkspaceScripts(workspace, { host: smtpHost, user: smtpUser, password: smtpPassword });
|
|
125
142
|
ok(`${workspace}/startAll.sh`);
|
|
126
143
|
ok(`${workspace}/stopAll.sh`);
|
|
127
144
|
|
|
@@ -141,13 +158,10 @@ module.exports = async function init() {
|
|
|
141
158
|
${chalk.cyan(`${workspace}/my-project/config/log-configs/`)}
|
|
142
159
|
${chalk.cyan(`${workspace}/my-project/config/repo-configs/`)}
|
|
143
160
|
|
|
144
|
-
2.
|
|
145
|
-
${chalk.cyan(`${workspace}/my-project/init.sh`)}
|
|
146
|
-
|
|
147
|
-
3. Start all projects:
|
|
161
|
+
2. Start all projects (Sentinel clones repos, indexes, and monitors automatically):
|
|
148
162
|
${chalk.cyan(`${workspace}/startAll.sh`)}
|
|
149
163
|
|
|
150
|
-
|
|
164
|
+
3. Stop all projects:
|
|
151
165
|
${chalk.cyan(`${workspace}/stopAll.sh`)}
|
|
152
166
|
`);
|
|
153
167
|
}
|