@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 +1 -1
- package/.cairn/session.json +2 -2
- package/lib/add.js +200 -67
- package/package.json +1 -1
- package/python/sentinel/config_loader.py +2 -0
- package/python/sentinel/git_manager.py +4 -2
package/.cairn/.hint-lock
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-03-
|
|
1
|
+
2026-03-23T17:50:08.240Z
|
package/.cairn/session.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"message": "Auto-checkpoint at 2026-03-
|
|
3
|
-
"checkpoint_at": "2026-03-
|
|
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
|
|
196
|
-
step(
|
|
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
|
|
199
|
-
const sshUrl = `git@${sshHost}:${orgRepo}.git`;
|
|
238
|
+
const { keyFile } = generateDeployKey(repoSlug);
|
|
200
239
|
printDeployKeyInstructions(orgRepo, keyFile);
|
|
201
240
|
|
|
202
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
// ──
|
|
391
|
+
// ── Write project files ────────────────────────────────────────────────────
|
|
283
392
|
const codeDir = requireCodeDir(workspace);
|
|
284
393
|
const pythonBin = path.join(codeDir, '.venv', 'bin', 'python3');
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
@@ -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
|
-
|
|
41
|
-
|
|
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:
|