@misterhuydo/sentinel 1.2.9 → 1.3.1

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 CHANGED
@@ -1,415 +1,603 @@
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
+ // ── repo discovery helpers ────────────────────────────────────────────────────
179
+
180
+ function gitUrlToOrgRepo(gitUrl) {
181
+ return gitUrl
182
+ .replace(/^git@github\.com:/, '')
183
+ .replace(/^https?:\/\/github\.com\//, '')
184
+ .replace(/\.git$/, '');
185
+ }
186
+
187
+ function toHttpsUrl(gitUrl) {
188
+ return 'https://github.com/' + gitUrlToOrgRepo(gitUrl) + '.git';
189
+ }
190
+
191
+ function isPublicRepo(gitUrl) {
192
+ const r = spawnSync('git', ['ls-remote', '--heads', toHttpsUrl(gitUrl)], {
193
+ encoding: 'utf8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'],
194
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
195
+ });
196
+ return r.status === 0;
197
+ }
198
+
199
+ function validateAccess(repoUrl, keyFile) {
200
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
201
+ if (keyFile) env.GIT_SSH_COMMAND = `ssh -i ${keyFile} -o StrictHostKeyChecking=no -o BatchMode=yes`;
202
+ const r = spawnSync('git', ['ls-remote', '--heads', repoUrl], {
203
+ encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'], env,
204
+ });
205
+ return { ok: r.status === 0, stderr: (r.stderr || r.error?.message || '').trim() };
206
+ }
207
+
208
+ function discoverReposFromClone(cloneDir) {
209
+ const repoCfgDir = path.join(cloneDir, 'config', 'repo-configs');
210
+ if (!fs.existsSync(repoCfgDir)) return [];
211
+ return fs.readdirSync(repoCfgDir)
212
+ .filter(f => f.endsWith('.properties') && !f.startsWith('_'))
213
+ .map(f => {
214
+ const content = fs.readFileSync(path.join(repoCfgDir, f), 'utf8');
215
+ const match = content.match(/^REPO_URL\s*=\s*(.+)$/m);
216
+ return match ? { file: f, propsPath: path.join(repoCfgDir, f), url: match[1].trim() } : null;
217
+ })
218
+ .filter(Boolean);
219
+ }
220
+
221
+ // ── addFromGit ────────────────────────────────────────────────────────────────
222
+
223
+ async function addFromGit(gitUrl, workspace) {
224
+ const repoSlug = gitUrl.replace(/\.git$/, '').split(/[:/]/).pop();
225
+ const orgRepo = gitUrlToOrgRepo(gitUrl);
226
+
227
+ const { name } = await prompts([{
228
+ type: 'text',
229
+ name: 'name',
230
+ message: 'Project name',
231
+ initial: repoSlug,
232
+ validate: v => VALID_NAME.test(v) || 'Use letters, numbers, hyphens only',
233
+ }], { onCancel: () => process.exit(0) });
234
+
235
+ // ── 1. Generate deploy key for the primary (config) repo ───────────────────
236
+ step(`[1/3] Setting up SSH access to ${repoSlug}`);
237
+ ensureKnownHosts();
238
+ const { keyFile } = generateDeployKey(repoSlug);
239
+ printDeployKeyInstructions(orgRepo, keyFile);
240
+
241
+ await prompts({
242
+ type: 'text', name: '_', format: () => '',
243
+ message: chalk.bold(`Press Enter once you've added the deploy key to GitHub…`),
244
+ }, { onCancel: () => process.exit(0) });
245
+
246
+ // Validate primary repo
247
+ const primary = validateAccess(gitUrl, keyFile);
248
+ if (!primary.ok) {
249
+ console.error(chalk.red(' ✖ Cannot reach ' + gitUrl));
250
+ if (primary.stderr) console.error(chalk.red(' ' + primary.stderr));
251
+ console.error(chalk.yellow(' Check the deploy key has write access, then re-run.'));
252
+ process.exit(1);
253
+ }
254
+ ok(`${repoSlug}: reachable`);
255
+
256
+ // ── 2. Clone primary repo and discover additional repos ────────────────────
257
+ const localPath = path.join(workspace, 'repos', repoSlug);
258
+ step(`[2/3] Scanning repo-configs in ${repoSlug}…`);
259
+
260
+ if (!fs.existsSync(localPath)) {
261
+ const cloneEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0',
262
+ GIT_SSH_COMMAND: `ssh -i ${keyFile} -o StrictHostKeyChecking=no -o BatchMode=yes` };
263
+ spawnSync('git', ['clone', '--depth', '1', gitUrl, localPath],
264
+ { stdio: 'inherit', env: cloneEnv });
265
+ }
266
+
267
+ const discovered = discoverReposFromClone(localPath);
268
+
269
+ // Classify each discovered repo
270
+ const privateRepos = [];
271
+ const publicRepos = [];
272
+
273
+ if (discovered.length === 0) {
274
+ info('No repo-configs found — project will use example config.');
275
+ } else {
276
+ info(`Found ${discovered.length} repo(s) in config/repo-configs/:`);
277
+ for (const r of discovered) {
278
+ const slug = r.file.replace('.properties', '');
279
+ const pub = isPublicRepo(r.url);
280
+ const tag = pub ? chalk.green('[public]') : chalk.yellow('[private]');
281
+ console.log(` ${tag} ${slug.padEnd(36)} ${r.url}`);
282
+ if (pub) publicRepos.push({ ...r, slug });
283
+ else privateRepos.push({ ...r, slug });
284
+ }
285
+ }
286
+
287
+ // ── 3. Generate deploy keys for all private repos (batch) ─────────────────
288
+ if (privateRepos.length > 0) {
289
+ step(`[3/3] Deploy keys needed for ${privateRepos.length} private repo(s)`);
290
+
291
+ for (const r of privateRepos) {
292
+ const { keyFile: rKey } = generateDeployKey(r.slug);
293
+ r.keyFile = rKey;
294
+ const rOrgRepo = gitUrlToOrgRepo(r.url);
295
+ printDeployKeyInstructions(rOrgRepo, rKey);
296
+ }
297
+
298
+ if (publicRepos.length > 0) {
299
+ console.log(chalk.green(' Public repos (no deploy key needed):'));
300
+ for (const r of publicRepos) {
301
+ console.log(chalk.green(` ${r.slug}`));
302
+ }
303
+ console.log('');
304
+ }
305
+
306
+ await prompts({
307
+ type: 'text', name: '_', format: () => '',
308
+ message: chalk.bold(`Press Enter once you've added all ${privateRepos.length} deploy key(s) to GitHub…`),
309
+ }, { onCancel: () => process.exit(0) });
310
+
311
+ // Validate each private repo
312
+ step('Validating repository access…');
313
+ for (const r of privateRepos) {
314
+ const v = validateAccess(r.url, r.keyFile);
315
+ if (!v.ok) {
316
+ console.error(chalk.red(` ✖ ${r.slug}: cannot reach ${r.url}`));
317
+ if (v.stderr) console.error(chalk.red(' ' + v.stderr));
318
+ console.error(chalk.yellow(' Fix access then re-run sentinel add.'));
319
+ process.exit(1);
320
+ }
321
+ ok(`${r.slug}: reachable`);
322
+ }
323
+ for (const r of publicRepos) {
324
+ ok(`${r.slug}: public, no key needed`);
325
+ }
326
+
327
+ // Write SSH_KEY_FILE into each private repo's .properties file
328
+ for (const r of privateRepos) {
329
+ let props = fs.readFileSync(r.propsPath, 'utf8');
330
+ if (/^#?\s*SSH_KEY_FILE\s*=/m.test(props)) {
331
+ props = props.replace(/^#?\s*SSH_KEY_FILE\s*=.*/m, `SSH_KEY_FILE=${r.keyFile}`);
332
+ } else {
333
+ props = props.trimEnd() + `\nSSH_KEY_FILE=${r.keyFile}\n`;
334
+ }
335
+ fs.writeFileSync(r.propsPath, props);
336
+ info(`SSH_KEY_FILE written to config/repo-configs/${r.file}`);
337
+ }
338
+ } else if (discovered.length > 0) {
339
+ step('[3/3] All repos are public — no deploy keys needed');
340
+ ok('All repos accessible without auth');
341
+ }
342
+
343
+ // ── Fix deployment mode ────────────────────────────────────────────────────
344
+ const { autoPublish } = await prompts({
345
+ type: 'select',
346
+ name: 'autoPublish',
347
+ message: 'How should Sentinel deploy fixes?',
348
+ hint: 'You can change this per-repo in config/repo-configs/',
349
+ choices: [
350
+ {
351
+ title: 'Open a PR for each fix (AUTO_PUBLISH=false) — recommended',
352
+ description: 'Sentinel pushes to a branch and opens a GitHub PR. You review and merge.',
353
+ value: false,
354
+ },
355
+ {
356
+ title: 'Push directly to main (AUTO_PUBLISH=true) — fully autonomous',
357
+ description: 'Sentinel commits and pushes fixes straight to your main branch.',
358
+ value: true,
359
+ },
360
+ ],
361
+ }, { onCancel: () => process.exit(0) });
362
+
363
+ if (autoPublish) {
364
+ warn('AUTO_PUBLISH=true: fixes push directly to main. Ensure CI blocks bad pushes.');
365
+ }
366
+
367
+ // ── Preview + confirm ──────────────────────────────────────────────────────
368
+ const projectDir = path.join(workspace, name);
369
+
370
+ step('Dry-run preview');
371
+ info(`Will create: ${projectDir}/`);
372
+ if (discovered.length > 0) {
373
+ info(` Using ${discovered.length} repo-config(s) from ${repoSlug}`);
374
+ } else {
375
+ info(` config/repo-configs/${repoSlug}.properties`);
376
+ }
377
+ info(` AUTO_PUBLISH=${autoPublish} (applies to all repos without an explicit setting)`);
378
+ info(' init.sh, start.sh, stop.sh');
379
+
380
+ const { confirm } = await prompts({
381
+ type: 'confirm', name: 'confirm',
382
+ message: `Create project "${name}"?`, initial: true,
383
+ }, { onCancel: () => process.exit(0) });
384
+ if (!confirm) { info('Aborted.'); return; }
385
+
386
+ if (fs.existsSync(projectDir) && projectDir !== localPath) {
387
+ console.error(chalk.yellow(`Project "${name}" already exists at ${projectDir}`));
388
+ process.exit(1);
389
+ }
390
+
391
+ // ── Write project files ────────────────────────────────────────────────────
392
+ const codeDir = requireCodeDir(workspace);
393
+ const pythonBin = path.join(codeDir, '.venv', 'bin', 'python3');
394
+
395
+ if (discovered.length > 0) {
396
+ // Config already exists in the cloned repo — just generate scripts
397
+ generateProjectScripts(localPath, codeDir, pythonBin);
398
+ // Write SSH_KEY_FILE for primary repo itself
399
+ const primaryProps = path.join(localPath, 'config', 'repo-configs', `${repoSlug}.properties`);
400
+ if (!fs.existsSync(primaryProps)) {
401
+ writePropertiesFile(primaryProps, {
402
+ REPO_NAME: repoSlug,
403
+ REPO_URL: gitUrl,
404
+ LOCAL_PATH: localPath,
405
+ BRANCH: 'main',
406
+ AUTO_PUBLISH: autoPublish ? 'true' : 'false',
407
+ SSH_KEY_FILE: keyFile,
408
+ CAIRN_MCP_ENABLED: 'true',
409
+ });
410
+ }
411
+ ok(`Project "${name}" ready at ${localPath}`);
412
+ printNextSteps(localPath, autoPublish);
413
+ } else {
414
+ // No existing repo-configs — scaffold fresh project
415
+ fs.ensureDirSync(projectDir);
416
+ writeExampleProject(projectDir, codeDir, pythonBin);
417
+ const repoDir = path.join(projectDir, 'config', 'repo-configs');
418
+ writePropertiesFile(path.join(repoDir, `${repoSlug}.properties`), {
419
+ REPO_NAME: repoSlug,
420
+ REPO_URL: gitUrl,
421
+ LOCAL_PATH: localPath,
422
+ BRANCH: 'main',
423
+ AUTO_PUBLISH: autoPublish ? 'true' : 'false',
424
+ SSH_KEY_FILE: keyFile,
425
+ CAIRN_MCP_ENABLED: 'true',
426
+ });
427
+ const example = path.join(repoDir, '_example.properties');
428
+ if (fs.existsSync(example)) fs.removeSync(example);
429
+ generateWorkspaceScripts(workspace);
430
+ ok(`Project "${name}" created at ${projectDir}`);
431
+ printNextSteps(projectDir, autoPublish);
432
+ }
433
+ }
434
+
435
+ // ── addFromName ───────────────────────────────────────────────────────────────
436
+
437
+ async function addFromName(nameArg, workspace) {
438
+ const answers = await prompts([{
439
+ type: 'text',
440
+ name: 'name',
441
+ message: 'Project name',
442
+ initial: nameArg || 'my-project',
443
+ validate: v => VALID_NAME.test(v) || 'Use letters, numbers, hyphens only',
444
+ }], { onCancel: () => process.exit(0) });
445
+ const { name } = answers;
446
+ const projectDir = path.join(workspace, name);
447
+ step('Dry-run preview');
448
+ info(`Will create: ${projectDir}/`);
449
+ info(' config/sentinel.properties');
450
+ info(' config/repo-configs/_example.properties');
451
+ info(' config/log-configs/_example.properties');
452
+ info(' init.sh, start.sh, stop.sh');
453
+ const { confirm } = await prompts({
454
+ type: 'confirm', name: 'confirm',
455
+ message: `Create project "${name}"?`, initial: true,
456
+ }, { onCancel: () => process.exit(0) });
457
+ if (!confirm) { info('Aborted.'); return; }
458
+ if (fs.existsSync(projectDir)) {
459
+ console.error(chalk.yellow(`Project "${name}" already exists at ${projectDir}`));
460
+ process.exit(1);
461
+ }
462
+ const codeDir = requireCodeDir(workspace);
463
+ const pythonBin = path.join(codeDir, '.venv', 'bin', 'python3');
464
+ writeExampleProject(projectDir, codeDir, pythonBin);
465
+ generateWorkspaceScripts(workspace);
466
+ ok(`Project "${name}" created at ${projectDir}`);
467
+ printNextSteps(projectDir);
468
+ }
469
+
470
+ // ── addFromJson ───────────────────────────────────────────────────────────────
471
+
472
+ async function addFromJson(jsonPath, workspace) {
473
+ step(`Reading ${jsonPath}`);
474
+ let obj;
475
+ try {
476
+ obj = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
477
+ } catch (e) {
478
+ console.error(chalk.red(` ✖ Cannot parse ${jsonPath}: ${e.message}`));
479
+ process.exit(1);
480
+ }
481
+ const errors = validateProjectJson(obj);
482
+ if (errors.length) {
483
+ console.error(chalk.red(' ✖ Invalid project JSON:'));
484
+ errors.forEach(e => console.error(chalk.red(` - ${e}`)));
485
+ process.exit(1);
486
+ }
487
+ ok('JSON is valid');
488
+ const { name } = obj;
489
+ const projectDir = path.join(workspace, name);
490
+ step('Dry-run preview');
491
+ info(`Will create: ${projectDir}/`);
492
+ (obj.repos || []).forEach(r => info(` config/repo-configs/${r.name}.properties (${r.REPO_URL})`));
493
+ (obj.log_sources || []).forEach(s => info(` config/log-configs/${s.name}.properties (${s.SOURCE_TYPE})`));
494
+ if (obj.sentinel) {
495
+ Object.entries(obj.sentinel).forEach(([k, v]) => info(` sentinel.properties: ${k}=${v}`));
496
+ }
497
+ const { confirm } = await prompts({
498
+ type: 'confirm', name: 'confirm',
499
+ message: `Create project "${name}" from ${path.basename(jsonPath)}?`, initial: true,
500
+ }, { onCancel: () => process.exit(0) });
501
+ if (!confirm) { info('Aborted.'); return; }
502
+ if (fs.existsSync(projectDir)) {
503
+ console.error(chalk.yellow(`Project "${name}" already exists at ${projectDir}`));
504
+ process.exit(1);
505
+ }
506
+ const codeDir = requireCodeDir(workspace);
507
+ const pythonBin = path.join(codeDir, '.venv', 'bin', 'python3');
508
+ writeExampleProject(projectDir, codeDir, pythonBin);
509
+ const repoDir = path.join(projectDir, 'config', 'repo-configs');
510
+ const logDir = path.join(projectDir, 'config', 'log-configs');
511
+ if (fs.existsSync(path.join(repoDir, '_example.properties'))) fs.removeSync(path.join(repoDir, '_example.properties'));
512
+ if (fs.existsSync(path.join(logDir, '_example.properties'))) fs.removeSync(path.join(logDir, '_example.properties'));
513
+ applyJsonToProject(projectDir, obj);
514
+ generateWorkspaceScripts(workspace);
515
+ ok(`Project "${name}" created at ${projectDir}`);
516
+ printNextSteps(projectDir);
517
+ }
518
+
519
+ // ── addFromUrl ────────────────────────────────────────────────────────────────
520
+
521
+ async function addFromUrl(url, workspace) {
522
+ step(`Fetching ${url}`);
523
+ let raw;
524
+ try {
525
+ raw = fetchUrl(url);
526
+ if (raw && typeof raw.then === 'function') raw = await raw;
527
+ } catch (e) {
528
+ console.error(chalk.red(` ✖ Cannot fetch ${url}: ${e.message}`));
529
+ process.exit(1);
530
+ }
531
+ let obj;
532
+ try {
533
+ obj = JSON.parse(raw);
534
+ } catch (e) {
535
+ console.error(chalk.red(` ✖ Response is not valid JSON: ${e.message}`));
536
+ process.exit(1);
537
+ }
538
+ const errors = validateProjectJson(obj);
539
+ if (errors.length) {
540
+ console.error(chalk.red(' ✖ Invalid project JSON at URL:'));
541
+ errors.forEach(e => console.error(chalk.red(` - ${e}`)));
542
+ process.exit(1);
543
+ }
544
+ ok('JSON is valid');
545
+ const { name } = obj;
546
+ const projectDir = path.join(workspace, name);
547
+ step('Dry-run preview');
548
+ info(`Will create: ${projectDir}/`);
549
+ (obj.repos || []).forEach(r => info(` config/repo-configs/${r.name}.properties (${r.REPO_URL})`));
550
+ (obj.log_sources || []).forEach(s => info(` config/log-configs/${s.name}.properties (${s.SOURCE_TYPE})`));
551
+ if (obj.sentinel) {
552
+ Object.entries(obj.sentinel).forEach(([k, v]) => info(` sentinel.properties: ${k}=${v}`));
553
+ }
554
+ const { confirm } = await prompts({
555
+ type: 'confirm', name: 'confirm',
556
+ message: `Create project "${name}" from ${url}?`, initial: true,
557
+ }, { onCancel: () => process.exit(0) });
558
+ if (!confirm) { info('Aborted.'); return; }
559
+ if (fs.existsSync(projectDir)) {
560
+ console.error(chalk.yellow(`Project "${name}" already exists at ${projectDir}`));
561
+ process.exit(1);
562
+ }
563
+ const codeDir = requireCodeDir(workspace);
564
+ const pythonBin = path.join(codeDir, '.venv', 'bin', 'python3');
565
+ writeExampleProject(projectDir, codeDir, pythonBin);
566
+ const repoDir = path.join(projectDir, 'config', 'repo-configs');
567
+ const logDir = path.join(projectDir, 'config', 'log-configs');
568
+ if (fs.existsSync(path.join(repoDir, '_example.properties'))) fs.removeSync(path.join(repoDir, '_example.properties'));
569
+ if (fs.existsSync(path.join(logDir, '_example.properties'))) fs.removeSync(path.join(logDir, '_example.properties'));
570
+ applyJsonToProject(projectDir, obj);
571
+ generateWorkspaceScripts(workspace);
572
+ ok(`Project "${name}" created at ${projectDir}`);
573
+ printNextSteps(projectDir);
574
+ }
575
+
576
+ // ── printNextSteps ────────────────────────────────────────────────────────────
577
+
578
+ function printNextSteps(projectDir, autoPublish) {
579
+ const mode = autoPublish === true
580
+ ? chalk.yellow(' ⚠ Fixes push directly to main — ensure CI blocks bad pushes')
581
+ : autoPublish === false
582
+ ? chalk.cyan(' → Sentinel will open a GitHub PR for each fix — review and merge at github.com')
583
+ : '';
584
+ console.log(`
585
+ Next steps:
586
+ 1. Edit config/log-configs/ to add your log sources
587
+ ${chalk.cyan(`${projectDir}/config/`)}
588
+ 2. Start Sentinel:
589
+ ${chalk.cyan(`${projectDir}/start.sh`)}
590
+ ${mode ? ' ' + mode : ''}
591
+ `);
592
+ }
593
+
594
+ // ── entry point ───────────────────────────────────────────────────────────────
595
+
596
+ module.exports = async function add(arg) {
597
+ const type = detectInputType(arg);
598
+ const workspace = await resolveWorkspace();
599
+ if (type === 'git') return addFromGit(arg, workspace);
600
+ if (type === 'url') return addFromUrl(arg, workspace);
601
+ if (type === 'json') return addFromJson(arg, workspace);
602
+ return addFromName(arg, workspace);
603
+ };