@misterhuydo/sentinel 1.0.5 → 1.0.9

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 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 Interactive setup — install everything and create workspace
31
- sentinel add <name> Add a new project instance to an existing workspace
32
- `);
33
- }
34
- }
35
-
36
- main().catch(err => {
37
- console.error(chalk.red('Error:'), err.message);
38
- process.exit(1);
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
@@ -3,48 +3,395 @@
3
3
  const fs = require('fs-extra');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
+ const { execSync, spawnSync } = require('child_process');
6
7
  const prompts = require('prompts');
7
8
  const chalk = require('chalk');
8
- const { writeExampleProject, generateWorkspaceScripts } = require('./generate');
9
-
10
- module.exports = async function add(nameArg) {
11
- const answers = await prompts([
12
- {
13
- type: 'text',
14
- name: 'workspace',
15
- message: 'Workspace directory',
16
- initial: path.join(os.homedir(), 'sentinel'),
17
- format: v => v.replace(/^~/, os.homedir()),
18
- },
19
- {
20
- type: 'text',
21
- name: 'name',
22
- message: 'Project name',
23
- initial: nameArg || 'my-project',
24
- validate: v => /^[a-z0-9_-]+$/i.test(v) || 'Use letters, numbers, hyphens only',
25
- },
26
- ], { onCancel: () => process.exit(0) });
27
-
28
- const { workspace, name } = answers;
29
- const codeDir = path.join(workspace, 'code');
30
- const pythonBin = path.join(codeDir, '.venv', 'bin', 'python3');
31
- const projectDir = path.join(workspace, name);
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
+ }
32
38
 
39
+ function requireCodeDir(workspace) {
40
+ const codeDir = path.join(workspace, 'code');
33
41
  if (!fs.existsSync(codeDir)) {
34
42
  console.error(chalk.red(`Sentinel code not found at ${codeDir}`));
35
43
  console.error(chalk.red('Run "sentinel init" first.'));
36
44
  process.exit(1);
37
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; }
38
232
 
39
233
  if (fs.existsSync(projectDir)) {
40
234
  console.error(chalk.yellow(`Project "${name}" already exists at ${projectDir}`));
41
235
  process.exit(1);
42
236
  }
43
237
 
238
+ const codeDir = requireCodeDir(workspace);
239
+ const pythonBin = path.join(codeDir, '.venv', 'bin', 'python3');
240
+
44
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
+
45
257
  generateWorkspaceScripts(workspace);
258
+ ok(`Project "${name}" created at ${projectDir}`);
259
+ printNextSteps(projectDir);
260
+ }
46
261
 
47
- console.log('\n' + chalk.green('✔'), `Project "${name}" created at ${projectDir}`);
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) {
48
395
  console.log(`
49
396
  Next steps:
50
397
  1. Edit config files in:
@@ -54,4 +401,17 @@ module.exports = async function add(nameArg) {
54
401
  3. Start this project:
55
402
  ${chalk.cyan(`${projectDir}/start.sh`)}
56
403
  `);
404
+ }
405
+
406
+ // ── Entry point ────────────────────────────────────────────────────────────
407
+
408
+ module.exports = async function add(arg) {
409
+ const type = detectInputType(arg);
410
+
411
+ const workspace = await resolveWorkspace();
412
+
413
+ if (type === 'git') return addFromGit(arg, workspace);
414
+ if (type === 'url') return addFromUrl(arg, workspace);
415
+ if (type === 'json') return addFromJson(arg, workspace);
416
+ return addFromName(arg, workspace);
57
417
  };
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
- // Inject API key into sentinel.properties if provided
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 += `\n# Anthropic API key for Claude Code (headless server auth)\nANTHROPIC_API_KEY=${anthropicKey}\n`;
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'));
@@ -73,7 +71,7 @@ if echo "$AUTH_OUT" | grep -Eqi "not logged in|/login"; then
73
71
  exit 1
74
72
  fi
75
73
 
76
- mkdir -p "$DIR/logs" "$DIR/workspace/fetched" "$DIR/workspace/patches"
74
+ mkdir -p "$DIR/logs" "$DIR/workspace/fetched" "$DIR/workspace/patches" "$DIR/issues"
77
75
  cd "$DIR"
78
76
  PYTHONPATH="${codeDir}" "${pythonBin}" -m sentinel.main --config ./config \\
79
77
  >> "$DIR/logs/sentinel.log" 2>&1 &
@@ -106,20 +104,50 @@ rm -f "$PID_FILE"
106
104
 
107
105
  // ── Workspace-level startAll / stopAll ────────────────────────────────────────
108
106
 
109
- function generateWorkspaceScripts(workspace) {
107
+ function generateWorkspaceScripts(workspace, smtpConfig = {}) {
108
+ // Write shared sentinel.properties once (never overwrite existing)
109
+ const workspaceProps = path.join(workspace, 'sentinel.properties');
110
+ if (!fs.existsSync(workspaceProps)) {
111
+ const tplDir = path.join(__dirname, '..', 'templates');
112
+ let tpl = fs.readFileSync(path.join(tplDir, 'workspace-sentinel.properties'), 'utf8');
113
+ if (smtpConfig.host) tpl = tpl.replace('SMTP_HOST=smtp.gmail.com', 'SMTP_HOST=' + smtpConfig.host);
114
+ if (smtpConfig.port) tpl = tpl.replace('SMTP_PORT=587', 'SMTP_PORT=' + smtpConfig.port);
115
+ if (smtpConfig.user) tpl = tpl.replace('SMTP_USER=sentinel@yourdomain.com', 'SMTP_USER=' + smtpConfig.user);
116
+ if (smtpConfig.password) tpl = tpl.replace('SMTP_PASSWORD=<app-password>', 'SMTP_PASSWORD=' + smtpConfig.password);
117
+ fs.writeFileSync(workspaceProps, tpl);
118
+ }
110
119
  // startAll.sh
111
120
  fs.writeFileSync(path.join(workspace, 'startAll.sh'), `#!/usr/bin/env bash
112
- # Start all Sentinel project instances
121
+ # Start all valid Sentinel project instances.
122
+ # A valid project must have config/repo-configs/*.properties with a GitHub REPO_URL.
113
123
  WORKSPACE="$(cd "$(dirname "$0")" && pwd)"
114
124
  started=0
125
+ skipped=0
115
126
  for project_dir in "$WORKSPACE"/*/; do
116
127
  name=$(basename "$project_dir")
117
128
  [[ "$name" == "code" ]] && continue
118
129
  [[ -f "$project_dir/start.sh" ]] || continue
130
+
131
+ # Must have at least one repo-config with a valid GitHub REPO_URL
132
+ valid_repo=false
133
+ for props in "$project_dir/config/repo-configs/"*.properties 2>/dev/null; do
134
+ [[ -f "$props" ]] || continue
135
+ if grep -qE "^REPO_URL[[:space:]]*=[[:space:]]*(git@github\.com:|https://github\.com/)" "$props"; then
136
+ valid_repo=true
137
+ break
138
+ fi
139
+ done
140
+
141
+ if [[ "$valid_repo" == "false" ]]; then
142
+ echo "[sentinel] Skipping $name — no valid REPO_URL found in config/repo-configs/"
143
+ skipped=$((skipped + 1))
144
+ continue
145
+ fi
146
+
119
147
  bash "$project_dir/start.sh"
120
148
  started=$((started + 1))
121
149
  done
122
- echo "[sentinel] $started project(s) started"
150
+ echo "[sentinel] $started project(s) started, $skipped skipped"
123
151
  `, { mode: 0o755 });
124
152
 
125
153
  // stopAll.sh
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.0.5",
3
+ "version": "1.0.9",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"