Browse Source

handle new repo creation

Nostr-Signature: 59bc1c664590bcbe3e05c4151154590aa1ca4399e2a48d64e94bb960e6056265 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc ae666597fc46256915abeec93be97c5d9559eaef90aa65208740f32fe4b00531a51ba432ed9a2089a7ec860ac1dc9a7a4a5d8e84db2a7ae433dd5c668f0b5035
main
Silberengel 3 weeks ago
parent
commit
2581d06cc0
  1. 1
      nostr/commit-signatures.jsonl
  2. 205
      src/lib/services/git/file-manager.ts
  3. 177
      src/lib/services/git/repo-manager.ts
  4. 6
      src/lib/services/nostr/maintainer-service.ts
  5. 18
      src/routes/api/repos/[npub]/[repo]/transfer/+server.ts

1
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":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":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":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"}

205
src/lib/services/git/file-manager.ts

@ -63,7 +63,7 @@ export class FileManager {
* More efficient than cloning the entire repo for each operation * More efficient than cloning the entire repo for each operation
* Security: Validates branch name to prevent path traversal attacks * Security: Validates branch name to prevent path traversal attacks
*/ */
private async getWorktree(repoPath: string, branch: string, npub: string, repoName: string): Promise<string> { async getWorktree(repoPath: string, branch: string, npub: string, repoName: string): Promise<string> {
// Security: Validate branch name to prevent path traversal // Security: Validate branch name to prevent path traversal
if (!isValidBranchName(branch)) { if (!isValidBranchName(branch)) {
throw new Error(`Invalid branch name: ${branch}`); throw new Error(`Invalid branch name: ${branch}`);
@ -263,7 +263,7 @@ export class FileManager {
/** /**
* Remove a worktree * Remove a worktree
*/ */
private async removeWorktree(repoPath: string, worktreePath: string): Promise<void> { async removeWorktree(repoPath: string, worktreePath: string): Promise<void> {
try { try {
// Use spawn for worktree remove (safer than exec) // Use spawn for worktree remove (safer than exec)
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@ -315,7 +315,7 @@ export class FileManager {
/** /**
* Get the full path to a repository * 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`); const repoPath = join(this.repoRoot, npub, `${repoName}.git`);
// Security: Ensure the resolved path is within repoRoot to prevent path traversal // Security: Ensure the resolved path is within repoRoot to prevent path traversal
// Normalize paths to handle Windows/Unix differences // Normalize paths to handle Windows/Unix differences
@ -755,9 +755,101 @@ export class FileManager {
} }
// Commit // Commit
await workGit.commit(finalCommitMessage, [filePath], { const commitResult = await workGit.commit(finalCommitMessage, [filePath], {
'--author': `${authorName} <${authorEmail}>` '--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, // 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 // 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<void> {
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<void> {
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<boolean> {
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) * Get list of branches (with caching)
*/ */

177
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 there are other clone URLs, sync from them after creating the repo
if (otherUrls.length > 0) { if (otherUrls.length > 0) {
await this.syncFromRemotes(repoPath.fullPath, otherUrls); 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) { } else if (isExistingRepo && selfTransferEvent) {
// For existing repos, we might want to add the self-transfer event // 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<void> {
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 * Get git environment variables with Tor proxy if needed for .onion addresses
* Security: Only whitelist necessary environment variables * Security: Only whitelist necessary environment variables
@ -495,13 +629,17 @@ export class RepoManager {
*/ */
/** /**
* Check if a repository is private based on announcement event * 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 { private isPrivateRepo(announcement: NostrEvent): boolean {
// Check for ["private", "true"] tag // Check for ["private", "true"] tag
const privateTag = announcement.tags.find(t => t[0] === 'private' && t[1] === 'true'); const privateTag = announcement.tags.find(t => t[0] === 'private' && t[1] === 'true');
if (privateTag) return 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) // Check for ["t", "private"] tag (topic tag)
const topicTag = announcement.tags.find(t => t[0] === 't' && t[1] === 'private'); const topicTag = announcement.tags.find(t => t[0] === 't' && t[1] === 'private');
if (topicTag) return true; if (topicTag) return true;
@ -807,6 +945,12 @@ export class RepoManager {
const announcementPath = join(workDir, VERIFICATION_FILE_PATH); const announcementPath = join(workDir, VERIFICATION_FILE_PATH);
writeFileSync(announcementPath, announcementFileContent, 'utf-8'); 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 // If self-transfer event is provided, include it in the commit
const filesToAdd = [VERIFICATION_FILE_PATH]; const filesToAdd = [VERIFICATION_FILE_PATH];
if (selfTransferEvent) { if (selfTransferEvent) {
@ -872,6 +1016,37 @@ export class RepoManager {
return { repoName: match[1] }; 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<void> {
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 * 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 * Used to determine if this is a truly new repo or an existing one being added

6
src/lib/services/nostr/maintainer-service.ts

@ -29,13 +29,17 @@ export class MaintainerService {
/** /**
* Check if a repository is private * 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 { private isPrivateRepo(announcement: NostrEvent): boolean {
// Check for ["private", "true"] tag // Check for ["private", "true"] tag
const privateTag = announcement.tags.find(t => t[0] === 'private' && t[1] === 'true'); const privateTag = announcement.tags.find(t => t[0] === 'private' && t[1] === 'true');
if (privateTag) return 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) // Check for ["t", "private"] tag (topic tag)
const topicTag = announcement.tags.find(t => t[0] === 't' && t[1] === 'private'); const topicTag = announcement.tags.find(t => t[0] === 't' && t[1] === 'private');
if (topicTag) return true; if (topicTag) return true;

18
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 // Save to repo if it exists locally
if (fileManager.repoExists(repoContext.npub, repoContext.repo)) { 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( await fileManager.writeFile(
repoContext.npub, repoContext.npub,
repoContext.repo, repoContext.repo,
@ -136,11 +147,16 @@ export const POST: RequestHandler = withRepoValidation(
`Add ownership transfer event: ${transferEvent.id.slice(0, 16)}...`, `Add ownership transfer event: ${transferEvent.id.slice(0, 16)}...`,
'Nostr', 'Nostr',
`${requestContext.userPubkeyHex}@nostr`, `${requestContext.userPubkeyHex}@nostr`,
'main' defaultBranch
).catch(err => { ).catch(err => {
// Log but don't fail - publishing to relays is more important // 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'); 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 { } else {
logger.debug({ npub: repoContext.npub, repo: repoContext.repo }, 'Repo does not exist locally, skipping transfer event save to repo'); logger.debug({ npub: repoContext.npub, repo: repoContext.repo }, 'Repo does not exist locally, skipping transfer event save to repo');
} }

Loading…
Cancel
Save