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.
543 lines
22 KiB
543 lines
22 KiB
/** |
|
* Announcement Manager |
|
* Handles saving and retrieving repository announcements from repos |
|
*/ |
|
|
|
import { existsSync } from 'fs'; |
|
import { readFile } from 'fs/promises'; |
|
import { join, dirname } from 'path'; |
|
import { mkdir, writeFile, rm, readdir } from 'fs/promises'; |
|
import { copyFileSync, mkdirSync, existsSync as fsExistsSync } from 'fs'; |
|
import simpleGit, { type SimpleGit } from 'simple-git'; |
|
import logger from '../logger.js'; |
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
import { validateAnnouncementEvent } from '../nostr/repo-verification.js'; |
|
import { DEFAULT_NOSTR_RELAYS } from '../../config.js'; |
|
import { NostrClient } from '../nostr/nostr-client.js'; |
|
import { KIND } from '../../types/nostr.js'; |
|
import { RepoUrlParser } from './repo-url-parser.js'; |
|
|
|
/** |
|
* Announcement Manager |
|
* Handles saving and retrieving repository announcements from repos |
|
*/ |
|
export class AnnouncementManager { |
|
private urlParser: RepoUrlParser; |
|
|
|
constructor(repoRoot: string = '/repos', domain: string = 'localhost:6543') { |
|
this.urlParser = new RepoUrlParser(repoRoot, domain); |
|
} |
|
|
|
/** |
|
* Check if an announcement event already exists in nostr/repo-events.jsonl |
|
*/ |
|
async hasAnnouncementInRepo(worktreePath: string, eventId?: string): Promise<boolean> { |
|
try { |
|
const jsonlFile = join(worktreePath, 'nostr', 'repo-events.jsonl'); |
|
if (!existsSync(jsonlFile)) { |
|
return false; |
|
} |
|
|
|
const content = await readFile(jsonlFile, 'utf-8'); |
|
const lines = content.trim().split('\n').filter(Boolean); |
|
|
|
for (const line of lines) { |
|
try { |
|
const entry = JSON.parse(line); |
|
if (entry.type === 'announcement' && entry.event) { |
|
// If eventId provided, check for exact match |
|
if (eventId) { |
|
if (entry.event.id === eventId) { |
|
return true; |
|
} |
|
} else { |
|
// Just check if any announcement exists |
|
return true; |
|
} |
|
} |
|
} catch { |
|
// Skip invalid lines |
|
continue; |
|
} |
|
} |
|
|
|
return false; |
|
} catch (err) { |
|
logger.debug({ error: err, worktreePath }, 'Failed to check for announcement in repo'); |
|
return false; |
|
} |
|
} |
|
|
|
/** |
|
* Read announcement event from nostr/repo-events.jsonl |
|
*/ |
|
async getAnnouncementFromRepo(worktreePath: string): Promise<NostrEvent | null> { |
|
try { |
|
const jsonlFile = join(worktreePath, 'nostr', 'repo-events.jsonl'); |
|
if (!existsSync(jsonlFile)) { |
|
return null; |
|
} |
|
|
|
const content = await readFile(jsonlFile, 'utf-8'); |
|
const lines = content.trim().split('\n').filter(Boolean); |
|
|
|
// Find the most recent announcement event |
|
let latestAnnouncement: NostrEvent | null = null; |
|
let latestTimestamp = 0; |
|
|
|
for (const line of lines) { |
|
try { |
|
const entry = JSON.parse(line); |
|
if (entry.type === 'announcement' && entry.event && entry.timestamp) { |
|
if (entry.timestamp > latestTimestamp) { |
|
latestTimestamp = entry.timestamp; |
|
latestAnnouncement = entry.event; |
|
} |
|
} |
|
} catch { |
|
// Skip invalid lines |
|
continue; |
|
} |
|
} |
|
|
|
return latestAnnouncement; |
|
} catch (err) { |
|
logger.debug({ error: err, worktreePath }, 'Failed to read announcement from repo'); |
|
return null; |
|
} |
|
} |
|
|
|
/** |
|
* Fetch announcement from relays and validate it |
|
*/ |
|
async fetchAnnouncementFromRelays( |
|
repoOwnerPubkey: string, |
|
repoName: string |
|
): Promise<NostrEvent | null> { |
|
try { |
|
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
|
const events = await nostrClient.fetchEvents([ |
|
{ |
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
|
authors: [repoOwnerPubkey], |
|
'#d': [repoName], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (events.length === 0) { |
|
return null; |
|
} |
|
|
|
const event = events[0]; |
|
|
|
// Validate the event |
|
const validation = validateAnnouncementEvent(event, repoName); |
|
if (!validation.valid) { |
|
logger.warn({ error: validation.error, repoName }, 'Fetched announcement failed validation'); |
|
return null; |
|
} |
|
|
|
return event; |
|
} catch (err) { |
|
logger.debug({ error: err, repoOwnerPubkey, repoName }, 'Failed to fetch announcement from relays'); |
|
return null; |
|
} |
|
} |
|
|
|
/** |
|
* Save a repo event (announcement or transfer) to nostr/repo-events.jsonl |
|
* Only saves if not already present (for announcements) |
|
* This provides a standard location for all repo-related Nostr events for easy analysis |
|
*/ |
|
async saveRepoEventToWorktree( |
|
worktreePath: string, |
|
event: NostrEvent, |
|
eventType: 'announcement' | 'transfer', |
|
skipIfExists: boolean = true |
|
): Promise<boolean> { |
|
try { |
|
// For announcements, check if already exists |
|
if (eventType === 'announcement' && skipIfExists) { |
|
const exists = await this.hasAnnouncementInRepo(worktreePath, event.id); |
|
if (exists) { |
|
logger.debug({ eventId: event.id, worktreePath }, 'Announcement already exists in repo, skipping'); |
|
return false; |
|
} |
|
} |
|
|
|
// Create nostr directory in worktree |
|
const nostrDir = join(worktreePath, 'nostr'); |
|
await mkdir(nostrDir, { recursive: true }); |
|
|
|
// Append to repo-events.jsonl with event type metadata |
|
const jsonlFile = join(nostrDir, 'repo-events.jsonl'); |
|
const eventLine = JSON.stringify({ |
|
type: eventType, |
|
timestamp: event.created_at, |
|
event |
|
}) + '\n'; |
|
await writeFile(jsonlFile, eventLine, { flag: 'a', encoding: 'utf-8' }); |
|
return true; |
|
} catch (err) { |
|
logger.debug({ error: err, worktreePath, eventType }, 'Failed to save repo event to nostr/repo-events.jsonl'); |
|
// Don't throw - this is a nice-to-have feature |
|
return false; |
|
} |
|
} |
|
|
|
/** |
|
* Ensure announcement event is saved to nostr/repo-events.jsonl in the repository |
|
* Only saves if not already present (avoids redundant entries) |
|
*/ |
|
async ensureAnnouncementInRepo(repoPath: string, event: NostrEvent, selfTransferEvent?: NostrEvent, preferredDefaultBranch?: string): Promise<void> { |
|
let isEmpty = false; |
|
try { |
|
// Create a temporary working directory |
|
const repoName = this.urlParser.parseRepoPathForName(repoPath)?.repoName || 'temp'; |
|
const workDir = join(repoPath, '..', `${repoName}.work`); |
|
|
|
// Clean up if exists |
|
if (existsSync(workDir)) { |
|
await rm(workDir, { recursive: true, force: true }); |
|
} |
|
await mkdir(workDir, { recursive: true }); |
|
|
|
// Check if repo has any commits (is empty) |
|
const bareGit: SimpleGit = simpleGit(repoPath); |
|
try { |
|
const logResult = await bareGit.log(['--all', '-1']); |
|
isEmpty = logResult.total === 0; |
|
} catch { |
|
// If log fails, assume repo is empty |
|
isEmpty = true; |
|
} |
|
|
|
const git: SimpleGit = simpleGit(); |
|
let workGit: SimpleGit; |
|
|
|
if (isEmpty) { |
|
// Repo is empty - initialize worktree and create initial branch |
|
// Use preferred branch, then environment, then try 'main' first, then 'master' |
|
const defaultBranch = preferredDefaultBranch || process.env.DEFAULT_BRANCH || 'main'; |
|
|
|
// Initialize git in workdir |
|
workGit = simpleGit(workDir); |
|
await workGit.init(false); |
|
await workGit.raw(['-C', workDir, 'checkout', '-b', defaultBranch]); |
|
|
|
// Add the bare repo as remote |
|
await workGit.addRemote('origin', repoPath); |
|
} else { |
|
// Repo has commits - clone normally |
|
await git.clone(repoPath, workDir); |
|
// Create workGit instance after clone |
|
workGit = simpleGit(workDir); |
|
|
|
// Determine the correct default branch to commit to |
|
let targetBranch = preferredDefaultBranch || process.env.DEFAULT_BRANCH || 'main'; |
|
|
|
// Check if the preferred branch exists, if not try to find the actual default branch |
|
try { |
|
const branches = await workGit.branch(['-a']); |
|
const branchList = branches.all |
|
.map(b => b.replace(/^remotes\/origin\//, '').replace(/^remotes\//, '').replace(/^refs\/heads\//, '')) |
|
.filter(b => b && !b.includes('HEAD')); |
|
|
|
// If preferred branch exists, use it; otherwise try main/master, then first branch |
|
if (preferredDefaultBranch && branchList.includes(preferredDefaultBranch)) { |
|
targetBranch = preferredDefaultBranch; |
|
} else if (branchList.includes('main')) { |
|
targetBranch = 'main'; |
|
} else if (branchList.includes('master')) { |
|
targetBranch = 'master'; |
|
} else if (branchList.length > 0) { |
|
targetBranch = branchList[0]; |
|
} |
|
|
|
// Checkout the target branch |
|
try { |
|
await workGit.checkout(targetBranch); |
|
logger.debug({ repoPath, targetBranch }, 'Checked out target branch for announcement commit'); |
|
} catch (checkoutErr) { |
|
// If checkout fails, try to create the branch |
|
logger.debug({ repoPath, targetBranch, error: checkoutErr }, 'Failed to checkout branch, will try to create it'); |
|
try { |
|
await workGit.checkout(['-b', targetBranch]); |
|
logger.debug({ repoPath, targetBranch }, 'Created and checked out new branch for announcement commit'); |
|
} catch (createErr) { |
|
logger.warn({ repoPath, targetBranch, error: createErr }, 'Failed to create branch, will commit to current branch'); |
|
} |
|
} |
|
} catch (branchErr) { |
|
logger.warn({ repoPath, error: branchErr }, 'Failed to determine or checkout branch, will commit to current branch'); |
|
} |
|
} |
|
|
|
// Check if announcement already exists in nostr/repo-events.jsonl |
|
const hasAnnouncement = await this.hasAnnouncementInRepo(workDir, event.id); |
|
|
|
const filesToAdd: string[] = []; |
|
|
|
// Only save announcement if not already present |
|
if (!hasAnnouncement) { |
|
const saved = await this.saveRepoEventToWorktree(workDir, event, 'announcement', false); |
|
if (saved) { |
|
filesToAdd.push('nostr/repo-events.jsonl'); |
|
logger.info({ repoPath, eventId: event.id }, 'Saved announcement to nostr/repo-events.jsonl'); |
|
} |
|
} else { |
|
logger.debug({ repoPath, eventId: event.id }, 'Announcement already exists in repo, skipping'); |
|
} |
|
|
|
// Save transfer event if provided |
|
if (selfTransferEvent) { |
|
const saved = await this.saveRepoEventToWorktree(workDir, selfTransferEvent, 'transfer', false); |
|
if (saved) { |
|
if (!filesToAdd.includes('nostr/repo-events.jsonl')) { |
|
filesToAdd.push('nostr/repo-events.jsonl'); |
|
} |
|
} |
|
} |
|
|
|
// Only commit if we added files |
|
if (filesToAdd.length > 0) { |
|
logger.info({ repoPath, filesToAdd, isEmpty }, 'Adding files and committing announcement'); |
|
await workGit.add(filesToAdd); |
|
|
|
// Configure git user.name and user.email for this repository |
|
// This is required for git commits to work (committer identity) |
|
// We use a generic identity since the server is making the commit on behalf of the system |
|
try { |
|
await workGit.addConfig('user.name', 'GitRepublic', false, 'local'); |
|
await workGit.addConfig('user.email', 'gitrepublic@gitrepublic.web', false, 'local'); |
|
logger.debug({ repoPath }, 'Configured git user.name and user.email for repository'); |
|
} catch (configError) { |
|
logger.warn({ repoPath, error: configError }, 'Failed to set git config, commit may fail'); |
|
} |
|
|
|
// Use the event timestamp for commit date |
|
const commitDate = new Date(event.created_at * 1000).toISOString(); |
|
const commitMessage = selfTransferEvent |
|
? 'Add Nostr repository announcement and initial ownership proof' |
|
: 'Add Nostr repository announcement'; |
|
|
|
// Note: Initial commits are unsigned. The repository owner can sign their own commits |
|
// when they make changes. The server should never sign commits on behalf of users. |
|
|
|
logger.info({ repoPath, commitMessage, isEmpty }, 'Committing announcement file'); |
|
await workGit.commit(commitMessage, filesToAdd, { |
|
'--author': `Nostr <${event.pubkey}@nostr>`, |
|
'--date': commitDate |
|
}); |
|
|
|
// Verify commit was created |
|
const commitHash = await workGit.revparse(['HEAD']).catch(() => null); |
|
if (!commitHash) { |
|
throw new Error('Commit was created but HEAD is not pointing to a valid commit'); |
|
} |
|
logger.info({ repoPath, commitHash, isEmpty, workDir }, 'Commit created successfully'); |
|
|
|
// Verify objects were created |
|
const workObjectsDir = join(workDir, '.git', 'objects'); |
|
if (!fsExistsSync(workObjectsDir)) { |
|
throw new Error(`Objects directory does not exist at ${workObjectsDir} after commit`); |
|
} |
|
const objectEntries = await readdir(workObjectsDir, { withFileTypes: true }); |
|
const hasObjects = objectEntries.some(entry => { |
|
if (entry.isDirectory()) return true; // Loose objects in subdirectories |
|
if (entry.isFile() && entry.name.endsWith('.pack')) return true; // Pack files |
|
return false; |
|
}); |
|
if (!hasObjects) { |
|
throw new Error(`No objects found in ${workObjectsDir} after commit - commit may have failed`); |
|
} |
|
logger.info({ repoPath, commitHash, objectCount: objectEntries.length }, 'Objects verified after commit'); |
|
|
|
// Push back to bare repo |
|
// Use preferred branch, then environment, then try 'main' first, then 'master' |
|
const defaultBranch = preferredDefaultBranch || process.env.DEFAULT_BRANCH || 'main'; |
|
|
|
if (isEmpty) { |
|
// For empty repos, directly copy objects and update refs (more reliable than push) |
|
try { |
|
logger.info({ repoPath, workDir, commitHash, defaultBranch }, 'Starting object copy for empty repo'); |
|
|
|
// Copy all objects from workdir to bare repo |
|
const workObjectsDir = join(workDir, '.git', 'objects'); |
|
const bareObjectsDir = join(repoPath, 'objects'); |
|
|
|
logger.info({ workObjectsDir, bareObjectsDir, exists: fsExistsSync(workObjectsDir) }, 'Checking objects directory'); |
|
|
|
// Ensure bare objects directory exists |
|
await mkdir(bareObjectsDir, { recursive: true }); |
|
|
|
// Copy object files (pack files and loose objects) |
|
if (fsExistsSync(workObjectsDir)) { |
|
const objectEntries = await readdir(workObjectsDir, { withFileTypes: true }); |
|
logger.info({ objectEntryCount: objectEntries.length }, 'Found object entries to copy'); |
|
|
|
for (const entry of objectEntries) { |
|
const sourcePath = join(workObjectsDir, entry.name); |
|
const targetPath = join(bareObjectsDir, entry.name); |
|
|
|
if (entry.isDirectory()) { |
|
// Copy subdirectory (for loose objects: XX/YYYY...) |
|
await mkdir(targetPath, { recursive: true }); |
|
const subEntries = await readdir(sourcePath, { withFileTypes: true }); |
|
logger.debug({ subdir: entry.name, subEntryCount: subEntries.length }, 'Copying object subdirectory'); |
|
for (const subEntry of subEntries) { |
|
const subSource = join(sourcePath, subEntry.name); |
|
const subTarget = join(targetPath, subEntry.name); |
|
if (subEntry.isFile()) { |
|
copyFileSync(subSource, subTarget); |
|
} |
|
} |
|
} else if (entry.isFile()) { |
|
// Copy file (pack files, etc.) |
|
logger.debug({ file: entry.name }, 'Copying object file'); |
|
copyFileSync(sourcePath, targetPath); |
|
} |
|
} |
|
logger.info({ repoPath }, 'Finished copying objects'); |
|
} else { |
|
logger.warn({ workObjectsDir }, 'Workdir objects directory does not exist'); |
|
} |
|
|
|
// Update the ref in bare repo |
|
const refPath = join(repoPath, 'refs', 'heads', defaultBranch); |
|
const refDir = dirname(refPath); |
|
await mkdir(refDir, { recursive: true }); |
|
await writeFile(refPath, `${commitHash}\n`); |
|
logger.info({ refPath, commitHash }, 'Updated branch ref'); |
|
|
|
// Update HEAD in bare repo to point to the new branch |
|
await bareGit.raw(['symbolic-ref', 'HEAD', `refs/heads/${defaultBranch}`]); |
|
logger.info({ repoPath, defaultBranch, commitHash }, 'Successfully copied objects and updated refs in bare repo'); |
|
} catch (copyError) { |
|
// If copy fails, try fallback branch |
|
const fallbackBranch = defaultBranch === 'main' ? 'master' : 'main'; |
|
try { |
|
// Rename current branch to fallback |
|
await workGit.raw(['-C', workDir, 'branch', '-m', fallbackBranch]); |
|
|
|
// Get the commit hash |
|
const commitHash = await workGit.revparse(['HEAD']); |
|
|
|
// Copy objects (same as above) |
|
const workObjectsDir = join(workDir, '.git', 'objects'); |
|
const bareObjectsDir = join(repoPath, 'objects'); |
|
await mkdir(bareObjectsDir, { recursive: true }); |
|
|
|
if (fsExistsSync(workObjectsDir)) { |
|
const objectEntries = await readdir(workObjectsDir, { withFileTypes: true }); |
|
for (const entry of objectEntries) { |
|
const sourcePath = join(workObjectsDir, entry.name); |
|
const targetPath = join(bareObjectsDir, entry.name); |
|
|
|
if (entry.isDirectory()) { |
|
await mkdir(targetPath, { recursive: true }); |
|
const subEntries = await readdir(sourcePath, { withFileTypes: true }); |
|
for (const subEntry of subEntries) { |
|
const subSource = join(sourcePath, subEntry.name); |
|
const subTarget = join(targetPath, subEntry.name); |
|
if (subEntry.isFile()) { |
|
copyFileSync(subSource, subTarget); |
|
} |
|
} |
|
} else if (entry.isFile()) { |
|
copyFileSync(sourcePath, targetPath); |
|
} |
|
} |
|
} |
|
|
|
// Update ref |
|
const refPath = join(repoPath, 'refs', 'heads', fallbackBranch); |
|
const refDir = dirname(refPath); |
|
await mkdir(refDir, { recursive: true }); |
|
await writeFile(refPath, `${commitHash}\n`); |
|
|
|
// Update HEAD |
|
await bareGit.raw(['symbolic-ref', 'HEAD', `refs/heads/${fallbackBranch}`]); |
|
|
|
logger.info({ repoPath, fallbackBranch, commitHash }, 'Successfully copied objects and updated refs with fallback branch'); |
|
} catch (fallbackError) { |
|
logger.error({ repoPath, defaultBranch, fallbackBranch, copyError, fallbackError }, 'Failed to copy objects and update refs'); |
|
throw fallbackError; // Re-throw to be caught by outer try-catch |
|
} |
|
} |
|
} else { |
|
// For non-empty repos, push normally |
|
await workGit.push(['origin', defaultBranch]).catch(async () => { |
|
// If default branch doesn't exist, try to create it |
|
try { |
|
await workGit.checkout(['-b', defaultBranch]); |
|
await workGit.push(['origin', defaultBranch]); |
|
} catch { |
|
// If default branch creation fails, try 'main' or 'master' as fallback |
|
const fallbackBranch = defaultBranch === 'main' ? 'master' : 'main'; |
|
try { |
|
await workGit.checkout(['-b', fallbackBranch]); |
|
await workGit.push(['origin', fallbackBranch]); |
|
} catch { |
|
// If all fails, log but don't throw - announcement is saved |
|
logger.warn({ repoPath, defaultBranch, fallbackBranch }, 'Failed to push announcement to any branch'); |
|
} |
|
} |
|
}); |
|
} |
|
} |
|
|
|
// Clean up |
|
await rm(workDir, { recursive: true, force: true }); |
|
} catch (error) { |
|
const errorMessage = error instanceof Error ? error.message : String(error); |
|
const errorStack = error instanceof Error ? error.stack : undefined; |
|
logger.error({ |
|
error: errorMessage, |
|
errorStack, |
|
repoPath, |
|
eventId: event.id, |
|
isEmpty |
|
}, 'Failed to ensure announcement in repo'); |
|
|
|
// Don't throw - this is now non-blocking |
|
// The announcement is available from relays, so this is just for offline papertrail |
|
// Even for empty repos, we don't throw since provisioning should succeed regardless |
|
} |
|
} |
|
|
|
/** |
|
* Check if a repository already has an announcement in nostr/repo-events.jsonl |
|
* Used to determine if this is a truly new repo or an existing one being added |
|
*/ |
|
async hasAnnouncementInRepoFile(repoPath: string): Promise<boolean> { |
|
if (!existsSync(repoPath)) { |
|
return false; |
|
} |
|
|
|
try { |
|
const git: SimpleGit = simpleGit(); |
|
const repoName = this.urlParser.parseRepoPathForName(repoPath)?.repoName || 'temp'; |
|
const workDir = join(repoPath, '..', `${repoName}.check`); |
|
|
|
// Clean up if exists |
|
if (existsSync(workDir)) { |
|
await rm(workDir, { recursive: true, force: true }); |
|
} |
|
await mkdir(workDir, { recursive: true }); |
|
|
|
// Try to clone and check for announcement in nostr/repo-events.jsonl |
|
await git.clone(repoPath, workDir); |
|
const hasAnnouncement = await this.hasAnnouncementInRepo(workDir, undefined); |
|
|
|
// Clean up |
|
await rm(workDir, { recursive: true, force: true }); |
|
|
|
return hasAnnouncement; |
|
} catch { |
|
// If we can't check, assume it doesn't have one |
|
return false; |
|
} |
|
} |
|
}
|
|
|