@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 +1 -1
- package/.cairn/session.json +2 -2
- package/lib/add.js +241 -63
- package/lib/generate.js +10 -1
- 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: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
|
|
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,65 @@ 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.');
|
|
364
|
+
warn('AUTO_PUBLISH=true: fixes push directly to main. Ensure CI blocks bad pushes.');
|
|
225
365
|
}
|
|
226
366
|
|
|
227
|
-
// ──
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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 (
|
|
245
|
-
|
|
246
|
-
console.
|
|
247
|
-
|
|
248
|
-
console.
|
|
249
|
-
console.
|
|
250
|
-
console.
|
|
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
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
// ──
|
|
436
|
+
// ── Write project files ────────────────────────────────────────────────────
|
|
283
437
|
const codeDir = requireCodeDir(workspace);
|
|
284
438
|
const pythonBin = path.join(codeDir, '.venv', 'bin', 'python3');
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
@@ -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:
|