@misterhuydo/sentinel 1.0.9 → 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/lib/add.js +415 -417
- package/lib/generate.js +0 -18
- package/lib/init.js +2 -5
- package/package.json +1 -1
- package/python/sentinel/main.py +72 -28
- package/python/sentinel/reporter.py +73 -0
package/lib/add.js
CHANGED
|
@@ -1,417 +1,415 @@
|
|
|
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.
|
|
400
|
-
${chalk.cyan(`${projectDir}/
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
if (type === '
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
return addFromName(arg, workspace);
|
|
417
|
-
};
|
|
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
|
@@ -27,24 +27,6 @@ function writeExampleProject(projectDir, codeDir, pythonBin, anthropicKey = '')
|
|
|
27
27
|
function generateProjectScripts(projectDir, codeDir, pythonBin) {
|
|
28
28
|
const name = path.basename(projectDir);
|
|
29
29
|
|
|
30
|
-
// init.sh
|
|
31
|
-
fs.writeFileSync(path.join(projectDir, 'init.sh'), `#!/usr/bin/env bash
|
|
32
|
-
# First-time setup for this Sentinel project instance.
|
|
33
|
-
#
|
|
34
|
-
# What this does:
|
|
35
|
-
# - Clones any repos defined in config/repo-configs/ that don't exist locally yet
|
|
36
|
-
# (skips repos that are already cloned — safe to run multiple times)
|
|
37
|
-
# - Indexes each repo with Cairn MCP for codebase context
|
|
38
|
-
# - Tests SSH connectivity to each configured log source
|
|
39
|
-
# - Sends a test email to verify SMTP settings
|
|
40
|
-
#
|
|
41
|
-
# Note: ongoing repo management (git pull, conflict resolution) is handled
|
|
42
|
-
# automatically by Sentinel on each fix cycle — you don't need to do it manually.
|
|
43
|
-
set -euo pipefail
|
|
44
|
-
cd "$(dirname "$0")"
|
|
45
|
-
PYTHONPATH="${codeDir}" "${pythonBin}" -m sentinel.main --config ./config --init
|
|
46
|
-
`, { mode: 0o755 });
|
|
47
|
-
|
|
48
30
|
// start.sh
|
|
49
31
|
fs.writeFileSync(path.join(projectDir, 'start.sh'), `#!/usr/bin/env bash
|
|
50
32
|
# Start this Sentinel instance
|
package/lib/init.js
CHANGED
|
@@ -158,13 +158,10 @@ module.exports = async function init() {
|
|
|
158
158
|
${chalk.cyan(`${workspace}/my-project/config/log-configs/`)}
|
|
159
159
|
${chalk.cyan(`${workspace}/my-project/config/repo-configs/`)}
|
|
160
160
|
|
|
161
|
-
2.
|
|
162
|
-
${chalk.cyan(`${workspace}/my-project/init.sh`)}
|
|
163
|
-
|
|
164
|
-
3. Start all projects:
|
|
161
|
+
2. Start all projects (Sentinel clones repos, indexes, and monitors automatically):
|
|
165
162
|
${chalk.cyan(`${workspace}/startAll.sh`)}
|
|
166
163
|
|
|
167
|
-
|
|
164
|
+
3. Stop all projects:
|
|
168
165
|
${chalk.cyan(`${workspace}/stopAll.sh`)}
|
|
169
166
|
`);
|
|
170
167
|
}
|
package/package.json
CHANGED
package/python/sentinel/main.py
CHANGED
|
@@ -24,7 +24,7 @@ from .log_fetcher import fetch_all
|
|
|
24
24
|
from .log_parser import parse_all, scan_all_for_markers, ErrorEvent
|
|
25
25
|
from .issue_watcher import scan_issues, mark_done, IssueEvent
|
|
26
26
|
from .repo_router import route
|
|
27
|
-
from .reporter import build_and_send, send_fix_notification, send_failure_notification, send_confirmed_notification, send_regression_notification
|
|
27
|
+
from .reporter import build_and_send, send_fix_notification, send_failure_notification, send_confirmed_notification, send_regression_notification, send_startup_notification
|
|
28
28
|
from .state_store import StateStore
|
|
29
29
|
|
|
30
30
|
logging.basicConfig(
|
|
@@ -311,50 +311,99 @@ def _report_due(cfg_loader: ConfigLoader, store: StateStore) -> bool:
|
|
|
311
311
|
return elapsed >= cfg_loader.sentinel.report_interval_hours * 3600
|
|
312
312
|
|
|
313
313
|
|
|
314
|
-
# ──
|
|
314
|
+
# ── Startup checks (runs automatically on every start) ───────────────────────────
|
|
315
315
|
|
|
316
|
-
def
|
|
317
|
-
|
|
318
|
-
|
|
316
|
+
async def _startup_checks(cfg_loader: ConfigLoader) -> dict:
|
|
317
|
+
"""
|
|
318
|
+
Clone missing repos, index with Cairn, test SSH sources.
|
|
319
|
+
Returns a results dict passed to the startup email.
|
|
320
|
+
"""
|
|
321
|
+
results = {
|
|
322
|
+
"repos": [], # {name, status, message}
|
|
323
|
+
"cairn": [], # {name, status, message}
|
|
324
|
+
"ssh": [], # {name, host, status, message}
|
|
325
|
+
"warnings": [],
|
|
326
|
+
}
|
|
319
327
|
|
|
320
328
|
if not cairn_installed():
|
|
321
|
-
|
|
329
|
+
results["warnings"].append("Cairn not found — run: npm install -g @misterhuydo/cairn-mcp")
|
|
322
330
|
|
|
323
331
|
for name, repo in cfg_loader.repos.items():
|
|
324
332
|
local = Path(repo.local_path)
|
|
325
333
|
if not local.exists():
|
|
326
334
|
logger.info("Cloning %s → %s", repo.repo_url, repo.local_path)
|
|
327
|
-
r = subprocess.run(
|
|
335
|
+
r = subprocess.run(
|
|
336
|
+
["git", "clone", repo.repo_url, str(local)],
|
|
337
|
+
capture_output=True, text=True,
|
|
338
|
+
)
|
|
328
339
|
if r.returncode != 0:
|
|
329
|
-
|
|
340
|
+
msg = r.stderr.strip()
|
|
341
|
+
logger.error("Clone failed for %s: %s", name, msg)
|
|
342
|
+
results["repos"].append({"name": name, "status": "error", "message": msg})
|
|
330
343
|
continue
|
|
331
|
-
|
|
344
|
+
results["repos"].append({"name": name, "status": "cloned", "message": repo.repo_url})
|
|
345
|
+
else:
|
|
346
|
+
results["repos"].append({"name": name, "status": "exists", "message": str(local)})
|
|
347
|
+
|
|
348
|
+
ok = index_repo(repo)
|
|
349
|
+
results["cairn"].append({
|
|
350
|
+
"name": name,
|
|
351
|
+
"status": "ok" if ok else "error",
|
|
352
|
+
"message": "indexed" if ok else "cairn index failed — check logs",
|
|
353
|
+
})
|
|
332
354
|
|
|
333
355
|
for src_name, src in cfg_loader.log_sources.items():
|
|
334
356
|
if src.source_type == "ssh" and src.hosts:
|
|
335
357
|
host = src.hosts[0]
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
358
|
+
try:
|
|
359
|
+
r = subprocess.run(
|
|
360
|
+
["ssh", "-i", src.key, "-o", "StrictHostKeyChecking=no",
|
|
361
|
+
"-o", "ConnectTimeout=5", f"ec2-user@{host}", "echo ok"],
|
|
362
|
+
capture_output=True, text=True, timeout=15,
|
|
363
|
+
)
|
|
364
|
+
ok = r.returncode == 0
|
|
365
|
+
results["ssh"].append({
|
|
366
|
+
"name": src_name, "host": host,
|
|
367
|
+
"status": "ok" if ok else "error",
|
|
368
|
+
"message": "" if ok else r.stderr.strip(),
|
|
369
|
+
})
|
|
370
|
+
except Exception as e:
|
|
371
|
+
results["ssh"].append({"name": src_name, "host": host,
|
|
372
|
+
"status": "error", "message": str(e)})
|
|
373
|
+
|
|
374
|
+
return results
|
|
375
|
+
|
|
343
376
|
|
|
344
|
-
|
|
377
|
+
async def _send_startup_email_delayed(cfg, results: dict, delay: int = 300):
|
|
378
|
+
"""Wait delay seconds then send startup summary email."""
|
|
379
|
+
await asyncio.sleep(delay)
|
|
345
380
|
try:
|
|
346
|
-
|
|
381
|
+
send_startup_notification(cfg, results)
|
|
347
382
|
except Exception as e:
|
|
348
|
-
logger.error("
|
|
383
|
+
logger.error("Failed to send startup notification: %s", e)
|
|
349
384
|
|
|
350
|
-
logger.info("=== Init complete ===")
|
|
351
385
|
|
|
352
|
-
|
|
353
|
-
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
386
|
+
# ── Entry point ──────────────────────────────────────────────────────────────────────────────────
|
|
354
387
|
|
|
355
388
|
async def run_loop(cfg_loader: ConfigLoader, store: StateStore):
|
|
356
389
|
interval = cfg_loader.sentinel.poll_interval_seconds
|
|
357
|
-
logger.info("Sentinel starting — poll interval: %ds, repos: %s",
|
|
390
|
+
logger.info("Sentinel starting — poll interval: %ds, repos: %s",
|
|
391
|
+
interval, list(cfg_loader.repos.keys()))
|
|
392
|
+
|
|
393
|
+
results = await _startup_checks(cfg_loader)
|
|
394
|
+
|
|
395
|
+
has_errors = any(
|
|
396
|
+
item["status"] == "error"
|
|
397
|
+
for key in ("repos", "cairn", "ssh")
|
|
398
|
+
for item in results[key]
|
|
399
|
+
)
|
|
400
|
+
if has_errors:
|
|
401
|
+
logger.warning("Startup completed with errors — check config and logs")
|
|
402
|
+
else:
|
|
403
|
+
logger.info("Startup checks passed — startup email in 5 minutes")
|
|
404
|
+
|
|
405
|
+
asyncio.ensure_future(_send_startup_email_delayed(cfg_loader.sentinel, results))
|
|
406
|
+
|
|
358
407
|
while True:
|
|
359
408
|
try:
|
|
360
409
|
await poll_cycle(cfg_loader, store)
|
|
@@ -370,7 +419,6 @@ def main():
|
|
|
370
419
|
Path("issues").mkdir(exist_ok=True)
|
|
371
420
|
|
|
372
421
|
parser = argparse.ArgumentParser(description="Sentinel — Autonomous DevOps Agent")
|
|
373
|
-
parser.add_argument("--init", action="store_true", help="First-time setup")
|
|
374
422
|
parser.add_argument("--config", default="./config", help="Config directory path")
|
|
375
423
|
args = parser.parse_args()
|
|
376
424
|
|
|
@@ -378,10 +426,6 @@ def main():
|
|
|
378
426
|
store = StateStore(cfg_loader.sentinel.state_db)
|
|
379
427
|
_register_signals()
|
|
380
428
|
|
|
381
|
-
if args.init:
|
|
382
|
-
run_init(cfg_loader)
|
|
383
|
-
return
|
|
384
|
-
|
|
385
429
|
asyncio.run(run_loop(cfg_loader, store))
|
|
386
430
|
|
|
387
431
|
|
|
@@ -272,3 +272,76 @@ def send_regression_notification(cfg: SentinelConfig, fix: dict, event: dict):
|
|
|
272
272
|
)
|
|
273
273
|
_send_email(cfg, subject, html)
|
|
274
274
|
logger.info('Regression notification sent for %s', fingerprint)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ---- Startup notification ---------------------------------------------------
|
|
278
|
+
|
|
279
|
+
def send_startup_notification(cfg: SentinelConfig, results: dict):
|
|
280
|
+
"""Send startup summary email 5 minutes after Sentinel starts."""
|
|
281
|
+
if not cfg.mails:
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
ts = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
|
|
285
|
+
|
|
286
|
+
repos = results.get('repos', [])
|
|
287
|
+
cairn = results.get('cairn', [])
|
|
288
|
+
ssh = results.get('ssh', [])
|
|
289
|
+
warns = results.get('warnings', [])
|
|
290
|
+
|
|
291
|
+
has_errors = any(r['status'] == 'error' for r in repos + cairn + ssh)
|
|
292
|
+
status_label = '⚠ Started with warnings' if has_errors else '✅ Started successfully'
|
|
293
|
+
|
|
294
|
+
def row(label, value, ok=True):
|
|
295
|
+
color = '' if ok else ' style="color:#c62828"'
|
|
296
|
+
return f'<tr><td class="label"{color}>{label}</td><td class="mono"{color}>{value}</td></tr>'
|
|
297
|
+
|
|
298
|
+
repo_rows = ''.join(
|
|
299
|
+
row(r['name'],
|
|
300
|
+
f"{r['status'].upper()}: {r['message']}",
|
|
301
|
+
r['status'] != 'error')
|
|
302
|
+
for r in repos
|
|
303
|
+
)
|
|
304
|
+
cairn_rows = ''.join(
|
|
305
|
+
row(r['name'], r['message'], r['status'] == 'ok')
|
|
306
|
+
for r in cairn
|
|
307
|
+
)
|
|
308
|
+
ssh_rows = ''.join(
|
|
309
|
+
row(f"{r['name']} ({r['host']})",
|
|
310
|
+
r['message'] or 'OK',
|
|
311
|
+
r['status'] == 'ok')
|
|
312
|
+
for r in ssh
|
|
313
|
+
)
|
|
314
|
+
warn_html = (
|
|
315
|
+
'<h3>⚠ Warnings</h3><ul>' +
|
|
316
|
+
''.join(f'<li>{w}</li>' for w in warns) +
|
|
317
|
+
'</ul>'
|
|
318
|
+
) if warns else ''
|
|
319
|
+
|
|
320
|
+
html = (
|
|
321
|
+
'<!DOCTYPE html><html><head><meta charset="utf-8">'
|
|
322
|
+
'<style>'
|
|
323
|
+
'body{font-family:Arial,sans-serif;font-size:14px;color:#222}'
|
|
324
|
+
'h2{color:#1a73e8}'
|
|
325
|
+
'h3{color:#444;border-bottom:1px solid #ddd;padding-bottom:4px}'
|
|
326
|
+
'table{border-collapse:collapse;width:100%;margin-bottom:16px}'
|
|
327
|
+
'th{background:#f1f3f4;text-align:left;padding:6px 10px}'
|
|
328
|
+
'td{padding:5px 10px;border-bottom:1px solid #eee;vertical-align:top}'
|
|
329
|
+
'.label{font-weight:bold;width:200px}'
|
|
330
|
+
'.mono{font-family:monospace;font-size:12px}'
|
|
331
|
+
'</style></head><body>'
|
|
332
|
+
f'<h2>Sentinel {status_label}</h2>'
|
|
333
|
+
f'<p>{ts}</p>'
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
if repo_rows:
|
|
337
|
+
html += f'<h3>Repositories</h3><table>{repo_rows}</table>'
|
|
338
|
+
if cairn_rows:
|
|
339
|
+
html += f'<h3>Cairn Index</h3><table>{cairn_rows}</table>'
|
|
340
|
+
if ssh_rows:
|
|
341
|
+
html += f'<h3>SSH Connectivity</h3><table>{ssh_rows}</table>'
|
|
342
|
+
html += warn_html
|
|
343
|
+
html += '<hr><small>Sentinel — Autonomous DevOps Agent</small></body></html>'
|
|
344
|
+
|
|
345
|
+
subject = f'[Sentinel] {status_label} — {ts}'
|
|
346
|
+
_send_email(cfg, subject, html)
|
|
347
|
+
logger.info('Startup notification sent to %d recipient(s)', len(cfg.mails))
|