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.
 
 
 
 
 

214 lines
6.9 KiB

/**
* 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<void> {
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<void> {
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<void> {
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<void> {
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] };
}
}