diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 2dfa373..58e6588 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -22,3 +22,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771607520,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"2040e0adbed520ee9a21c6a1c7df48fae27021c1d3474b584388cd5ddafc6a49","sig":"893b4881e3876c0f556e3be991e9c6e99c9f5933bc9755e4075c1d0bfea95750b2318f3d3409d689c7e9a862cf053db0e7d3083ee28cf48ffbe794583c3ad783"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771612082,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","harmonize user.name and user.email\nadd settings menu\nautosave OFF 10 min"]],"content":"Signed commit: harmonize user.name and user.email\nadd settings menu\nautosave OFF 10 min","id":"80834df600e5ad22f44fc26880333d28054895b7b5fde984921fab008a27ce6d","sig":"41991d089d26e3f90094dcebd1dee7504c59cadd0ea2f4dfe8693106d9000a528157fb905aec9001e0b8f3ef9e8590557f3df6961106859775d9416b546a44c0"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771612354,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","remove theme button from bar"]],"content":"Signed commit: remove theme button from bar","id":"fc758a0681c072108b196911bbeee6d49df1efe635d5d78427b7874be4d6e657","sig":"6c0e991e960a29c623c936ab2a31478a85907780eda692c035762deabc740ca0a76df113f5ce853a6d839b023e2b483ce2d7686c40b91c4cea5f32945799a31f"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771614223,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix websocket problems\nhandle replaceable events correctly\nfix css for docs"]],"content":"Signed commit: fix websocket problems\nhandle replaceable events correctly\nfix css for docs","id":"88c007de2bd48c32c879b9950f0908270b009c6341a97b1c0164982648beb3d9","sig":"c9250a23d38671a5b1c0d3389e003931222385ca9591b9b332585c8c639e2af2a7b2e8cac9c1ca5bd47df19b330622b1a1874e586f112fa84a4a7aa4347c7456"} diff --git a/src/lib/services/git/file-manager.ts b/src/lib/services/git/file-manager.ts index 29d05c4..581f112 100644 --- a/src/lib/services/git/file-manager.ts +++ b/src/lib/services/git/file-manager.ts @@ -63,7 +63,7 @@ export class FileManager { * More efficient than cloning the entire repo for each operation * Security: Validates branch name to prevent path traversal attacks */ - private async getWorktree(repoPath: string, branch: string, npub: string, repoName: string): Promise { + async getWorktree(repoPath: string, branch: string, npub: string, repoName: string): Promise { // Security: Validate branch name to prevent path traversal if (!isValidBranchName(branch)) { throw new Error(`Invalid branch name: ${branch}`); @@ -263,7 +263,7 @@ export class FileManager { /** * Remove a worktree */ - private async removeWorktree(repoPath: string, worktreePath: string): Promise { + async removeWorktree(repoPath: string, worktreePath: string): Promise { try { // Use spawn for worktree remove (safer than exec) await new Promise((resolve, reject) => { @@ -315,7 +315,7 @@ export class FileManager { /** * Get the full path to a repository */ - private getRepoPath(npub: string, repoName: string): string { + getRepoPath(npub: string, repoName: string): string { const repoPath = join(this.repoRoot, npub, `${repoName}.git`); // Security: Ensure the resolved path is within repoRoot to prevent path traversal // Normalize paths to handle Windows/Unix differences @@ -755,9 +755,101 @@ export class FileManager { } // Commit - await workGit.commit(finalCommitMessage, [filePath], { + const commitResult = await workGit.commit(finalCommitMessage, [filePath], { '--author': `${authorName} <${authorEmail}>` - }); + }) as string | { commit: string }; + + // Get commit hash from result + let commitHash: string; + if (typeof commitResult === 'string') { + commitHash = commitResult.trim(); + } else if (commitResult && typeof commitResult === 'object' && 'commit' in commitResult) { + commitHash = String(commitResult.commit); + } else { + // Fallback: get latest commit hash + commitHash = await workGit.revparse(['HEAD']); + } + + // Save commit signature event to nostr folder if signing was used + if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) { + try { + // Get the signature event that was used (already created above) + let signatureEvent: NostrEvent; + if (signingOptions.commitSignatureEvent) { + signatureEvent = signingOptions.commitSignatureEvent; + } else { + // Re-create it to get the event object + const { signedMessage: _, signatureEvent: event } = await createGitCommitSignature( + commitMessage, + authorName, + authorEmail, + signingOptions + ); + signatureEvent = event; + } + + // Update signature event with actual commit hash + const { updateCommitSignatureWithHash } = await import('./commit-signer.js'); + const updatedEvent = updateCommitSignatureWithHash(signatureEvent, commitHash); + + // Save to nostr/commit-signatures.jsonl (use workDir since we have it) + await this.saveCommitSignatureEventToWorktree(workDir, updatedEvent); + + // Check if repo is private - only publish to relays if public + const isPrivate = await this.isRepoPrivate(npub, repoName); + if (!isPrivate) { + // Public repo: publish commit signature event to relays + try { + const { NostrClient } = await import('../nostr/nostr-client.js'); + const { DEFAULT_NOSTR_RELAYS } = await import('../../config.js'); + const { getUserRelays } = await import('../nostr/user-relays.js'); + const { combineRelays } = await import('../../config.js'); + + // Get user's preferred relays (outbox/inbox from kind 10002) + const { nip19 } = await import('nostr-tools'); + const { requireNpubHex } = await import('../../utils/npub-utils.js'); + const userPubkeyHex = requireNpubHex(npub); + + const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + const { inbox, outbox } = await getUserRelays(userPubkeyHex, nostrClient); + + // Use user's outbox relays if available, otherwise inbox, otherwise defaults + const userRelays = outbox.length > 0 + ? combineRelays(outbox, DEFAULT_NOSTR_RELAYS) + : inbox.length > 0 + ? combineRelays(inbox, DEFAULT_NOSTR_RELAYS) + : DEFAULT_NOSTR_RELAYS; + + // Publish to relays (non-blocking - don't fail if publishing fails) + const publishResult = await nostrClient.publishEvent(updatedEvent, userRelays); + if (publishResult.success.length > 0) { + logger.debug({ + eventId: updatedEvent.id, + commitHash, + relays: publishResult.success + }, 'Published commit signature event to relays'); + } + if (publishResult.failed.length > 0) { + logger.warn({ + eventId: updatedEvent.id, + failed: publishResult.failed + }, 'Some relays failed to publish commit signature event'); + } + } catch (publishErr) { + // Log but don't fail - publishing is nice-to-have, saving to repo is the important part + const sanitizedErr = sanitizeError(publishErr); + logger.debug({ error: sanitizedErr, repoPath, filePath }, 'Failed to publish commit signature event to relays'); + } + } else { + // Private repo: only save to repo, don't publish to relays + logger.debug({ repoPath, filePath }, 'Private repo - commit signature event saved to repo only (not published to relays)'); + } + } catch (err) { + // Log but don't fail - saving event is nice-to-have + const sanitizedErr = sanitizeError(err); + logger.debug({ error: sanitizedErr, repoPath, filePath }, 'Failed to save commit signature event'); + } + } // Note: No push needed - worktrees of bare repos share the same object database, // so the commit is already in the bare repository. We don't push to remote origin @@ -772,6 +864,109 @@ export class FileManager { } } + /** + * Save commit signature event to nostr/commit-signatures.jsonl in a worktree + */ + private async saveCommitSignatureEventToWorktree(worktreePath: string, event: NostrEvent): Promise { + try { + const { mkdir, writeFile } = await import('fs/promises'); + const { join } = await import('path'); + + // Create nostr directory in worktree + const nostrDir = join(worktreePath, 'nostr'); + await mkdir(nostrDir, { recursive: true }); + + // Append to commit-signatures.jsonl + const jsonlFile = join(nostrDir, 'commit-signatures.jsonl'); + const eventLine = JSON.stringify(event) + '\n'; + await writeFile(jsonlFile, eventLine, { flag: 'a', encoding: 'utf-8' }); + } catch (err) { + logger.debug({ error: err, worktreePath }, 'Failed to save commit signature event to nostr folder'); + // Don't throw - this is a nice-to-have feature + } + } + + /** + * Save a repo event (announcement or transfer) to nostr/repo-events.jsonl + * This provides a standard location for all repo-related Nostr events for easy analysis + */ + async saveRepoEventToWorktree( + worktreePath: string, + event: NostrEvent, + eventType: 'announcement' | 'transfer' + ): Promise { + try { + const { mkdir, writeFile } = await import('fs/promises'); + const { join } = await import('path'); + + // 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' }); + } 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 + } + } + + /** + * Check if a repository is private by fetching its announcement event + */ + private async isRepoPrivate(npub: string, repoName: string): Promise { + try { + const { requireNpubHex } = await import('../../utils/npub-utils.js'); + const repoOwnerPubkey = requireNpubHex(npub); + + // Fetch the repository announcement + const { NostrClient } = await import('../nostr/nostr-client.js'); + const { DEFAULT_NOSTR_RELAYS } = await import('../../config.js'); + const { KIND } = await import('../../types/nostr.js'); + + 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) { + // No announcement found - assume public (default) + return false; + } + + const announcement = events[0]; + + // Check for ["private", "true"] tag + const privateTag = announcement.tags.find(t => t[0] === 'private' && t[1] === 'true'); + if (privateTag) return true; + + // Check for ["private"] tag (just the tag name, no value) + const privateTagOnly = announcement.tags.find(t => t[0] === 'private' && (!t[1] || t[1] === '')); + if (privateTagOnly) return true; + + // Check for ["t", "private"] tag (topic tag) + const topicTag = announcement.tags.find(t => t[0] === 't' && t[1] === 'private'); + if (topicTag) return true; + + return false; + } catch (err) { + // If we can't determine, default to public (safer - allows publishing) + logger.debug({ error: err, npub, repoName }, 'Failed to check repo privacy, defaulting to public'); + return false; + } + } + /** * Get list of branches (with caching) */ diff --git a/src/lib/services/git/repo-manager.ts b/src/lib/services/git/repo-manager.ts index 003a28b..82e7e0d 100644 --- a/src/lib/services/git/repo-manager.ts +++ b/src/lib/services/git/repo-manager.ts @@ -150,6 +150,9 @@ export class RepoManager { // If there are other clone URLs, sync from them after creating the repo if (otherUrls.length > 0) { await this.syncFromRemotes(repoPath.fullPath, otherUrls); + } else { + // No external URLs - this is a brand new repo, create initial branch and README + await this.createInitialBranchAndReadme(repoPath.fullPath, repoPath.npub, repoPath.repoName, event); } } else if (isExistingRepo && selfTransferEvent) { // For existing repos, we might want to add the self-transfer event @@ -160,6 +163,137 @@ export class RepoManager { } } + /** + * Create initial branch and README.md for a new repository + */ + private async createInitialBranchAndReadme( + repoPath: string, + npub: string, + repoName: string, + announcementEvent: NostrEvent + ): Promise { + try { + // Get default branch from environment or use 'master' + const defaultBranch = process.env.DEFAULT_BRANCH || 'master'; + + // Get repo name from d-tag or use repoName from path + const dTag = announcementEvent.tags.find(t => t[0] === 'd')?.[1] || repoName; + + // Get name tag for README title, fallback to d-tag + const nameTag = announcementEvent.tags.find(t => t[0] === 'name')?.[1] || dTag; + + // Get author info from user profile (fetch from relays) + const { fetchUserProfile, extractProfileData, getUserName, getUserEmail } = await import('../../utils/user-profile.js'); + const { nip19 } = await import('nostr-tools'); + const { DEFAULT_NOSTR_RELAYS } = await import('../../config.js'); + const userNpub = nip19.npubEncode(announcementEvent.pubkey); + + const profileEvent = await fetchUserProfile(announcementEvent.pubkey, DEFAULT_NOSTR_RELAYS); + const profile = extractProfileData(profileEvent); + const authorName = getUserName(profile, announcementEvent.pubkey, userNpub); + const authorEmail = getUserEmail(profile, announcementEvent.pubkey, userNpub); + + // Create README.md content + const readmeContent = `# ${nameTag} + +Welcome to your new GitRepublic repo. + +You can use this read-me file to explain the purpose of this repo to everyone who looks at it. You can also make a ReadMe.adoc file and delete this one, if you prefer. GitRepublic supports both markups. + +Your commits will all be signed by your Nostr keys and saved to the event files in the ./nostr folder. +`; + + // Generate announcement file content + const { generateVerificationFile, VERIFICATION_FILE_PATH } = await import('../nostr/repo-verification.js'); + + // Try to get announcement file content from client-signed event (kind 1642) first + let announcementFileContent: string | null = null; + try { + const { NostrClient } = await import('../nostr/nostr-client.js'); + const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + + // Look for a kind 1642 event that references this announcement + const announcementFileEvents = await nostrClient.fetchEvents([ + { + kinds: [1642], // Announcement file event kind + authors: [announcementEvent.pubkey], + '#e': [announcementEvent.id], // References this announcement + limit: 1 + } + ]); + + if (announcementFileEvents.length > 0) { + // Extract announcement file content from the client-signed event + announcementFileContent = announcementFileEvents[0].content; + logger.info({ repoPath, announcementFileEventId: announcementFileEvents[0].id }, 'Using client-signed announcement file for initial commit'); + } + } catch (err) { + logger.debug({ error: err, repoPath }, 'Failed to fetch announcement file event, will generate from announcement event'); + } + + // If client didn't provide announcement file, generate it from the announcement event + if (!announcementFileContent) { + announcementFileContent = generateVerificationFile(announcementEvent, announcementEvent.pubkey); + } + + // Use FileManager to create the initial branch and files + const { FileManager } = await import('./file-manager.js'); + const fileManager = new FileManager(this.repoRoot); + + // For a new repo with no branches, we need to create an orphan branch first + // Check if repo has any branches + const git = simpleGit(repoPath); + let hasBranches = false; + try { + const branches = await git.branch(['-a']); + hasBranches = branches.all.length > 0; + } catch { + // No branches exist + hasBranches = false; + } + + if (!hasBranches) { + // Create orphan branch first (pass undefined for fromBranch to create orphan) + await fileManager.createBranch(npub, repoName, defaultBranch, undefined); + } + + // Create both README.md and announcement file in the initial commit + // We'll use a worktree to write both files and commit them together + const workDir = await fileManager.getWorktree(repoPath, defaultBranch, npub, repoName); + const { writeFile: writeFileFs, mkdir } = await import('fs/promises'); + const { join } = await import('path'); + + // Write README.md + const readmePath = join(workDir, 'README.md'); + await writeFileFs(readmePath, readmeContent, 'utf-8'); + + // Write announcement file + const announcementPath = join(workDir, VERIFICATION_FILE_PATH); + await writeFileFs(announcementPath, announcementFileContent, 'utf-8'); + + // Save repo announcement event to nostr/repo-events.jsonl (standard file for easy analysis) + await this.saveRepoEventToWorktree(workDir, announcementEvent, 'announcement'); + + // Stage both files + const workGit = simpleGit(workDir); + await workGit.add(['README.md', VERIFICATION_FILE_PATH]); + + // Commit both files together + const commitResult = await workGit.commit('Initial commit', ['README.md', VERIFICATION_FILE_PATH], { + '--author': `${authorName} <${authorEmail}>` + }); + + // Clean up worktree + await fileManager.removeWorktree(repoPath, workDir); + + logger.info({ npub, repoName, branch: defaultBranch }, 'Created initial branch and README.md'); + } catch (err) { + // Log but don't fail - initial README creation is nice-to-have + const sanitizedErr = sanitizeError(err); + logger.warn({ error: sanitizedErr, repoPath, npub, repoName }, 'Failed to create initial branch and README, continuing anyway'); + } + } + /** * Get git environment variables with Tor proxy if needed for .onion addresses * Security: Only whitelist necessary environment variables @@ -495,13 +629,17 @@ export class RepoManager { */ /** * Check if a repository is private based on announcement event - * A repo is private if it has a tag ["private", "true"] or ["t", "private"] + * A repo is private if it has a tag ["private"], ["private", "true"], or ["t", "private"] */ private isPrivateRepo(announcement: NostrEvent): boolean { // Check for ["private", "true"] tag const privateTag = announcement.tags.find(t => t[0] === 'private' && t[1] === 'true'); if (privateTag) return true; + // Check for ["private"] tag (just the tag name, no value) + const privateTagOnly = announcement.tags.find(t => t[0] === 'private' && (!t[1] || t[1] === '')); + if (privateTagOnly) return true; + // Check for ["t", "private"] tag (topic tag) const topicTag = announcement.tags.find(t => t[0] === 't' && t[1] === 'private'); if (topicTag) return true; @@ -807,6 +945,12 @@ export class RepoManager { const announcementPath = join(workDir, VERIFICATION_FILE_PATH); writeFileSync(announcementPath, announcementFileContent, 'utf-8'); + // Save repo events to nostr/repo-events.jsonl (standard file for easy analysis) + await this.saveRepoEventToWorktree(workDir, event, 'announcement'); + if (selfTransferEvent) { + await this.saveRepoEventToWorktree(workDir, selfTransferEvent, 'transfer'); + } + // If self-transfer event is provided, include it in the commit const filesToAdd = [VERIFICATION_FILE_PATH]; if (selfTransferEvent) { @@ -872,6 +1016,37 @@ export class RepoManager { return { repoName: match[1] }; } + /** + * Save a repo event (announcement or transfer) to nostr/repo-events.jsonl + * This provides a standard location for all repo-related Nostr events for easy analysis + */ + private async saveRepoEventToWorktree( + worktreePath: string, + event: NostrEvent, + eventType: 'announcement' | 'transfer' + ): Promise { + try { + const { mkdir, writeFile } = await import('fs/promises'); + const { join } = await import('path'); + + // 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' }); + } 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 + } + } + /** * Check if a repository already has an announcement file * Used to determine if this is a truly new repo or an existing one being added diff --git a/src/lib/services/nostr/maintainer-service.ts b/src/lib/services/nostr/maintainer-service.ts index 24b7fe8..8f44df9 100644 --- a/src/lib/services/nostr/maintainer-service.ts +++ b/src/lib/services/nostr/maintainer-service.ts @@ -29,13 +29,17 @@ export class MaintainerService { /** * Check if a repository is private - * A repo is private if it has a tag ["private", "true"] or ["t", "private"] + * A repo is private if it has a tag ["private"], ["private", "true"], or ["t", "private"] */ private isPrivateRepo(announcement: NostrEvent): boolean { // Check for ["private", "true"] tag const privateTag = announcement.tags.find(t => t[0] === 'private' && t[1] === 'true'); if (privateTag) return true; + // Check for ["private"] tag (just the tag name, no value) + const privateTagOnly = announcement.tags.find(t => t[0] === 'private' && (!t[1] || t[1] === '')); + if (privateTagOnly) return true; + // Check for ["t", "private"] tag (topic tag) const topicTag = announcement.tags.find(t => t[0] === 't' && t[1] === 'private'); if (topicTag) return true; diff --git a/src/routes/api/repos/[npub]/[repo]/transfer/+server.ts b/src/routes/api/repos/[npub]/[repo]/transfer/+server.ts index d3abd0e..1f338a3 100644 --- a/src/routes/api/repos/[npub]/[repo]/transfer/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/transfer/+server.ts @@ -128,6 +128,17 @@ export const POST: RequestHandler = withRepoValidation( // Save to repo if it exists locally if (fileManager.repoExists(repoContext.npub, repoContext.repo)) { + // Get worktree to save to repo-events.jsonl + const defaultBranch = await fileManager.getDefaultBranch(repoContext.npub, repoContext.repo).catch(() => 'main'); + const repoPath = fileManager.getRepoPath(repoContext.npub, repoContext.repo); + const workDir = await fileManager.getWorktree(repoPath, defaultBranch, repoContext.npub, repoContext.repo); + + // Save to repo-events.jsonl (standard file for easy analysis) + await fileManager.saveRepoEventToWorktree(workDir, transferEvent as NostrEvent, 'transfer').catch(err => { + logger.debug({ error: err }, 'Failed to save transfer event to repo-events.jsonl'); + }); + + // Also save individual transfer file await fileManager.writeFile( repoContext.npub, repoContext.repo, @@ -136,11 +147,16 @@ export const POST: RequestHandler = withRepoValidation( `Add ownership transfer event: ${transferEvent.id.slice(0, 16)}...`, 'Nostr', `${requestContext.userPubkeyHex}@nostr`, - 'main' + defaultBranch ).catch(err => { // Log but don't fail - publishing to relays is more important logger.warn({ error: err, npub: repoContext.npub, repo: repoContext.repo }, 'Failed to save transfer event to repo'); }); + + // Clean up worktree + await fileManager.removeWorktree(repoPath, workDir).catch(err => { + logger.debug({ error: err }, 'Failed to remove worktree after saving transfer event'); + }); } else { logger.debug({ npub: repoContext.npub, repo: repoContext.repo }, 'Repo does not exist locally, skipping transfer event save to repo'); }