@misterhuydo/sentinel 1.3.0 → 1.3.2

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:56:42.485Z",
3
+ "checkpoint_at": "2026-03-23T17:56:42.486Z",
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,65 @@ 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.');
364
+ warn('AUTO_PUBLISH=true: fixes push directly to main. Ensure CI blocks bad pushes.');
225
365
  }
226
366
 
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
- });
367
+ // ── GitHub token ───────────────────────────────────────────────────────────
368
+ // AUTO_PUBLISH=false → required (open PRs via API)
369
+ // AUTO_PUBLISH=true → optional (CI/CD triggers only)
370
+ const workspaceProps = path.join(workspace, 'sentinel.properties');
371
+ const existingToken = fs.existsSync(workspaceProps)
372
+ ? (fs.readFileSync(workspaceProps, 'utf8').match(/^GITHUB_TOKEN\s*=\s*(.+)$/m) || [])[1]?.trim()
373
+ : '';
243
374
 
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);
375
+ if (!autoPublish) {
376
+ // PR mode token is required
377
+ console.log('');
378
+ console.log(chalk.bold(' GitHub Personal Access Token (classic) — required for opening PRs'));
379
+ console.log(chalk.cyan(' github.com/settings/tokens/new → Tokens (classic)'));
380
+ console.log(chalk.cyan(' Note: "Expiration No expiration" Scope: repo'));
381
+ console.log('');
252
382
  }
253
383
 
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})`);
384
+ const { githubToken } = await prompts({
385
+ type: (!autoPublish || !existingToken) ? 'password' : null,
386
+ name: 'githubToken',
387
+ message: existingToken
388
+ ? 'GitHub token (press Enter to keep current)'
389
+ : autoPublish
390
+ ? 'GitHub token (classic, repo scope) — optional, press Enter to skip'
391
+ : 'GitHub token (classic, repo scope)',
392
+ validate: v => {
393
+ if (!autoPublish && !v && !existingToken) return 'Token is required for PR mode';
394
+ if (v && !v.startsWith('ghp_') && !v.startsWith('github_pat_')) return 'Should start with ghp_ or github_pat_';
395
+ return true;
396
+ },
397
+ }, { onCancel: () => process.exit(0) });
398
+
399
+ const effectiveToken = githubToken || existingToken || '';
400
+ if (effectiveToken && fs.existsSync(workspaceProps)) {
401
+ let props = fs.readFileSync(workspaceProps, 'utf8');
402
+ if (/^#?\s*GITHUB_TOKEN\s*=/m.test(props))
403
+ props = props.replace(/^#?\s*GITHUB_TOKEN\s*=.*/m, `GITHUB_TOKEN=${effectiveToken}`);
404
+ else
405
+ props = props.trimEnd() + `\nGITHUB_TOKEN=${effectiveToken}\n`;
406
+ fs.writeFileSync(workspaceProps, props);
407
+ ok('GITHUB_TOKEN saved to workspace sentinel.properties');
408
+ } else if (effectiveToken) {
409
+ info('GITHUB_TOKEN will be written when project files are created');
410
+ }
258
411
 
259
- // ── 5. Preview + confirm ────────────────────────────────────────────────────
412
+ // ── Preview + confirm ──────────────────────────────────────────────────────
260
413
  const projectDir = path.join(workspace, name);
261
- const localPath = path.join(workspace, 'repos', repoSlug);
262
414
 
263
415
  step('Dry-run preview');
264
416
  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}`);
417
+ if (discovered.length > 0) {
418
+ info(` Using ${discovered.length} repo-config(s) from ${repoSlug}`);
419
+ } else {
420
+ info(` config/repo-configs/${repoSlug}.properties`);
421
+ }
422
+ info(` AUTO_PUBLISH=${autoPublish} (applies to all repos without an explicit setting)`);
269
423
  info(' init.sh, start.sh, stop.sh');
270
424
 
271
425
  const { confirm } = await prompts({
@@ -274,29 +428,53 @@ async function addFromGit(gitUrl, workspace) {
274
428
  }, { onCancel: () => process.exit(0) });
275
429
  if (!confirm) { info('Aborted.'); return; }
276
430
 
277
- if (fs.existsSync(projectDir)) {
431
+ if (fs.existsSync(projectDir) && projectDir !== localPath) {
278
432
  console.error(chalk.yellow(`Project "${name}" already exists at ${projectDir}`));
279
433
  process.exit(1);
280
434
  }
281
435
 
282
- // ── 6. Write files ──────────────────────────────────────────────────────────
436
+ // ── Write project files ────────────────────────────────────────────────────
283
437
  const codeDir = requireCodeDir(workspace);
284
438
  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);
439
+
440
+ if (discovered.length > 0) {
441
+ // Config already exists in the cloned repo — just generate scripts
442
+ generateProjectScripts(localPath, codeDir, pythonBin);
443
+ // Write SSH_KEY_FILE for primary repo itself
444
+ const primaryProps = path.join(localPath, 'config', 'repo-configs', `${repoSlug}.properties`);
445
+ if (!fs.existsSync(primaryProps)) {
446
+ writePropertiesFile(primaryProps, {
447
+ REPO_NAME: repoSlug,
448
+ REPO_URL: gitUrl,
449
+ LOCAL_PATH: localPath,
450
+ BRANCH: 'main',
451
+ AUTO_PUBLISH: autoPublish ? 'true' : 'false',
452
+ SSH_KEY_FILE: keyFile,
453
+ CAIRN_MCP_ENABLED: 'true',
454
+ });
455
+ }
456
+ ok(`Project "${name}" ready at ${localPath}`);
457
+ printNextSteps(localPath, autoPublish);
458
+ } else {
459
+ // No existing repo-configs — scaffold fresh project
460
+ fs.ensureDirSync(projectDir);
461
+ writeExampleProject(projectDir, codeDir, pythonBin);
462
+ const repoDir = path.join(projectDir, 'config', 'repo-configs');
463
+ writePropertiesFile(path.join(repoDir, `${repoSlug}.properties`), {
464
+ REPO_NAME: repoSlug,
465
+ REPO_URL: gitUrl,
466
+ LOCAL_PATH: localPath,
467
+ BRANCH: 'main',
468
+ AUTO_PUBLISH: autoPublish ? 'true' : 'false',
469
+ SSH_KEY_FILE: keyFile,
470
+ CAIRN_MCP_ENABLED: 'true',
471
+ });
472
+ const example = path.join(repoDir, '_example.properties');
473
+ if (fs.existsSync(example)) fs.removeSync(example);
474
+ generateWorkspaceScripts(workspace, {}, {}, {}, effectiveToken);
475
+ ok(`Project "${name}" created at ${projectDir}`);
476
+ printNextSteps(projectDir, autoPublish);
477
+ }
300
478
  }
301
479
 
302
480
  // ── addFromName ───────────────────────────────────────────────────────────────
package/lib/generate.js CHANGED
@@ -96,7 +96,7 @@ rm -f "$PID_FILE"
96
96
 
97
97
  // ── Workspace-level startAll / stopAll ────────────────────────────────────────
98
98
 
99
- function generateWorkspaceScripts(workspace, smtpConfig = {}, slackConfig = {}, authConfig = {}) {
99
+ function generateWorkspaceScripts(workspace, smtpConfig = {}, slackConfig = {}, authConfig = {}, githubToken = '') {
100
100
  // Write shared sentinel.properties once (never overwrite existing)
101
101
  const workspaceProps = path.join(workspace, 'sentinel.properties');
102
102
  if (!fs.existsSync(workspaceProps)) {
@@ -126,6 +126,15 @@ function generateWorkspaceScripts(workspace, smtpConfig = {}, slackConfig = {},
126
126
  }
127
127
  fs.writeFileSync(workspaceProps, props);
128
128
  }
129
+ // Always upsert GitHub token so re-runs persist it
130
+ if (githubToken) {
131
+ let props = fs.readFileSync(workspaceProps, 'utf8');
132
+ if (/^#?\s*GITHUB_TOKEN=/m.test(props))
133
+ props = props.replace(/^#?\s*GITHUB_TOKEN=.*/mg, 'GITHUB_TOKEN=' + githubToken);
134
+ else
135
+ props = props.trimEnd() + '\nGITHUB_TOKEN=' + githubToken + '\n';
136
+ fs.writeFileSync(workspaceProps, props);
137
+ }
129
138
  // Always upsert Slack tokens so re-runs persist them
130
139
  if (slackConfig.botToken || slackConfig.appToken) {
131
140
  let props = fs.readFileSync(workspaceProps, 'utf8');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
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: