/** * Repository manager for git repositories * Handles repo provisioning, syncing, and NIP-34 integration */ import { exec } from 'child_process'; import { promisify } from 'util'; import { existsSync, mkdirSync, writeFileSync } from 'fs'; import { join } from 'path'; import type { NostrEvent } from '../../types/nostr.js'; import { GIT_DOMAIN } from '../../config.js'; import { generateVerificationFile, VERIFICATION_FILE_PATH } from '../nostr/repo-verification.js'; import simpleGit, { type SimpleGit } from 'simple-git'; const execAsync = promisify(exec); export interface RepoPath { npub: string; repoName: string; fullPath: string; } export class RepoManager { private repoRoot: string; private domain: string; constructor(repoRoot: string = '/repos', domain: string = GIT_DOMAIN) { this.repoRoot = repoRoot; this.domain = domain; } /** * Parse git domain URL to extract npub and repo name */ parseRepoUrl(url: string): RepoPath | null { // Match: https://{domain}/{npub}/{repo-name}.git or http://{domain}/{npub}/{repo-name}.git // Escape domain for regex (replace dots with \.) const escapedDomain = this.domain.replace(/\./g, '\\.'); const match = url.match(new RegExp(`${escapedDomain}\\/(npub[a-z0-9]+)\\/([^\\/]+)\\.git`)); if (!match) return null; const [, npub, repoName] = match; const fullPath = join(this.repoRoot, npub, `${repoName}.git`); return { npub, repoName, fullPath }; } /** * Create a bare git repository from a NIP-34 repo announcement */ async provisionRepo(event: NostrEvent): Promise { const cloneUrls = this.extractCloneUrls(event); const domainUrl = cloneUrls.find(url => url.includes(this.domain)); if (!domainUrl) { throw new Error(`No ${this.domain} URL found in repo announcement`); } const repoPath = this.parseRepoUrl(domainUrl); if (!repoPath) { throw new Error(`Invalid ${this.domain} URL format`); } // Create directory structure const repoDir = join(this.repoRoot, repoPath.npub); if (!existsSync(repoDir)) { mkdirSync(repoDir, { recursive: true }); } // Create bare repository if it doesn't exist const isNewRepo = !existsSync(repoPath.fullPath); if (isNewRepo) { await execAsync(`git init --bare "${repoPath.fullPath}"`); // Create verification file in the repository await this.createVerificationFile(repoPath.fullPath, event); } // If there are other clone URLs, sync from them const otherUrls = cloneUrls.filter(url => !url.includes(this.domain)); if (otherUrls.length > 0) { await this.syncFromRemotes(repoPath.fullPath, otherUrls); } } /** * Sync repository from multiple remote URLs */ async syncFromRemotes(repoPath: string, remoteUrls: string[]): Promise { for (const url of remoteUrls) { try { // Add remote if not exists const remoteName = `remote-${remoteUrls.indexOf(url)}`; await execAsync(`cd "${repoPath}" && git remote add ${remoteName} "${url}" || true`); // Fetch from remote await execAsync(`cd "${repoPath}" && git fetch ${remoteName} --all`); // Update all branches await execAsync(`cd "${repoPath}" && git remote set-head ${remoteName} -a`); } catch (error) { console.error(`Failed to sync from ${url}:`, error); // Continue with other remotes } } } /** * Sync repository to multiple remote URLs after a push */ async syncToRemotes(repoPath: string, remoteUrls: string[]): Promise { for (const url of remoteUrls) { try { const remoteName = `remote-${remoteUrls.indexOf(url)}`; await execAsync(`cd "${repoPath}" && git remote add ${remoteName} "${url}" || true`); await execAsync(`cd "${repoPath}" && git push ${remoteName} --all --force`); await execAsync(`cd "${repoPath}" && git push ${remoteName} --tags --force`); } catch (error) { console.error(`Failed to sync to ${url}:`, error); // Continue with other remotes } } } /** * Extract clone URLs from a NIP-34 repo announcement */ private extractCloneUrls(event: NostrEvent): string[] { const urls: string[] = []; for (const tag of event.tags) { if (tag[0] === 'clone') { for (let i = 1; i < tag.length; i++) { const url = tag[i]; if (url && typeof url === 'string') { urls.push(url); } } } } return urls; } /** * Check if a repository exists */ repoExists(repoPath: string): boolean { return existsSync(repoPath); } /** * Create verification file in a new repository * This proves the repository is owned by the announcement author */ private async createVerificationFile(repoPath: string, event: NostrEvent): Promise { try { // Create a temporary working directory const repoName = this.parseRepoPathForName(repoPath)?.repoName || 'temp'; const workDir = join(repoPath, '..', `${repoName}.work`); const { rm, mkdir } = await import('fs/promises'); // Clean up if exists if (existsSync(workDir)) { await rm(workDir, { recursive: true, force: true }); } await mkdir(workDir, { recursive: true }); // Clone the bare repo const git: SimpleGit = simpleGit(); await git.clone(repoPath, workDir); // Generate verification file content const verificationContent = generateVerificationFile(event, event.pubkey); // Write verification file const verificationPath = join(workDir, VERIFICATION_FILE_PATH); writeFileSync(verificationPath, verificationContent, 'utf-8'); // Commit the verification file const workGit: SimpleGit = simpleGit(workDir); await workGit.add(VERIFICATION_FILE_PATH); // Use the event timestamp for commit date const commitDate = new Date(event.created_at * 1000).toISOString(); await workGit.commit('Add Nostr repository verification file', [VERIFICATION_FILE_PATH], { '--author': `Nostr <${event.pubkey}@nostr>`, '--date': commitDate }); // Push back to bare repo await workGit.push(['origin', 'main']).catch(async () => { // If main branch doesn't exist, create it await workGit.checkout(['-b', 'main']); await workGit.push(['origin', 'main']); }); // Clean up await rm(workDir, { recursive: true, force: true }); } catch (error) { console.error('Failed to create verification file:', error); // Don't throw - verification file creation is important but shouldn't block provisioning } } /** * Parse repo path to extract repo name (helper for verification file creation) */ private parseRepoPathForName(repoPath: string): { repoName: string } | null { const match = repoPath.match(/\/([^\/]+)\.git$/); if (!match) return null; return { repoName: match[1] }; } }