gh-legacy
Version:
A powerful GitHub CLI tool for automatic repository ownership transfer to trusted beneficiaries after periods of inactivity
198 lines (180 loc) ⢠7.77 kB
JavaScript
// commands/triggerAccess.js
const fs = require('fs');
const path = require('path');
const inquirer = require('inquirer').default;
const { Octokit } = require('@octokit/rest');
const CONFIG_PATH = path.join(__dirname, '../db.json');
const os = require('os');
const TOKEN_PATH = path.join(os.homedir(), '.gh-legacy', '.gh_token');
const { sendEmailNotification } = require('../lib/email');
const crypto = require('crypto');
const TRANSFERRED_PATH = path.join(os.homedir(), '.gh-legacy', 'transferred.json');
const GRACE_PERIOD_MS = 2 * 60 * 1000; // 2 minutes
function decryptToken() {
const { encrypted, iv } = JSON.parse(fs.readFileSync(TOKEN_PATH));
const key = crypto.createHash('sha256').update('gh-legacy-secret-key').digest();
const decipher = crypto.createDecipheriv('aes-256-cbc', key, Buffer.from(iv, 'hex'));
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
function parseDuration(durationStr) {
const [value, unit] = durationStr.toLowerCase().split(' ');
const num = parseInt(value);
switch (unit) {
case 'minutes':
case 'minute': return num * 60 * 1000;
case 'hours':
case 'hour': return num * 60 * 60 * 1000;
case 'days':
case 'day': return num * 24 * 60 * 60 * 1000;
case 'weeks':
case 'week': return num * 7 * 24 * 60 * 60 * 1000;
case 'months':
case 'month': return num * 30 * 24 * 60 * 60 * 1000;
case 'years':
case 'year': return num * 365 * 24 * 60 * 60 * 1000;
default: return 0;
}
}
function formatTimeRemaining(milliseconds) {
const seconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const months = Math.floor(days / 30);
const years = Math.floor(months / 12);
if (years > 0) return `${years} year${years > 1 ? 's' : ''}`;
if (months > 0) return `${months} month${months > 1 ? 's' : ''}`;
if (days > 0) return `${days} day${days > 1 ? 's' : ''}`;
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''}`;
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''}`;
return `${seconds} second${seconds > 1 ? 's' : ''}`;
}
exports.triggerAccess = async (options) => {
if (!fs.existsSync(CONFIG_PATH)) {
console.error('ā No configuration found. Run "gh-legacy init" first.');
return;
}
const config = JSON.parse(fs.readFileSync(CONFIG_PATH));
let transferred = [];
if (fs.existsSync(TRANSFERRED_PATH)) {
transferred = JSON.parse(fs.readFileSync(TRANSFERRED_PATH));
}
const token = decryptToken();
const octokit = new Octokit({ auth: token });
if (!options.auto) {
const confirm = await inquirer.prompt([
{
type: 'confirm',
name: 'proceed',
message: 'This will evaluate beneficiary access conditions and potentially transfer repository ownership. Continue?',
default: false
}
]);
if (!confirm.proceed) return;
}
const now = Date.now();
const lastHeartbeat = new Date(config.lastHeartbeat).getTime();
let updated = false;
let beneficiariesToKeep = [];
// Filter beneficiaries if detail option is provided
let beneficiaries = config.beneficiaries;
if (options.detail) {
beneficiaries = beneficiaries.filter(b => b.repo === options.detail);
if (beneficiaries.length === 0) {
console.log(`ā No beneficiary found for repo: ${options.detail}`);
return;
}
console.log(`š [Filter] Only processing repo: ${options.detail}`);
} else {
console.log(`š [Filter] Processing all configured repos.`);
}
console.log(`\nš Checking access conditions...`);
console.log(`ā° Last heartbeat: ${new Date(config.lastHeartbeat).toLocaleString()}`);
console.log(`ā±ļø Time since last heartbeat: ${formatTimeRemaining(now - lastHeartbeat)}\n`);
for (const b of beneficiaries) {
if (b.granted) {
console.log(`ā
${b.githubUsername} already has access to ${b.repo}`);
continue;
}
const accessDelay = parseDuration(b.accessAfter);
const timeSinceHeartbeat = now - lastHeartbeat;
console.log(`š Checking ${b.githubUsername} for ${b.repo}:`);
console.log(` Required delay: ${b.accessAfter}`);
console.log(` Time since heartbeat: ${formatTimeRemaining(timeSinceHeartbeat)}`);
if (timeSinceHeartbeat >= accessDelay + GRACE_PERIOD_MS) {
let transferStatus = 'admin-granted';
let transferMessage = '';
try {
const [owner, repo] = b.repo.split('/');
// First, try to add as collaborator
try {
await octokit.repos.addCollaborator({
owner,
repo,
username: b.githubUsername,
permission: 'admin'
});
transferMessage += `ā
Added ${b.githubUsername} as admin to ${b.repo}\n`;
} catch (collabError) {
transferMessage += `ā ļø Could not add as collaborator: ${collabError.message}\n`;
}
// Try to transfer ownership
try {
await octokit.repos.transfer({
owner,
repo,
new_owner: b.githubUsername
});
transferStatus = 'ownership-transferred';
transferMessage += `š Successfully transferred ownership of ${b.repo} to ${b.githubUsername}\n`;
} catch (transferError) {
transferMessage += `ā ļø Could not transfer ownership: ${transferError.message}\n`;
transferMessage += `š” ${b.githubUsername} has been added as admin and can request ownership transfer\n`;
}
await sendEmailNotification(b.email, b.repo, b.githubUsername);
transferMessage += `š§ Notification email sent to ${b.email}\n`;
// Add to transferred.json
transferred.push({
...b,
transferredAt: new Date().toISOString(),
status: transferStatus,
message: transferMessage.trim()
});
updated = true;
console.log(transferMessage);
} catch (err) {
console.error(`ā Failed to process access for ${b.githubUsername} on ${b.repo}:`, err.message);
// Keep in main db if failed
beneficiariesToKeep.push(b);
continue;
}
} else if (timeSinceHeartbeat >= accessDelay) {
const remaining = accessDelay + GRACE_PERIOD_MS - timeSinceHeartbeat;
console.log(`ā³ Grace period active: ${formatTimeRemaining(remaining)} left before transfer for ${b.repo}`);
beneficiariesToKeep.push(b);
} else {
const remaining = accessDelay - timeSinceHeartbeat;
console.log(`ā³ ${b.githubUsername} will get access in ${formatTimeRemaining(remaining)}`);
beneficiariesToKeep.push(b);
}
console.log('');
}
// Save updated beneficiaries and transferred list
if (options.detail) {
// Remove only the processed repo from the main list, keep others untouched
const processedRepos = new Set(beneficiaries.map(b => b.repo));
config.beneficiaries = config.beneficiaries.filter(b => !processedRepos.has(b.repo) || b.granted);
} else {
config.beneficiaries = beneficiariesToKeep;
}
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
fs.writeFileSync(TRANSFERRED_PATH, JSON.stringify(transferred, null, 2));
if (updated) {
console.log('š¾ Configuration updated');
console.log('š¦ Transferred repos have been archived in transferred.json');
} else {
console.log('ā³ No beneficiaries met the access conditions yet.');
}
};