@misterhuydo/sentinel 1.2.9 → 1.3.0

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.
@@ -1 +1,8 @@
1
- {}
1
+ {
2
+ "J:\\Projects\\Sentinel\\cli\\bin\\sentinel.js": {
3
+ "tempPath": "J:\\Projects\\Sentinel\\cli\\.cairn\\views\\a348d8_sentinel.js",
4
+ "state": "compressed",
5
+ "minifiedAt": 1774252515044.4768,
6
+ "readCount": 1
7
+ }
8
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-23T12:00:29.548Z",
3
- "checkpoint_at": "2026-03-23T12:00:29.550Z",
2
+ "message": "Auto-checkpoint at 2026-03-23T12:04:39.918Z",
3
+ "checkpoint_at": "2026-03-23T12:04:39.919Z",
4
4
  "active_files": [],
5
5
  "notes": [],
6
6
  "mtime_snapshot": {}
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const chalk = require('chalk');
4
+ const [,, command = 'help', ...args] = process.argv;
5
+ if (command === '--version' || command === '-v') {
6
+ const { version } = require('../package.json');
7
+ console.log(version);
8
+ process.exit(0);
9
+ }
10
+ if (command === '--help' || command === '-h') {
11
+ printUsage();
12
+ process.exit(0);
13
+ }
14
+ const BANNER = `
15
+ ${chalk.cyan('███████╗███████╗███╗ ██╗████████╗██╗███╗ ██╗███████╗██╗')}
16
+ ${chalk.cyan('██╔════╝██╔════╝████╗ ██║╚══██╔══╝██║████╗ ██║██╔════╝██║')}
17
+ ${chalk.cyan('███████╗█████╗ ██╔██╗ ██║ ██║ ██║██╔██╗ ██║█████╗ ██║')}
18
+ ${chalk.cyan('╚════██║██╔══╝ ██║╚██╗██║ ██║ ██║██║╚██╗██║██╔══╝ ██║')}
19
+ ${chalk.cyan('███████║███████╗██║ ╚████║ ██║ ██║██║ ╚████║███████╗███████╗')}
20
+ ${chalk.cyan('╚══════╝╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚══════╝')}
21
+ ${chalk.gray(' Autonomous DevOps Agent')}
22
+ `;
23
+ async function main() {
24
+ console.log(BANNER);
25
+ switch (command) {
26
+ case 'init':
27
+ await require('../lib/init')();
28
+ break;
29
+ case 'add':
30
+ await require('../lib/add')(args[0]);
31
+ break;
32
+ case 'test':
33
+ await require('../lib/test')(args[0]);
34
+ break;
35
+ case 'upgrade': {
36
+ let upgradeCmd;
37
+ try {
38
+ upgradeCmd = require('../lib/upgrade');
39
+ } catch (loadErr) {
40
+ console.log(chalk.yellow(' ⚠'), 'upgrade module failed to load (' + loadErr.message + ')');
41
+ console.log(chalk.cyan(' →'), 'Running bare npm install to self-heal...');
42
+ const { spawnSync } = require('child_process');
43
+ const r = spawnSync('npm', ['install', '-g', '@misterhuydo/sentinel@latest'],
44
+ { stdio: 'inherit' });
45
+ if (r.status === 0) {
46
+ console.log(chalk.green(' ✔'), 'Self-healed — run `sentinel upgrade` again to finish');
47
+ } else {
48
+ console.error(chalk.red(' ✖'), 'npm install failed — try: npm install -g @misterhuydo/sentinel');
49
+ }
50
+ process.exit(r.status || 0);
51
+ }
52
+ await upgradeCmd();
53
+ break;
54
+ }
55
+ case 'help':
56
+ default:
57
+ printUsage();
58
+ }
59
+ }
60
+ function printUsage() {
61
+ const { version } = require('../package.json');
62
+ console.log(`${chalk.bold('sentinel')} v${version} — Autonomous DevOps Agent
63
+ ${chalk.bold('Usage:')}
64
+ sentinel init Interactive setup — install everything and create workspace
65
+ sentinel add <name> Add a blank project (fill config manually)
66
+ sentinel add <git-url> Add a project pre-configured for a GitHub repo
67
+ sentinel add <project.json> Add a project from a local JSON config file
68
+ sentinel add <https://host/cfg.json> Add a project from a remote JSON config URL
69
+ sentinel test [project] Validate installation and config before going live
70
+ sentinel upgrade Pull latest version and hot-deploy Python source
71
+ ${chalk.bold('Options:')}
72
+ --version, -v Print version
73
+ --help, -h Print this help
74
+ `);
75
+ }
76
+ main().catch(err => {
77
+ console.error(chalk.red('Error:'), err.message);
78
+ process.exit(1);
79
+ });
package/lib/add.js CHANGED
@@ -1,415 +1,470 @@
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
- };
1
+ 'use strict';
2
+ const fs = require('fs-extra');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const { execSync, spawnSync } = require('child_process');
6
+ const prompts = require('prompts');
7
+ const chalk = require('chalk');
8
+ const { writeExampleProject, generateWorkspaceScripts, generateProjectScripts } = require('./generate');
9
+ const ok = msg => console.log(chalk.green(''), msg);
10
+ const info = msg => console.log(chalk.cyan(' →'), msg);
11
+ const warn = msg => console.log(chalk.yellow(' '), msg);
12
+ const step = msg => console.log('\n' + chalk.bold.white(msg));
13
+
14
+ function detectInputType(arg) {
15
+ if (!arg) return 'name';
16
+ if (/^git@/.test(arg) || (/^https?:\/\/github\.com\//.test(arg) && arg.endsWith('.git'))) return 'git';
17
+ if (/^https?:\/\//.test(arg)) return 'url';
18
+ if (arg.toLowerCase().endsWith('.json') || arg.includes('/') || arg.includes('\\')) return 'json';
19
+ return 'name';
20
+ }
21
+
22
+ async function resolveWorkspace(initial) {
23
+ const ans = await prompts([{
24
+ type: 'text',
25
+ name: 'workspace',
26
+ message: 'Workspace directory',
27
+ initial: initial || path.join(os.homedir(), 'sentinel'),
28
+ format: v => v.replace(/^~/, os.homedir()),
29
+ }], { onCancel: () => process.exit(0) });
30
+ return ans.workspace;
31
+ }
32
+
33
+ function requireCodeDir(workspace) {
34
+ const codeDir = path.join(workspace, 'code');
35
+ if (!fs.existsSync(codeDir)) {
36
+ console.error(chalk.red(`Sentinel code not found at ${codeDir}`));
37
+ console.error(chalk.red('Run "sentinel init" first.'));
38
+ process.exit(1);
39
+ }
40
+ return codeDir;
41
+ }
42
+
43
+ const VALID_NAME = /^[a-z0-9_-]+$/i;
44
+ const GITHUB_URL = /^(git@github\.com:|https:\/\/github\.com\/).+\.git$/;
45
+
46
+ function validateProjectJson(obj) {
47
+ const errors = [];
48
+ if (!obj.name || !VALID_NAME.test(obj.name)) {
49
+ errors.push('name must be letters, numbers, hyphens only');
50
+ }
51
+ if (!Array.isArray(obj.repos) || obj.repos.length === 0) {
52
+ errors.push('repos array is required and must be non-empty');
53
+ } else {
54
+ obj.repos.forEach((r, i) => {
55
+ if (!r.REPO_URL || !GITHUB_URL.test(r.REPO_URL)) {
56
+ errors.push(`repos[${i}].REPO_URL must be a valid GitHub URL`);
57
+ }
58
+ if (!r.name) errors.push(`repos[${i}].name is required`);
59
+ });
60
+ }
61
+ return errors;
62
+ }
63
+
64
+ function writePropertiesFile(filePath, obj) {
65
+ const lines = Object.entries(obj).map(([k, v]) => `${k}=${v}`);
66
+ fs.writeFileSync(filePath, lines.join('\n') + '\n');
67
+ }
68
+
69
+ function applyJsonToProject(projectDir, obj) {
70
+ const configDir = path.join(projectDir, 'config');
71
+ const repoDir = path.join(projectDir, 'config', 'repo-configs');
72
+ const logDir = path.join(projectDir, 'config', 'log-configs');
73
+ fs.ensureDirSync(repoDir);
74
+ fs.ensureDirSync(logDir);
75
+ if (obj.sentinel) {
76
+ const propsPath = path.join(configDir, 'sentinel.properties');
77
+ const existing = fs.existsSync(propsPath) ? fs.readFileSync(propsPath, 'utf8') : '';
78
+ let updated = existing;
79
+ Object.entries(obj.sentinel).forEach(([k, v]) => {
80
+ const re = new RegExp(`^#?\\s*${k}\\s*=.*$`, 'm');
81
+ if (re.test(updated)) {
82
+ updated = updated.replace(re, `${k}=${v}`);
83
+ } else {
84
+ updated += `\n${k}=${v}`;
85
+ }
86
+ });
87
+ fs.writeFileSync(propsPath, updated);
88
+ ok('Updated sentinel.properties');
89
+ }
90
+ if (Array.isArray(obj.repos)) {
91
+ obj.repos.forEach(repo => {
92
+ const { name, ...props } = repo;
93
+ writePropertiesFile(path.join(repoDir, `${name}.properties`), props);
94
+ ok(`Created repo-configs/${name}.properties`);
95
+ });
96
+ }
97
+ if (Array.isArray(obj.log_sources)) {
98
+ obj.log_sources.forEach(src => {
99
+ const { name, ...props } = src;
100
+ writePropertiesFile(path.join(logDir, `${name}.properties`), props);
101
+ ok(`Created log-configs/${name}.properties`);
102
+ });
103
+ }
104
+ }
105
+
106
+ function fetchUrl(url) {
107
+ try {
108
+ const result = spawnSync('curl', ['-fsSL', '--max-time', '10', url], { encoding: 'utf8' });
109
+ if (result.status !== 0) throw new Error(result.stderr || 'curl failed');
110
+ return result.stdout;
111
+ } catch (_) {
112
+ const https = require('https');
113
+ const http = require('http');
114
+ const lib = url.startsWith('https') ? https : http;
115
+ return new Promise((resolve, reject) => {
116
+ lib.get(url, res => {
117
+ let data = '';
118
+ res.on('data', c => (data += c));
119
+ res.on('end', () => resolve(data));
120
+ }).on('error', reject);
121
+ });
122
+ }
123
+ }
124
+
125
+ // ── SSH deploy key helpers ────────────────────────────────────────────────────
126
+
127
+ function ensureKnownHosts() {
128
+ const knownHosts = path.join(os.homedir(), '.ssh', 'known_hosts');
129
+ const content = fs.existsSync(knownHosts) ? fs.readFileSync(knownHosts, 'utf8') : '';
130
+ if (content.includes('github.com')) return;
131
+ info('Adding GitHub to known_hosts…');
132
+ const r = spawnSync('ssh-keyscan', ['github.com'], { encoding: 'utf8', timeout: 10000 });
133
+ if (r.stdout) fs.appendFileSync(knownHosts, r.stdout);
134
+ }
135
+
136
+ function generateDeployKey(repoSlug) {
137
+ const sshDir = path.join(os.homedir(), '.ssh');
138
+ const keyFile = path.join(sshDir, `${repoSlug}.key`);
139
+ fs.ensureDirSync(sshDir);
140
+ fs.chmodSync(sshDir, 0o700);
141
+
142
+ if (fs.existsSync(keyFile)) {
143
+ info(`Using existing key: ${keyFile}`);
144
+ } else {
145
+ spawnSync('ssh-keygen', ['-t', 'ed25519', '-C', `sentinel@${repoSlug}`, '-f', keyFile, '-N', ''],
146
+ { stdio: 'inherit' });
147
+ ok(`Deploy key generated: ${keyFile}`);
148
+ }
149
+
150
+ const configFile = path.join(sshDir, 'config');
151
+ const sshHost = `github-${repoSlug}`;
152
+ const existing = fs.existsSync(configFile) ? fs.readFileSync(configFile, 'utf8') : '';
153
+ if (!existing.includes(`Host ${sshHost}`)) {
154
+ fs.appendFileSync(configFile,
155
+ `\nHost ${sshHost}\n HostName github.com\n User git\n IdentityFile ${keyFile}\n IdentitiesOnly yes\n`);
156
+ fs.chmodSync(configFile, 0o600);
157
+ ok('SSH config updated');
158
+ }
159
+
160
+ return { keyFile, sshHost };
161
+ }
162
+
163
+ function printDeployKeyInstructions(orgRepo, keyFile) {
164
+ const pubKey = fs.readFileSync(`${keyFile}.pub`, 'utf8').trim();
165
+ const bar = '─'.repeat(70);
166
+ console.log('');
167
+ console.log(chalk.bold.yellow(` ┌${bar}┐`));
168
+ console.log(chalk.bold.yellow(` │`) + chalk.bold(` Add this deploy key to GitHub`) + chalk.bold.yellow(' '.repeat(40) + ''));
169
+ console.log(chalk.bold.yellow(` │`) + chalk.cyan(` github.com/${orgRepo}`) + chalk.bold.yellow(' '.repeat(Math.max(0, 70 - 14 - orgRepo.length)) + '│'));
170
+ console.log(chalk.bold.yellow(` │`) + ` Settings Deploy keys → Add deploy key` + chalk.bold.yellow(' '.repeat(29) + '│'));
171
+ console.log(chalk.bold.yellow(` │`) + ` Allow write access: ✓` + chalk.bold.yellow(' '.repeat(47) + '│'));
172
+ console.log(chalk.bold.yellow(` └${bar}┘`));
173
+ console.log('');
174
+ console.log(chalk.green(pubKey));
175
+ console.log('');
176
+ }
177
+
178
+ // ── addFromGit ────────────────────────────────────────────────────────────────
179
+
180
+ async function addFromGit(gitUrl, workspace) {
181
+ const repoSlug = gitUrl.replace(/\.git$/, '').split(/[:/]/).pop();
182
+ const orgRepo = gitUrl
183
+ .replace(/^git@github\.com:/, '')
184
+ .replace(/^https:\/\/github\.com\//, '')
185
+ .replace(/\.git$/, '');
186
+
187
+ const { name } = await prompts([{
188
+ type: 'text',
189
+ name: 'name',
190
+ message: 'Project name',
191
+ initial: repoSlug,
192
+ validate: v => VALID_NAME.test(v) || 'Use letters, numbers, hyphens only',
193
+ }], { onCancel: () => process.exit(0) });
194
+
195
+ // ── 1. Generate SSH deploy key ──────────────────────────────────────────────
196
+ step('Setting up SSH deploy key');
197
+ ensureKnownHosts();
198
+ const { keyFile, sshHost } = generateDeployKey(repoSlug);
199
+ const sshUrl = `git@${sshHost}:${orgRepo}.git`;
200
+ printDeployKeyInstructions(orgRepo, keyFile);
201
+
202
+ // ── 2. Fix deployment mode ──────────────────────────────────────────────────
203
+ const { autoPublish } = await prompts({
204
+ type: 'select',
205
+ name: 'autoPublish',
206
+ message: 'How should Sentinel deploy fixes?',
207
+ hint: 'You can change this later in config/repo-configs/',
208
+ choices: [
209
+ {
210
+ title: 'Open a PR for each fix (AUTO_PUBLISH=false) — recommended',
211
+ description: 'Sentinel pushes to a branch and opens a GitHub PR. You review and merge.',
212
+ value: false,
213
+ },
214
+ {
215
+ title: 'Push directly to main (AUTO_PUBLISH=true) fully autonomous',
216
+ description: 'Sentinel commits and pushes fixes straight to your main branch.',
217
+ value: true,
218
+ },
219
+ ],
220
+ }, { onCancel: () => process.exit(0) });
221
+
222
+ if (autoPublish) {
223
+ warn('AUTO_PUBLISH=true: Sentinel will push fixes directly to main without review.');
224
+ warn('Make sure your repo has branch protection rules and CI that blocks bad pushes.');
225
+ }
226
+
227
+ // ── 3. Wait for user to add the key ────────────────────────────────────────
228
+ await prompts({
229
+ type: 'text',
230
+ name: '_',
231
+ message: chalk.bold('Press Enter once you\'ve added the deploy key to GitHub…'),
232
+ format: () => '',
233
+ }, { onCancel: () => process.exit(0) });
234
+
235
+ // ── 4. Validate access ──────────────────────────────────────────────────────
236
+ step('Validating repository access…');
237
+ info(`Testing SSH: ${sshUrl}`);
238
+ const result = spawnSync('git', ['ls-remote', '--heads', sshUrl],
239
+ {
240
+ encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
241
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
242
+ });
243
+
244
+ if (result.status !== 0) {
245
+ const errText = (result.stderr || result.error?.message || '').trim();
246
+ console.error(chalk.red(' ✖ Cannot reach repository'));
247
+ if (errText) console.error(chalk.red(` ${errText}`));
248
+ console.error('');
249
+ console.error(chalk.yellow(' Check that the deploy key was added to the correct repo with write access.'));
250
+ console.error(chalk.yellow(' Then re-run: sentinel add ' + gitUrl));
251
+ process.exit(1);
252
+ }
253
+
254
+ const branches = (result.stdout || '').split('\n')
255
+ .filter(Boolean).map(l => l.split('\t')[1].replace('refs/heads/', ''));
256
+ const defaultBranch = branches.includes('main') ? 'main' : (branches[0] || 'main');
257
+ ok(`Repository is reachable (default branch: ${defaultBranch})`);
258
+
259
+ // ── 5. Preview + confirm ────────────────────────────────────────────────────
260
+ const projectDir = path.join(workspace, name);
261
+ const localPath = path.join(workspace, 'repos', repoSlug);
262
+
263
+ step('Dry-run preview');
264
+ info(`Will create: ${projectDir}/`);
265
+ info(` config/repo-configs/${repoSlug}.properties`);
266
+ info(` REPO_URL=${sshUrl}`);
267
+ info(` BRANCH=${defaultBranch}`);
268
+ info(` AUTO_PUBLISH=${autoPublish}`);
269
+ info(' init.sh, start.sh, stop.sh');
270
+
271
+ const { confirm } = await prompts({
272
+ type: 'confirm', name: 'confirm',
273
+ message: `Create project "${name}"?`, initial: true,
274
+ }, { onCancel: () => process.exit(0) });
275
+ if (!confirm) { info('Aborted.'); return; }
276
+
277
+ if (fs.existsSync(projectDir)) {
278
+ console.error(chalk.yellow(`Project "${name}" already exists at ${projectDir}`));
279
+ process.exit(1);
280
+ }
281
+
282
+ // ── 6. Write files ──────────────────────────────────────────────────────────
283
+ const codeDir = requireCodeDir(workspace);
284
+ const pythonBin = path.join(codeDir, '.venv', 'bin', 'python3');
285
+ writeExampleProject(projectDir, codeDir, pythonBin);
286
+ const repoDir = path.join(projectDir, 'config', 'repo-configs');
287
+ writePropertiesFile(path.join(repoDir, `${repoSlug}.properties`), {
288
+ REPO_NAME: repoSlug,
289
+ REPO_URL: sshUrl,
290
+ LOCAL_PATH: localPath,
291
+ BRANCH: defaultBranch,
292
+ AUTO_PUBLISH: autoPublish ? 'true' : 'false',
293
+ CAIRN_MCP_ENABLED: 'true',
294
+ });
295
+ const example = path.join(repoDir, '_example.properties');
296
+ if (fs.existsSync(example)) fs.removeSync(example);
297
+ generateWorkspaceScripts(workspace);
298
+ ok(`Project "${name}" created at ${projectDir}`);
299
+ printNextSteps(projectDir, autoPublish);
300
+ }
301
+
302
+ // ── addFromName ───────────────────────────────────────────────────────────────
303
+
304
+ async function addFromName(nameArg, workspace) {
305
+ const answers = await prompts([{
306
+ type: 'text',
307
+ name: 'name',
308
+ message: 'Project name',
309
+ initial: nameArg || 'my-project',
310
+ validate: v => VALID_NAME.test(v) || 'Use letters, numbers, hyphens only',
311
+ }], { onCancel: () => process.exit(0) });
312
+ const { name } = answers;
313
+ const projectDir = path.join(workspace, name);
314
+ step('Dry-run preview');
315
+ info(`Will create: ${projectDir}/`);
316
+ info(' config/sentinel.properties');
317
+ info(' config/repo-configs/_example.properties');
318
+ info(' config/log-configs/_example.properties');
319
+ info(' init.sh, start.sh, stop.sh');
320
+ const { confirm } = await prompts({
321
+ type: 'confirm', name: 'confirm',
322
+ message: `Create project "${name}"?`, initial: true,
323
+ }, { onCancel: () => process.exit(0) });
324
+ if (!confirm) { info('Aborted.'); return; }
325
+ if (fs.existsSync(projectDir)) {
326
+ console.error(chalk.yellow(`Project "${name}" already exists at ${projectDir}`));
327
+ process.exit(1);
328
+ }
329
+ const codeDir = requireCodeDir(workspace);
330
+ const pythonBin = path.join(codeDir, '.venv', 'bin', 'python3');
331
+ writeExampleProject(projectDir, codeDir, pythonBin);
332
+ generateWorkspaceScripts(workspace);
333
+ ok(`Project "${name}" created at ${projectDir}`);
334
+ printNextSteps(projectDir);
335
+ }
336
+
337
+ // ── addFromJson ───────────────────────────────────────────────────────────────
338
+
339
+ async function addFromJson(jsonPath, workspace) {
340
+ step(`Reading ${jsonPath}`);
341
+ let obj;
342
+ try {
343
+ obj = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
344
+ } catch (e) {
345
+ console.error(chalk.red(` Cannot parse ${jsonPath}: ${e.message}`));
346
+ process.exit(1);
347
+ }
348
+ const errors = validateProjectJson(obj);
349
+ if (errors.length) {
350
+ console.error(chalk.red(' Invalid project JSON:'));
351
+ errors.forEach(e => console.error(chalk.red(` - ${e}`)));
352
+ process.exit(1);
353
+ }
354
+ ok('JSON is valid');
355
+ const { name } = obj;
356
+ const projectDir = path.join(workspace, name);
357
+ step('Dry-run preview');
358
+ info(`Will create: ${projectDir}/`);
359
+ (obj.repos || []).forEach(r => info(` config/repo-configs/${r.name}.properties (${r.REPO_URL})`));
360
+ (obj.log_sources || []).forEach(s => info(` config/log-configs/${s.name}.properties (${s.SOURCE_TYPE})`));
361
+ if (obj.sentinel) {
362
+ Object.entries(obj.sentinel).forEach(([k, v]) => info(` sentinel.properties: ${k}=${v}`));
363
+ }
364
+ const { confirm } = await prompts({
365
+ type: 'confirm', name: 'confirm',
366
+ message: `Create project "${name}" from ${path.basename(jsonPath)}?`, initial: true,
367
+ }, { onCancel: () => process.exit(0) });
368
+ if (!confirm) { info('Aborted.'); return; }
369
+ if (fs.existsSync(projectDir)) {
370
+ console.error(chalk.yellow(`Project "${name}" already exists at ${projectDir}`));
371
+ process.exit(1);
372
+ }
373
+ const codeDir = requireCodeDir(workspace);
374
+ const pythonBin = path.join(codeDir, '.venv', 'bin', 'python3');
375
+ writeExampleProject(projectDir, codeDir, pythonBin);
376
+ const repoDir = path.join(projectDir, 'config', 'repo-configs');
377
+ const logDir = path.join(projectDir, 'config', 'log-configs');
378
+ if (fs.existsSync(path.join(repoDir, '_example.properties'))) fs.removeSync(path.join(repoDir, '_example.properties'));
379
+ if (fs.existsSync(path.join(logDir, '_example.properties'))) fs.removeSync(path.join(logDir, '_example.properties'));
380
+ applyJsonToProject(projectDir, obj);
381
+ generateWorkspaceScripts(workspace);
382
+ ok(`Project "${name}" created at ${projectDir}`);
383
+ printNextSteps(projectDir);
384
+ }
385
+
386
+ // ── addFromUrl ────────────────────────────────────────────────────────────────
387
+
388
+ async function addFromUrl(url, workspace) {
389
+ step(`Fetching ${url}`);
390
+ let raw;
391
+ try {
392
+ raw = fetchUrl(url);
393
+ if (raw && typeof raw.then === 'function') raw = await raw;
394
+ } catch (e) {
395
+ console.error(chalk.red(` ✖ Cannot fetch ${url}: ${e.message}`));
396
+ process.exit(1);
397
+ }
398
+ let obj;
399
+ try {
400
+ obj = JSON.parse(raw);
401
+ } catch (e) {
402
+ console.error(chalk.red(` ✖ Response is not valid JSON: ${e.message}`));
403
+ process.exit(1);
404
+ }
405
+ const errors = validateProjectJson(obj);
406
+ if (errors.length) {
407
+ console.error(chalk.red(' Invalid project JSON at URL:'));
408
+ errors.forEach(e => console.error(chalk.red(` - ${e}`)));
409
+ process.exit(1);
410
+ }
411
+ ok('JSON is valid');
412
+ const { name } = obj;
413
+ const projectDir = path.join(workspace, name);
414
+ step('Dry-run preview');
415
+ info(`Will create: ${projectDir}/`);
416
+ (obj.repos || []).forEach(r => info(` config/repo-configs/${r.name}.properties (${r.REPO_URL})`));
417
+ (obj.log_sources || []).forEach(s => info(` config/log-configs/${s.name}.properties (${s.SOURCE_TYPE})`));
418
+ if (obj.sentinel) {
419
+ Object.entries(obj.sentinel).forEach(([k, v]) => info(` sentinel.properties: ${k}=${v}`));
420
+ }
421
+ const { confirm } = await prompts({
422
+ type: 'confirm', name: 'confirm',
423
+ message: `Create project "${name}" from ${url}?`, initial: true,
424
+ }, { onCancel: () => process.exit(0) });
425
+ if (!confirm) { info('Aborted.'); return; }
426
+ if (fs.existsSync(projectDir)) {
427
+ console.error(chalk.yellow(`Project "${name}" already exists at ${projectDir}`));
428
+ process.exit(1);
429
+ }
430
+ const codeDir = requireCodeDir(workspace);
431
+ const pythonBin = path.join(codeDir, '.venv', 'bin', 'python3');
432
+ writeExampleProject(projectDir, codeDir, pythonBin);
433
+ const repoDir = path.join(projectDir, 'config', 'repo-configs');
434
+ const logDir = path.join(projectDir, 'config', 'log-configs');
435
+ if (fs.existsSync(path.join(repoDir, '_example.properties'))) fs.removeSync(path.join(repoDir, '_example.properties'));
436
+ if (fs.existsSync(path.join(logDir, '_example.properties'))) fs.removeSync(path.join(logDir, '_example.properties'));
437
+ applyJsonToProject(projectDir, obj);
438
+ generateWorkspaceScripts(workspace);
439
+ ok(`Project "${name}" created at ${projectDir}`);
440
+ printNextSteps(projectDir);
441
+ }
442
+
443
+ // ── printNextSteps ────────────────────────────────────────────────────────────
444
+
445
+ function printNextSteps(projectDir, autoPublish) {
446
+ const mode = autoPublish === true
447
+ ? chalk.yellow(' ⚠ Fixes push directly to main — ensure CI blocks bad pushes')
448
+ : autoPublish === false
449
+ ? chalk.cyan(' → Sentinel will open a GitHub PR for each fix — review and merge at github.com')
450
+ : '';
451
+ console.log(`
452
+ Next steps:
453
+ 1. Edit config/log-configs/ to add your log sources
454
+ ${chalk.cyan(`${projectDir}/config/`)}
455
+ 2. Start Sentinel:
456
+ ${chalk.cyan(`${projectDir}/start.sh`)}
457
+ ${mode ? ' ' + mode : ''}
458
+ `);
459
+ }
460
+
461
+ // ── entry point ───────────────────────────────────────────────────────────────
462
+
463
+ module.exports = async function add(arg) {
464
+ const type = detectInputType(arg);
465
+ const workspace = await resolveWorkspace();
466
+ if (type === 'git') return addFromGit(arg, workspace);
467
+ if (type === 'url') return addFromUrl(arg, workspace);
468
+ if (type === 'json') return addFromJson(arg, workspace);
469
+ return addFromName(arg, workspace);
470
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.2.9",
3
+ "version": "1.3.0",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -0,0 +1,94 @@
1
+ #!/bin/bash
2
+ # gen_deploy_keys.sh — Generate one ed25519 deploy key per GitHub repo.
3
+ #
4
+ # Usage:
5
+ # ./gen_deploy_keys.sh org/repo1 org/repo2 ...
6
+ #
7
+ # What it does:
8
+ # 1. Generates ~/.ssh/<repo>.key and ~/.ssh/<repo>.key.pub for each repo
9
+ # 2. Adds a Host block to ~/.ssh/config so git uses the right key per repo
10
+ # 3. Adds github.com to known_hosts (if not already there)
11
+ # 4. Prints each public key for pasting into GitHub → Settings → Deploy keys
12
+ #
13
+ # After running:
14
+ # - Add each printed public key to its repo on GitHub (allow write access)
15
+ # - Use the SSH alias in git URLs: git@github-<repo>:org/repo.git
16
+ # - Test with: ssh -T github-<repo>
17
+
18
+ set -euo pipefail
19
+
20
+ if [[ $# -eq 0 ]]; then
21
+ echo "Usage: $0 org/repo1 org/repo2 ..."
22
+ echo " e.g. $0 Opplysningen1881/sentinel-1881 Opplysningen1881/1881-SSOLoginWebApp"
23
+ exit 1
24
+ fi
25
+
26
+ SSH_DIR="$HOME/.ssh"
27
+ mkdir -p "$SSH_DIR"
28
+ chmod 700 "$SSH_DIR"
29
+
30
+ # Add GitHub host key once
31
+ if ! grep -q "github.com" "$SSH_DIR/known_hosts" 2>/dev/null; then
32
+ echo "Adding GitHub to known_hosts..."
33
+ ssh-keyscan github.com >> "$SSH_DIR/known_hosts" 2>/dev/null
34
+ fi
35
+
36
+ touch "$SSH_DIR/config"
37
+ chmod 600 "$SSH_DIR/config"
38
+
39
+ for repo_path in "$@"; do
40
+ repo="${repo_path##*/}" # strip org/ prefix → just the repo name
41
+ keyfile="$SSH_DIR/${repo}.key"
42
+
43
+ echo ""
44
+ echo "══════════════════════════════════════════════"
45
+ echo " Repo: $repo_path"
46
+ echo " Keyfile: $keyfile"
47
+ echo "══════════════════════════════════════════════"
48
+
49
+ # Generate (skip if key already exists)
50
+ if [[ -f "$keyfile" ]]; then
51
+ echo " Key already exists — skipping generation (delete $keyfile to regenerate)"
52
+ else
53
+ ssh-keygen -t ed25519 -C "sentinel@${repo}" -f "$keyfile" -N "" -q
54
+ echo " Key generated."
55
+ fi
56
+
57
+ # Add SSH config block (skip if Host already configured)
58
+ if ! grep -q "Host github-${repo}" "$SSH_DIR/config" 2>/dev/null; then
59
+ cat >> "$SSH_DIR/config" << EOF
60
+
61
+ Host github-${repo}
62
+ HostName github.com
63
+ User git
64
+ IdentityFile ${keyfile}
65
+ IdentitiesOnly yes
66
+ EOF
67
+ echo " SSH config block added."
68
+ else
69
+ echo " SSH config block already exists — skipping."
70
+ fi
71
+
72
+ echo ""
73
+ echo " ┌─ Add this deploy key to GitHub ─────────────────────────────────────┐"
74
+ echo " │ $repo_path → Settings → Deploy keys → Add deploy key"
75
+ echo " │ Title: sentinel@$(hostname)"
76
+ echo " │ Allow write access: ✓"
77
+ echo " └──────────────────────────────────────────────────────────────────────┘"
78
+ echo ""
79
+ cat "$keyfile.pub"
80
+ done
81
+
82
+ echo ""
83
+ echo "══════════════════════════════════════════════"
84
+ echo "Done. After adding keys on GitHub, test each:"
85
+ for repo_path in "$@"; do
86
+ repo="${repo_path##*/}"
87
+ echo " ssh -T github-${repo}"
88
+ done
89
+ echo ""
90
+ echo "Use SSH aliases in sentinel add:"
91
+ for repo_path in "$@"; do
92
+ repo="${repo_path##*/}"
93
+ echo " sentinel add git@github-${repo}:${repo_path}.git"
94
+ done
@@ -0,0 +1,63 @@
1
+ #!/bin/bash
2
+ # Usage: ./setup_deploy_keys.sh repo1 repo2 ...
3
+ # Generates one deploy key per repo, prints each public key for GitHub.
4
+
5
+ set -e
6
+
7
+ if [[ $# -eq 0 ]]; then
8
+ echo "Usage: $0 repo1 repo2 ..."
9
+ exit 1
10
+ fi
11
+
12
+ SSH_DIR="$HOME/.ssh"
13
+
14
+ # Wipe known state
15
+ echo "Cleaning ~/.ssh ..."
16
+ rm -f "$SSH_DIR"/known_hosts "$SSH_DIR"/known_hosts.old
17
+ for f in "$SSH_DIR"/*.pub "$SSH_DIR"/config; do
18
+ [[ -f "$f" ]] && rm -f "$f" "${f%.pub}"
19
+ done
20
+ rm -f "$SSH_DIR"/config
21
+
22
+ # Re-fetch GitHub host key once
23
+ ssh-keyscan github.com >> "$SSH_DIR/known_hosts" 2>/dev/null
24
+ echo "GitHub host key added."
25
+ echo ""
26
+
27
+ # Fresh SSH config
28
+ CONFIG="$SSH_DIR/config"
29
+
30
+ for repo in "$@"; do
31
+ keyfile="$SSH_DIR/$repo"
32
+
33
+ echo "──────────────────────────────────────────"
34
+ echo "Repo: $repo"
35
+
36
+ # Generate key
37
+ ssh-keygen -t ed25519 -C "sentinel@$repo" -f "$keyfile" -N "" -q
38
+
39
+ # Append SSH config block
40
+ cat >> "$CONFIG" << EOF
41
+
42
+ Host github-$repo
43
+ HostName github.com
44
+ User git
45
+ IdentityFile $keyfile
46
+ IdentitiesOnly yes
47
+ EOF
48
+
49
+ echo ""
50
+ echo "Deploy key for: github.com/Opplysningen1881/$repo"
51
+ echo "→ GitHub: Settings → Deploy keys → Add deploy key (allow write access)"
52
+ echo ""
53
+ cat "$keyfile.pub"
54
+ echo ""
55
+ done
56
+
57
+ chmod 600 "$CONFIG"
58
+
59
+ echo "──────────────────────────────────────────"
60
+ echo "Done. After adding keys on GitHub, test with:"
61
+ for repo in "$@"; do
62
+ echo " ssh -T github-$repo"
63
+ done