@misterhuydo/sentinel 1.3.0 → 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/.cairn/.hint-lock CHANGED
@@ -1 +1 @@
1
- 2026-03-23T11:43:23.881Z
1
+ 2026-03-23T17:50:08.240Z
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-23T12:04:39.918Z",
3
- "checkpoint_at": "2026-03-23T12:04:39.919Z",
2
+ "message": "Auto-checkpoint at 2026-03-23T17:39:48.232Z",
3
+ "checkpoint_at": "2026-03-23T17:39:48.233Z",
4
4
  "active_files": [],
5
5
  "notes": [],
6
6
  "mtime_snapshot": {}
package/lib/add.js CHANGED
@@ -175,14 +175,54 @@ function printDeployKeyInstructions(orgRepo, keyFile) {
175
175
  console.log('');
176
176
  }
177
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
+
178
221
  // ── addFromGit ────────────────────────────────────────────────────────────────
179
222
 
180
223
  async function addFromGit(gitUrl, workspace) {
181
224
  const repoSlug = gitUrl.replace(/\.git$/, '').split(/[:/]/).pop();
182
- const orgRepo = gitUrl
183
- .replace(/^git@github\.com:/, '')
184
- .replace(/^https:\/\/github\.com\//, '')
185
- .replace(/\.git$/, '');
225
+ const orgRepo = gitUrlToOrgRepo(gitUrl);
186
226
 
187
227
  const { name } = await prompts([{
188
228
  type: 'text',
@@ -192,19 +232,120 @@ async function addFromGit(gitUrl, workspace) {
192
232
  validate: v => VALID_NAME.test(v) || 'Use letters, numbers, hyphens only',
193
233
  }], { onCancel: () => process.exit(0) });
194
234
 
195
- // ── 1. Generate SSH deploy key ──────────────────────────────────────────────
196
- step('Setting up SSH deploy key');
235
+ // ── 1. Generate deploy key for the primary (config) repo ───────────────────
236
+ step(`[1/3] Setting up SSH access to ${repoSlug}`);
197
237
  ensureKnownHosts();
198
- const { keyFile, sshHost } = generateDeployKey(repoSlug);
199
- const sshUrl = `git@${sshHost}:${orgRepo}.git`;
238
+ const { keyFile } = generateDeployKey(repoSlug);
200
239
  printDeployKeyInstructions(orgRepo, keyFile);
201
240
 
202
- // ── 2. Fix deployment mode ──────────────────────────────────────────────────
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 ────────────────────────────────────────────────────
203
344
  const { autoPublish } = await prompts({
204
345
  type: 'select',
205
346
  name: 'autoPublish',
206
347
  message: 'How should Sentinel deploy fixes?',
207
- hint: 'You can change this later in config/repo-configs/',
348
+ hint: 'You can change this per-repo in config/repo-configs/',
208
349
  choices: [
209
350
  {
210
351
  title: 'Open a PR for each fix (AUTO_PUBLISH=false) — recommended',
@@ -220,52 +361,20 @@ async function addFromGit(gitUrl, workspace) {
220
361
  }, { onCancel: () => process.exit(0) });
221
362
 
222
363
  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);
364
+ warn('AUTO_PUBLISH=true: fixes push directly to main. Ensure CI blocks bad pushes.');
252
365
  }
253
366
 
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 ────────────────────────────────────────────────────
367
+ // ── Preview + confirm ──────────────────────────────────────────────────────
260
368
  const projectDir = path.join(workspace, name);
261
- const localPath = path.join(workspace, 'repos', repoSlug);
262
369
 
263
370
  step('Dry-run preview');
264
371
  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}`);
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)`);
269
378
  info(' init.sh, start.sh, stop.sh');
270
379
 
271
380
  const { confirm } = await prompts({
@@ -274,29 +383,53 @@ async function addFromGit(gitUrl, workspace) {
274
383
  }, { onCancel: () => process.exit(0) });
275
384
  if (!confirm) { info('Aborted.'); return; }
276
385
 
277
- if (fs.existsSync(projectDir)) {
386
+ if (fs.existsSync(projectDir) && projectDir !== localPath) {
278
387
  console.error(chalk.yellow(`Project "${name}" already exists at ${projectDir}`));
279
388
  process.exit(1);
280
389
  }
281
390
 
282
- // ── 6. Write files ──────────────────────────────────────────────────────────
391
+ // ── Write project files ────────────────────────────────────────────────────
283
392
  const codeDir = requireCodeDir(workspace);
284
393
  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);
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
+ }
300
433
  }
301
434
 
302
435
  // ── addFromName ───────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -106,6 +106,7 @@ class RepoConfig:
106
106
  cicd_job_url: str = ""
107
107
  health_url: str = "" # optional: HTTP endpoint returning {"Status": "true"}
108
108
  cicd_token: str = ""
109
+ ssh_key_file: str = "" # path to SSH private key; sets GIT_SSH_COMMAND when present
109
110
 
110
111
 
111
112
  # ── Loader ────────────────────────────────────────────────────────────────────
@@ -224,6 +225,7 @@ class ConfigLoader:
224
225
  r.cicd_job_url = d.get("CICD_JOB_URL", "")
225
226
  r.cicd_token = d.get("CICD_TOKEN", "")
226
227
  r.health_url = d.get("HEALTH_URL", "")
228
+ r.ssh_key_file = os.path.expanduser(d.get("SSH_KEY_FILE", ""))
227
229
  self.repos[r.repo_name] = r
228
230
 
229
231
  def _register_sighup(self):
@@ -37,8 +37,10 @@ def _git(args: list[str], cwd: str, env: dict | None = None, timeout: int = GIT_
37
37
 
38
38
 
39
39
  def _git_env(repo: RepoConfig) -> dict:
40
- # GIT_SSH_COMMAND can be set externally for SSH-based repos
41
- return os.environ.copy()
40
+ env = os.environ.copy()
41
+ if repo.ssh_key_file:
42
+ env["GIT_SSH_COMMAND"] = f"ssh -i {repo.ssh_key_file} -o StrictHostKeyChecking=no -o BatchMode=yes"
43
+ return env
42
44
 
43
45
 
44
46
  def _check_protected_paths(patch_path: Path) -> bool: