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]; 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}/verify`; 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 or repos get '); 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 '); console.log(''); console.log('Update settings:'); console.log(' gitrep repos settings [options]'); console.log(''); console.log('Options:'); console.log(' --description Update repository description'); console.log(' --visibility Set visibility level'); console.log(' Values: public, unlisted, restricted, private'); console.log(' --project-relay Add project relay (can be used multiple times)'); console.log(' Required for unlisted and restricted visibility'); console.log(' --private (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}/fork`, '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 { console.error('Invalid repos command. Use: list, get, settings, maintainers, branches, tags, fork, delete'); process.exit(1); } }