You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
467 lines
20 KiB
467 lines
20 KiB
import { decode } from 'nostr-tools/nip19'; |
|
import { nip19 } from 'nostr-tools'; |
|
import { apiRequest } from '../utils/api.js'; |
|
|
|
/** |
|
* Repository operations command |
|
*/ |
|
export async function repos(args, server, json) { |
|
const subcommand = args[0]; |
|
|
|
// Show help if no subcommand or help requested |
|
if (!subcommand || subcommand === '--help' || subcommand === '-h') { |
|
console.log('Repository Operations:'); |
|
console.log(''); |
|
console.log('Commands:'); |
|
console.log(' list List all repositories (registered and local)'); |
|
console.log(' get <npub> <repo> Get repository info with clone URL reachability'); |
|
console.log(' (or use naddr: get <naddr>)'); |
|
console.log(' settings <npub> <repo> Get/update repository settings'); |
|
console.log(' Use --help for options'); |
|
console.log(' maintainers <npub> <repo> [add|remove <npub>] Manage maintainers'); |
|
console.log(' branches <npub> <repo> List branches'); |
|
console.log(' tags <npub> <repo> List tags'); |
|
console.log(' fork <npub> <repo> Fork a repository'); |
|
console.log(' delete <npub> <repo> Delete a repository'); |
|
console.log(' poll Trigger repository polling (provisions new repos from Nostr)'); |
|
console.log(''); |
|
console.log('Examples:'); |
|
console.log(' gitrep repos list'); |
|
console.log(' gitrep repos get npub1abc... myrepo'); |
|
console.log(' gitrep repos poll'); |
|
console.log(''); |
|
process.exit(0); |
|
} |
|
|
|
if (subcommand === 'list') { |
|
// Get registered and unregistered repos from Nostr |
|
const listData = await apiRequest(server, '/repos/list', 'GET'); |
|
|
|
// Get local repos (cloned on server) |
|
let localRepos = []; |
|
try { |
|
localRepos = await apiRequest(server, '/repos/local', 'GET'); |
|
} catch (err) { |
|
// Local repos endpoint might not be available or might fail |
|
// Continue without local repos |
|
} |
|
|
|
// Helper function to check verification status |
|
async function checkVerification(npub, repoName) { |
|
try { |
|
// The verify endpoint doesn't require authentication, so we can call it directly |
|
const url = `${server.replace(/\/$/, '')}/api/repos/${npub}/${repoName}/verification`; |
|
const response = await fetch(url); |
|
if (!response.ok) { |
|
// If endpoint returns error, assume not verified |
|
return false; |
|
} |
|
const verifyData = await response.json(); |
|
// Return true only if verified is explicitly true |
|
return verifyData.verified === true; |
|
} catch (err) { |
|
// Silently fail - assume not verified if check fails |
|
return false; |
|
} |
|
} |
|
|
|
// Check verification status for all repos (in parallel for performance) |
|
const registered = listData.registered || []; |
|
const verificationPromises = []; |
|
|
|
// Check verification for registered repos |
|
for (const repo of registered) { |
|
const name = repo.repoName || repo.name || 'unknown'; |
|
const npub = repo.npub || 'unknown'; |
|
if (name !== 'unknown' && npub !== 'unknown') { |
|
verificationPromises.push( |
|
checkVerification(npub, name).then(verified => ({ |
|
key: `${npub}/${name}`, |
|
verified |
|
})) |
|
); |
|
} |
|
} |
|
|
|
// Check verification for local repos |
|
for (const repo of localRepos) { |
|
const name = repo.repoName || repo.name || 'unknown'; |
|
const npub = repo.npub || 'unknown'; |
|
if (name !== 'unknown' && npub !== 'unknown') { |
|
verificationPromises.push( |
|
checkVerification(npub, name).then(verified => ({ |
|
key: `${npub}/${name}`, |
|
verified |
|
})) |
|
); |
|
} |
|
} |
|
|
|
// Wait for all verification checks to complete |
|
const verificationResults = await Promise.all(verificationPromises); |
|
const verifiedMap = new Map(); |
|
verificationResults.forEach(result => { |
|
verifiedMap.set(result.key, result.verified); |
|
}); |
|
|
|
if (json) { |
|
// Add verification status to JSON output |
|
const registeredWithVerification = registered.map(repo => ({ |
|
...repo, |
|
verified: verifiedMap.get(`${repo.npub}/${repo.repoName || repo.name || 'unknown'}`) || false |
|
})); |
|
const localWithVerification = localRepos.map(repo => ({ |
|
...repo, |
|
verified: verifiedMap.get(`${repo.npub}/${repo.repoName || repo.name || 'unknown'}`) || false |
|
})); |
|
|
|
console.log(JSON.stringify({ |
|
registered: registeredWithVerification, |
|
local: localWithVerification, |
|
total: { |
|
registered: registered.length, |
|
local: localRepos.length, |
|
total: (registered.length + localRepos.length) |
|
} |
|
}, null, 2)); |
|
} else { |
|
// Display help text explaining the difference |
|
console.log('Repository Types:'); |
|
console.log(' Registered: Repositories announced on Nostr with this server in their clone URLs'); |
|
console.log(' Local: Repositories cloned on this server (may be registered or unregistered)'); |
|
console.log(' Verified: Repository ownership has been cryptographically verified'); |
|
console.log(''); |
|
|
|
// Display registered repositories |
|
if (registered.length > 0) { |
|
console.log('Registered Repositories:'); |
|
registered.forEach(repo => { |
|
const name = repo.repoName || repo.name || 'unknown'; |
|
const npub = repo.npub || 'unknown'; |
|
const desc = repo.event?.tags?.find(t => t[0] === 'description')?.[1] || |
|
repo.description || |
|
'No description'; |
|
const key = `${npub}/${name}`; |
|
const verified = verifiedMap.has(key) ? verifiedMap.get(key) : false; |
|
const verifiedStatus = verified ? 'verified' : 'unverified'; |
|
console.log(` ${npub}/${name} (${verifiedStatus}) - ${desc}`); |
|
}); |
|
console.log(''); |
|
} |
|
|
|
// Display local repositories |
|
if (localRepos.length > 0) { |
|
console.log('Local Repositories:'); |
|
localRepos.forEach(repo => { |
|
const name = repo.repoName || repo.name || 'unknown'; |
|
const npub = repo.npub || 'unknown'; |
|
const desc = repo.announcement?.tags?.find(t => t[0] === 'description')?.[1] || |
|
repo.description || |
|
'No description'; |
|
const registrationStatus = repo.isRegistered ? 'registered' : 'unregistered'; |
|
const key = `${npub}/${name}`; |
|
// Get verification status - use has() to distinguish between false and undefined |
|
const verified = verifiedMap.has(key) ? verifiedMap.get(key) : false; |
|
const verifiedStatus = verified ? 'verified' : 'unverified'; |
|
console.log(` ${npub}/${name} (${registrationStatus}, ${verifiedStatus}) - ${desc}`); |
|
}); |
|
console.log(''); |
|
} |
|
|
|
// Summary |
|
const totalRegistered = registered.length; |
|
const totalLocal = localRepos.length; |
|
const totalVerified = Array.from(verifiedMap.values()).filter(v => v === true).length; |
|
if (totalRegistered === 0 && totalLocal === 0) { |
|
console.log('No repositories found.'); |
|
} else { |
|
console.log(`Total: ${totalRegistered} registered, ${totalLocal} local, ${totalVerified} verified`); |
|
} |
|
} |
|
} else if (subcommand === 'get' && args[1]) { |
|
let npub, repo; |
|
|
|
// Check if first argument is naddr format |
|
if (args[1].startsWith('naddr1')) { |
|
try { |
|
const decoded = decode(args[1]); |
|
if (decoded.type === 'naddr') { |
|
const data = decoded.data; |
|
// naddr contains pubkey (hex) and identifier (d-tag) |
|
npub = nip19.npubEncode(data.pubkey); |
|
repo = data.identifier || data['d']; |
|
if (!repo) { |
|
throw new Error('Invalid naddr: missing identifier (d-tag)'); |
|
} |
|
} else { |
|
throw new Error('Invalid naddr format'); |
|
} |
|
} catch (err) { |
|
console.error(`Error: Failed to decode naddr: ${err.message}`); |
|
process.exit(1); |
|
} |
|
} else if (args[2]) { |
|
// Traditional npub/repo format |
|
[npub, repo] = args.slice(1); |
|
} else { |
|
console.error('Error: Invalid arguments. Use: repos get <npub> <repo> or repos get <naddr>'); |
|
process.exit(1); |
|
} |
|
|
|
const data = await apiRequest(server, `/repos/${npub}/${repo}/settings`, 'GET'); |
|
|
|
// Fetch clone URL reachability information |
|
let cloneUrlReachability = null; |
|
try { |
|
const reachabilityData = await apiRequest(server, `/repos/${npub}/${repo}/clone-urls/reachability`, 'GET'); |
|
if (reachabilityData.results && Array.isArray(reachabilityData.results)) { |
|
cloneUrlReachability = reachabilityData.results; |
|
} |
|
} catch (err) { |
|
// Silently fail - reachability endpoint might not be available or might fail |
|
// This is optional information |
|
} |
|
|
|
if (json) { |
|
const output = { ...data }; |
|
if (cloneUrlReachability) { |
|
output.cloneUrls = cloneUrlReachability; |
|
} |
|
console.log(JSON.stringify(output, null, 2)); |
|
} else { |
|
console.log(`Repository: ${npub}/${repo}`); |
|
console.log(`Description: ${data.description || 'No description'}`); |
|
if (data.visibility) { |
|
console.log(`Visibility: ${data.visibility}`); |
|
} else { |
|
// Backward compatibility: show private status if visibility not available |
|
console.log(`Private: ${data.private ? 'Yes' : 'No'}`); |
|
} |
|
if (data.projectRelays && data.projectRelays.length > 0) { |
|
console.log(`Project Relays: ${data.projectRelays.join(', ')}`); |
|
} |
|
console.log(`Owner: ${data.owner || npub}`); |
|
|
|
if (cloneUrlReachability && cloneUrlReachability.length > 0) { |
|
console.log('\nClone URLs:'); |
|
for (const result of cloneUrlReachability) { |
|
const status = result.reachable ? '✅' : '❌'; |
|
const serverType = result.serverType === 'grasp' ? ' (GRASP)' : result.serverType === 'git' ? ' (Git)' : ''; |
|
const error = result.error ? ` - ${result.error}` : ''; |
|
console.log(` ${status} ${result.url}${serverType}${error}`); |
|
} |
|
} |
|
} |
|
} else if (subcommand === 'settings' && args[1] && args[2]) { |
|
const [npub, repo] = args.slice(1); |
|
|
|
// Show help if requested |
|
if (args[3] === '--help' || args[3] === '-h') { |
|
console.log('Repository Settings:'); |
|
console.log(''); |
|
console.log('Get settings:'); |
|
console.log(' gitrep repos settings <npub> <repo>'); |
|
console.log(''); |
|
console.log('Update settings:'); |
|
console.log(' gitrep repos settings <npub> <repo> [options]'); |
|
console.log(''); |
|
console.log('Options:'); |
|
console.log(' --description <text> Update repository description'); |
|
console.log(' --visibility <level> Set visibility level'); |
|
console.log(' Values: public, unlisted, restricted, private'); |
|
console.log(' --project-relay <url> Add project relay (can be used multiple times)'); |
|
console.log(' Required for unlisted and restricted visibility'); |
|
console.log(' --private <true|false> (Deprecated) Use --visibility instead'); |
|
console.log(''); |
|
console.log('Visibility levels:'); |
|
console.log(' public - Repository and events published to all relays + project relay'); |
|
console.log(' unlisted - Repository public, events only to project relay'); |
|
console.log(' restricted - Repository private, events only to project relay'); |
|
console.log(' private - Repository private, no relay publishing (git-only)'); |
|
console.log(''); |
|
console.log('Examples:'); |
|
console.log(' gitrep repos settings npub1... myrepo --description "My repo"'); |
|
console.log(' gitrep repos settings npub1... myrepo --visibility unlisted --project-relay wss://relay.example.com'); |
|
console.log(' gitrep repos settings npub1... myrepo --visibility restricted --project-relay wss://relay1.com --project-relay wss://relay2.com'); |
|
process.exit(0); |
|
} |
|
|
|
if (args[3]) { |
|
// Update settings |
|
const settings = {}; |
|
const projectRelays = []; |
|
|
|
// Parse arguments - handle both --key value and --key=value formats |
|
for (let i = 3; i < args.length; i++) { |
|
const arg = args[i]; |
|
|
|
// Handle --key=value format |
|
if (arg.includes('=')) { |
|
const [key, value] = arg.split('=', 2); |
|
const cleanKey = key.replace('--', ''); |
|
if (cleanKey === 'description') { |
|
settings.description = value; |
|
} else if (cleanKey === 'visibility') { |
|
const vis = value.toLowerCase(); |
|
if (['public', 'unlisted', 'restricted', 'private'].includes(vis)) { |
|
settings.visibility = vis; |
|
} else { |
|
console.error(`Error: Invalid visibility '${value}'. Must be one of: public, unlisted, restricted, private`); |
|
process.exit(1); |
|
} |
|
} else if (cleanKey === 'project-relay') { |
|
projectRelays.push(value); |
|
} else if (cleanKey === 'private') { |
|
// Backward compatibility: map private boolean to visibility |
|
settings.visibility = value === 'true' ? 'restricted' : 'public'; |
|
} |
|
} else if (arg.startsWith('--')) { |
|
// Handle --key value format |
|
const key = arg.replace('--', ''); |
|
const value = args[i + 1]; |
|
|
|
if (!value || value.startsWith('--')) { |
|
console.error(`Error: Missing value for flag ${arg}`); |
|
process.exit(1); |
|
} |
|
|
|
if (key === 'description') { |
|
settings.description = value; |
|
i++; // Skip next arg as it's the value |
|
} else if (key === 'visibility') { |
|
const vis = value.toLowerCase(); |
|
if (['public', 'unlisted', 'restricted', 'private'].includes(vis)) { |
|
settings.visibility = vis; |
|
} else { |
|
console.error(`Error: Invalid visibility '${value}'. Must be one of: public, unlisted, restricted, private`); |
|
process.exit(1); |
|
} |
|
i++; // Skip next arg as it's the value |
|
} else if (key === 'project-relay') { |
|
projectRelays.push(value); |
|
i++; // Skip next arg as it's the value |
|
} else if (key === 'private') { |
|
// Backward compatibility: map private boolean to visibility |
|
settings.visibility = value === 'true' ? 'restricted' : 'public'; |
|
i++; // Skip next arg as it's the value |
|
} |
|
} |
|
} |
|
|
|
// Add project relays if any were specified |
|
if (projectRelays.length > 0) { |
|
settings.projectRelays = projectRelays; |
|
} |
|
|
|
const data = await apiRequest(server, `/repos/${npub}/${repo}/settings`, 'POST', settings); |
|
if (json) { |
|
console.log(JSON.stringify(data, null, 2)); |
|
} else { |
|
console.log('Settings updated successfully'); |
|
console.log(` Description: ${data.description || 'No description'}`); |
|
console.log(` Visibility: ${data.visibility || 'public'}`); |
|
if (data.projectRelays && data.projectRelays.length > 0) { |
|
console.log(` Project Relays: ${data.projectRelays.join(', ')}`); |
|
} |
|
} |
|
} else { |
|
// Get settings |
|
const data = await apiRequest(server, `/repos/${npub}/${repo}/settings`, 'GET'); |
|
if (json) { |
|
console.log(JSON.stringify(data, null, 2)); |
|
} else { |
|
console.log(`Repository: ${npub}/${repo}`); |
|
console.log(`Description: ${data.description || 'No description'}`); |
|
console.log(`Visibility: ${data.visibility || 'public'}`); |
|
if (data.projectRelays && data.projectRelays.length > 0) { |
|
console.log(`Project Relays: ${data.projectRelays.join(', ')}`); |
|
} |
|
console.log(`Owner: ${data.owner || npub}`); |
|
// Backward compatibility: show private status if visibility not available |
|
if (!data.visibility && data.private !== undefined) { |
|
console.log(`Private: ${data.private ? 'Yes' : 'No'}`); |
|
} |
|
} |
|
} |
|
} else if (subcommand === 'maintainers' && args[1] && args[2]) { |
|
const [npub, repo] = args.slice(1); |
|
const action = args[3]; |
|
const maintainerNpub = args[4]; |
|
|
|
if (action === 'add' && maintainerNpub) { |
|
const data = await apiRequest(server, `/repos/${npub}/${repo}/maintainers`, 'POST', { maintainer: maintainerNpub }); |
|
console.log(json ? JSON.stringify(data, null, 2) : `Maintainer ${maintainerNpub} added successfully`); |
|
} else if (action === 'remove' && maintainerNpub) { |
|
const data = await apiRequest(server, `/repos/${npub}/${repo}/maintainers`, 'DELETE', { maintainer: maintainerNpub }); |
|
console.log(json ? JSON.stringify(data, null, 2) : `Maintainer ${maintainerNpub} removed successfully`); |
|
} else { |
|
// List maintainers |
|
const data = await apiRequest(server, `/repos/${npub}/${repo}/maintainers`, 'GET'); |
|
if (json) { |
|
console.log(JSON.stringify(data, null, 2)); |
|
} else { |
|
console.log(`Repository: ${npub}/${repo}`); |
|
console.log(`Owner: ${data.owner}`); |
|
console.log(`Maintainers: ${data.maintainers?.length || 0}`); |
|
if (data.maintainers?.length > 0) { |
|
data.maintainers.forEach(m => console.log(` - ${m}`)); |
|
} |
|
} |
|
} |
|
} else if (subcommand === 'branches' && args[1] && args[2]) { |
|
const [npub, repo] = args.slice(1); |
|
const data = await apiRequest(server, `/repos/${npub}/${repo}/branches`, 'GET'); |
|
if (json) { |
|
console.log(JSON.stringify(data, null, 2)); |
|
} else { |
|
console.log(`Branches for ${npub}/${repo}:`); |
|
if (Array.isArray(data)) { |
|
data.forEach(branch => { |
|
console.log(` ${branch.name} - ${branch.commit?.substring(0, 7) || 'N/A'}`); |
|
}); |
|
} |
|
} |
|
} else if (subcommand === 'tags' && args[1] && args[2]) { |
|
const [npub, repo] = args.slice(1); |
|
const data = await apiRequest(server, `/repos/${npub}/${repo}/tags`, 'GET'); |
|
if (json) { |
|
console.log(JSON.stringify(data, null, 2)); |
|
} else { |
|
console.log(`Tags for ${npub}/${repo}:`); |
|
if (Array.isArray(data)) { |
|
data.forEach(tag => { |
|
console.log(` ${tag.name} - ${tag.hash?.substring(0, 7) || 'N/A'}`); |
|
}); |
|
} |
|
} |
|
} else if (subcommand === 'fork' && args[1] && args[2]) { |
|
const [npub, repo] = args.slice(1); |
|
const data = await apiRequest(server, `/repos/${npub}/${repo}/forks`, 'POST', {}); |
|
if (json) { |
|
console.log(JSON.stringify(data, null, 2)); |
|
} else { |
|
console.log(`Repository forked successfully: ${data.npub}/${data.repo}`); |
|
} |
|
} else if (subcommand === 'delete' && args[1] && args[2]) { |
|
const [npub, repo] = args.slice(1); |
|
const data = await apiRequest(server, `/repos/${npub}/${repo}/delete`, 'DELETE'); |
|
console.log(json ? JSON.stringify(data, null, 2) : 'Repository deleted successfully'); |
|
} else if (subcommand === 'poll') { |
|
// Trigger repository polling to provision new repos from Nostr announcements |
|
const data = await apiRequest(server, '/repos/poll', 'POST'); |
|
if (json) { |
|
console.log(JSON.stringify(data, null, 2)); |
|
} else { |
|
if (data.success) { |
|
console.log('Repository polling triggered successfully'); |
|
console.log('The server will fetch NIP-34 repo announcements and provision repositories that list this server\'s domain.'); |
|
} else { |
|
console.error('Failed to trigger polling:', data.error || 'Unknown error'); |
|
process.exit(1); |
|
} |
|
} |
|
} else { |
|
console.error('Invalid repos command. Use: list, get, settings, maintainers, branches, tags, fork, delete, poll'); |
|
process.exit(1); |
|
} |
|
}
|
|
|