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

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);
}
}