Browse Source

bug-fixes

Nostr-Signature: 02dcdcda1083cffd91dbf8906716c2ae09f06f77ef8590802afecd85f0b3108a 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 13d2b30ed37af03fd47dc09536058babb4dc63d1cfc55b8f38651ffd6342abcddc840b543c085b047721e9102b2d07e3dae78ff31d5990c92c04410ef1efcd5b
main
Silberengel 2 weeks ago
parent
commit
ca29f89e02
  1. 1
      nostr/commit-signatures.jsonl
  2. 15
      src/app.css
  3. 64
      src/lib/components/RepoHeaderEnhanced.svelte
  4. 219
      src/lib/services/git/announcement-manager.ts
  5. 7
      src/lib/services/git/file-manager/commit-operations.ts
  6. 20
      src/lib/services/git/repo-manager.ts
  7. 22
      src/lib/services/nostr/nostr-client.ts
  8. 48
      src/lib/services/nostr/relay-write-proof.ts
  9. 6
      src/lib/services/nostr/repo-polling.ts
  10. 1
      src/lib/styles/repo.css
  11. 10
      src/lib/utils/api-context.ts
  12. 6
      src/routes/api/repos/[npub]/[repo]/branches/+server.ts
  13. 39
      src/routes/api/repos/[npub]/[repo]/clone/+server.ts
  14. 9
      src/routes/api/repos/[npub]/[repo]/commits/+server.ts
  15. 205
      src/routes/api/repos/[npub]/[repo]/fork/+server.ts
  16. 6
      src/routes/api/repos/[npub]/[repo]/tree/+server.ts
  17. 89
      src/routes/api/repos/[npub]/[repo]/verify/+server.ts
  18. 161
      src/routes/repos/+page.svelte
  19. 27
      src/routes/repos/[npub]/[repo]/+page.svelte
  20. 18
      src/routes/repos/[npub]/[repo]/services/repo-operations.ts
  21. 10
      static/icons/git-fork.svg
  22. 7
      static/icons/hard-drive.svg

1
nostr/commit-signatures.jsonl

@ -108,3 +108,4 @@
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772142448,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 11"]],"content":"Signed commit: refactor 11","id":"bb9d5c56a291e48221df96868fb925e309cb560aa350c2cf5f9c4ddd5e5c4a6b","sig":"75662c916bf4d8bb3d70cdae4e4882382692c6f1ca67598a69abe3dc96069ef6f2bda5a1b8f91b724aa43b3cb3c6b8ad6cbce286b5d165377a34a881e7275d2a"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772142448,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 11"]],"content":"Signed commit: refactor 11","id":"bb9d5c56a291e48221df96868fb925e309cb560aa350c2cf5f9c4ddd5e5c4a6b","sig":"75662c916bf4d8bb3d70cdae4e4882382692c6f1ca67598a69abe3dc96069ef6f2bda5a1b8f91b724aa43b3cb3c6b8ad6cbce286b5d165377a34a881e7275d2a"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772142558,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","remove redundancy"]],"content":"Signed commit: remove redundancy","id":"11ac91151bebd4dd49b91bcdef7b0b7157f0afd8ce710f7231be4860fb073d08","sig":"a7efcafa5ea83a0c37eae4562a84a7581c3d5c5dd1416f8f3e2bd2633d8523ae0eb7cc56dc4292c127ea16fb2dd5bc639483cb096263a850956b47312ed7ff6f"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772142558,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","remove redundancy"]],"content":"Signed commit: remove redundancy","id":"11ac91151bebd4dd49b91bcdef7b0b7157f0afd8ce710f7231be4860fb073d08","sig":"a7efcafa5ea83a0c37eae4562a84a7581c3d5c5dd1416f8f3e2bd2633d8523ae0eb7cc56dc4292c127ea16fb2dd5bc639483cb096263a850956b47312ed7ff6f"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772182112,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 12"]],"content":"Signed commit: refactor 12","id":"73671ae6535309f9eae164f7a3ec403b1bc818ef811b9692fd0122d0b72c2774","sig":"0df56b009f5afb77de334225ab30cff55586ac0cf48f5ee435428201a1e72dc357a0fb5e80ef196f5bd76d6d448056d25f0feab0b1bcbe45f9af1a2a0d5453ca"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772182112,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 12"]],"content":"Signed commit: refactor 12","id":"73671ae6535309f9eae164f7a3ec403b1bc818ef811b9692fd0122d0b72c2774","sig":"0df56b009f5afb77de334225ab30cff55586ac0cf48f5ee435428201a1e72dc357a0fb5e80ef196f5bd76d6d448056d25f0feab0b1bcbe45f9af1a2a0d5453ca"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772188835,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 13"]],"content":"Signed commit: refactor 13","id":"f41c8662dcbf1be408c560d11eda0890c40582a8ea8bb3220116e645cc6a2bb5","sig":"2b7b70089cecfa4652fe236fa586a6fe1b05c1c95434a160717cbf5ee2f37382cdd8e8f31d7b3a7576ee5264e9e70c7a8651591caaea0cd311d1be4c561d282f"}

15
src/app.css

@ -938,11 +938,22 @@ button.theme-option.active img.theme-icon-option,
[data-theme="dark"] .verification-badge.unverified .icon-inline img, [data-theme="dark"] .verification-badge.unverified .icon-inline img,
[data-theme="black"] .verification-badge.unverified .icon-inline img { [data-theme="black"] .verification-badge.unverified .icon-inline img {
filter: brightness(0) saturate(100%) invert(0.5) sepia(1) saturate(3) hue-rotate(0deg); filter: brightness(0) saturate(100%) invert(1) sepia(1) saturate(5) hue-rotate(0deg);
} }
[data-theme="light"] .verification-badge.unverified .icon-inline img { [data-theme="light"] .verification-badge.unverified .icon-inline img {
filter: brightness(0) saturate(100%) invert(0.3) sepia(1) saturate(3) hue-rotate(0deg); filter: brightness(0) saturate(100%) invert(0.2) sepia(1) saturate(3) hue-rotate(0deg);
}
/* Alert triangle icons in verification badges - ensure visibility on dark backgrounds */
.verification-badge.unverified img[src*="alert-triangle.svg"].icon-inline,
.verification-badge.unverified .icon-inline img[src*="alert-triangle.svg"] {
filter: brightness(0) saturate(100%) invert(1) sepia(1) saturate(5) hue-rotate(0deg);
}
[data-theme="light"] .verification-badge.unverified img[src*="alert-triangle.svg"].icon-inline,
[data-theme="light"] .verification-badge.unverified .icon-inline img[src*="alert-triangle.svg"] {
filter: brightness(0) saturate(100%) invert(0.2) sepia(1) saturate(3) hue-rotate(0deg);
} }
.clear-lookup-button img, .clear-lookup-button img,

64
src/lib/components/RepoHeaderEnhanced.svelte

@ -89,6 +89,28 @@
topics = [] topics = []
}: Props = $props(); }: Props = $props();
// Check if user is the repo owner
// userPubkey can be in npub format, so we need to decode it if needed
const userPubkeyHex = $derived.by(() => {
if (!userPubkey) return null;
// If it's already hex (64 chars), return as-is
if (/^[0-9a-f]{64}$/i.test(userPubkey)) {
return userPubkey.toLowerCase();
}
// Try to decode as npub
try {
const decoded = nip19.decode(userPubkey);
if (decoded.type === 'npub') {
return decoded.data as string;
}
} catch {
// Not an npub, assume it's hex
}
return userPubkey.toLowerCase();
});
const isOwner = $derived(userPubkeyHex && ownerPubkey && userPubkeyHex === ownerPubkey.toLowerCase());
let showMoreMenu = $state(false); let showMoreMenu = $state(false);
let showBranchMenu = $state(false); let showBranchMenu = $state(false);
let showOwnerMenu = $state(false); let showOwnerMenu = $state(false);
@ -208,20 +230,34 @@
Create Patch Create Patch
</button> </button>
{/if} {/if}
{#if (isRepoCloned === false || isRepoCloned === null) && onCloneToServer} {#if (isRepoCloned === false || isRepoCloned === null)}
<button {#if isOwner && onCloneToServer}
class="menu-item" <button
onclick={() => { class="menu-item"
if (hasUnlimitedAccess) { onclick={() => {
onCloneToServer(); if (hasUnlimitedAccess) {
} onCloneToServer();
showMoreMenu = false; }
}} showMoreMenu = false;
disabled={cloning || checkingCloneStatus || !hasUnlimitedAccess} }}
title={!hasUnlimitedAccess ? 'Unlimited access required to clone repositories' : undefined} disabled={cloning || checkingCloneStatus || !hasUnlimitedAccess}
> title={!hasUnlimitedAccess ? 'Unlimited access required to clone repositories' : undefined}
{cloning ? 'Cloning...' : (checkingCloneStatus ? 'Checking...' : 'Clone to Server')} >
</button> {cloning ? 'Cloning...' : (checkingCloneStatus ? 'Checking...' : 'Clone to Server')}
</button>
{:else if !isOwner && onFork && hasUnlimitedAccess}
<button
class="menu-item"
onclick={() => {
onFork();
showMoreMenu = false;
}}
disabled={forking || !hasUnlimitedAccess}
title={!hasUnlimitedAccess ? 'Unlimited access required to fork repositories' : 'Create a local-only fork (private, not published to Nostr)'}
>
{forking ? 'Forking...' : 'Fork to Server'}
</button>
{/if}
{/if} {/if}
{#if isMaintainer && onSettings} {#if isMaintainer && onSettings}
<button class="menu-item" onclick={() => { onSettings(); showMoreMenu = false; }}> <button class="menu-item" onclick={() => { onSettings(); showMoreMenu = false; }}>

219
src/lib/services/git/announcement-manager.ts

@ -5,8 +5,9 @@
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { readFile } from 'fs/promises'; import { readFile } from 'fs/promises';
import { join } from 'path'; import { join, dirname } from 'path';
import { mkdir, writeFile, rm } from 'fs/promises'; import { mkdir, writeFile, rm, readdir } from 'fs/promises';
import { copyFileSync, mkdirSync, existsSync as fsExistsSync } from 'fs';
import simpleGit, { type SimpleGit } from 'simple-git'; import simpleGit, { type SimpleGit } from 'simple-git';
import logger from '../logger.js'; import logger from '../logger.js';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
@ -190,6 +191,7 @@ export class AnnouncementManager {
* Only saves if not already present (avoids redundant entries) * Only saves if not already present (avoids redundant entries)
*/ */
async ensureAnnouncementInRepo(repoPath: string, event: NostrEvent, selfTransferEvent?: NostrEvent): Promise<void> { async ensureAnnouncementInRepo(repoPath: string, event: NostrEvent, selfTransferEvent?: NostrEvent): Promise<void> {
let isEmpty = false;
try { try {
// Create a temporary working directory // Create a temporary working directory
const repoName = this.urlParser.parseRepoPathForName(repoPath)?.repoName || 'temp'; const repoName = this.urlParser.parseRepoPathForName(repoPath)?.repoName || 'temp';
@ -201,9 +203,37 @@ export class AnnouncementManager {
} }
await mkdir(workDir, { recursive: true }); await mkdir(workDir, { recursive: true });
// Clone the bare repo // 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(); const git: SimpleGit = simpleGit();
await git.clone(repoPath, workDir); let workGit: SimpleGit;
if (isEmpty) {
// Repo is empty - initialize worktree and create initial branch
// Use default branch from environment or try 'main' first, then 'master'
const defaultBranch = 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);
}
// Check if announcement already exists in nostr/repo-events.jsonl // Check if announcement already exists in nostr/repo-events.jsonl
const hasAnnouncement = await this.hasAnnouncementInRepo(workDir, event.id); const hasAnnouncement = await this.hasAnnouncementInRepo(workDir, event.id);
@ -233,7 +263,7 @@ export class AnnouncementManager {
// Only commit if we added files // Only commit if we added files
if (filesToAdd.length > 0) { if (filesToAdd.length > 0) {
const workGit: SimpleGit = simpleGit(workDir); logger.info({ repoPath, filesToAdd, isEmpty }, 'Adding files and committing announcement');
await workGit.add(filesToAdd); await workGit.add(filesToAdd);
// Use the event timestamp for commit date // Use the event timestamp for commit date
@ -245,38 +275,189 @@ export class AnnouncementManager {
// Note: Initial commits are unsigned. The repository owner can sign their own commits // 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. // 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, { await workGit.commit(commitMessage, filesToAdd, {
'--author': `Nostr <${event.pubkey}@nostr>`, '--author': `Nostr <${event.pubkey}@nostr>`,
'--date': commitDate '--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 // Push back to bare repo
// Use default branch from environment or try 'main' first, then 'master' // Use default branch from environment or try 'main' first, then 'master'
const defaultBranch = process.env.DEFAULT_BRANCH || 'main'; const defaultBranch = process.env.DEFAULT_BRANCH || 'main';
await workGit.push(['origin', defaultBranch]).catch(async () => {
// If default branch doesn't exist, try to create it if (isEmpty) {
// For empty repos, directly copy objects and update refs (more reliable than push)
try { try {
await workGit.checkout(['-b', defaultBranch]); logger.info({ repoPath, workDir, commitHash, defaultBranch }, 'Starting object copy for empty repo');
await workGit.push(['origin', defaultBranch]);
} catch { // Copy all objects from workdir to bare repo
// If default branch creation fails, try 'main' or 'master' as fallback 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'; const fallbackBranch = defaultBranch === 'main' ? 'master' : 'main';
try { try {
await workGit.checkout(['-b', fallbackBranch]); // Rename current branch to fallback
await workGit.push(['origin', fallbackBranch]); await workGit.raw(['-C', workDir, 'branch', '-m', fallbackBranch]);
} catch {
// If all fails, log but don't throw - announcement is saved // Get the commit hash
logger.warn({ repoPath, defaultBranch, fallbackBranch }, 'Failed to push announcement to any branch'); 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 // Clean up
await rm(workDir, { recursive: true, force: true }); await rm(workDir, { recursive: true, force: true });
} catch (error) { } catch (error) {
logger.error({ error, repoPath }, 'Failed to ensure announcement in repo'); const errorMessage = error instanceof Error ? error.message : String(error);
// Don't throw - announcement file creation is important but shouldn't block provisioning const errorStack = error instanceof Error ? error.stack : undefined;
logger.error({
error: errorMessage,
errorStack,
repoPath,
eventId: event.id,
isEmpty
}, 'Failed to ensure announcement in repo');
// For empty repos, this is critical - we need the initial commit
// For non-empty repos, it's less critical but still important
if (isEmpty) {
// Re-throw for empty repos so caller knows it failed
throw new Error(`Failed to commit announcement to empty repo: ${errorMessage}`);
}
// For non-empty repos, don't throw - announcement file creation is important but shouldn't block provisioning
} }
} }

7
src/lib/services/git/file-manager/commit-operations.ts

@ -31,7 +31,12 @@ export interface DiffOptions {
* Get commit history * Get commit history
*/ */
export async function getCommitHistory(options: CommitHistoryOptions): Promise<Commit[]> { export async function getCommitHistory(options: CommitHistoryOptions): Promise<Commit[]> {
const { npub, repoName, branch = 'main', limit = 50, path, repoPath } = options; let { npub, repoName, branch = 'main', limit = 50, path, repoPath } = options;
// Normalize 'null' string to undefined, then use default
if (branch === 'null' || branch === null) {
branch = 'main';
}
// Validate inputs // Validate inputs
const npubValidation = validateNpub(npub); const npubValidation = validateNpub(npub);

20
src/lib/services/git/repo-manager.ts

@ -362,14 +362,18 @@ Your commits will all be signed by your Nostr keys and saved to the event files
return { success: true }; return { success: true };
} }
// Repo exists but no announcement - try to fetch from relays // Repo exists but no announcement - use provided announcement or try to fetch from relays
const { requireNpubHex: requireNpubHexUtil } = await import('../../utils/npub-utils.js'); let announcementToUse: NostrEvent | null | undefined = announcementEvent;
const repoOwnerPubkey = requireNpubHexUtil(npub); if (!announcementToUse) {
const fetchedAnnouncement = await this.announcementManager.fetchAnnouncementFromRelays(repoOwnerPubkey, repoName); const { requireNpubHex: requireNpubHexUtil } = await import('../../utils/npub-utils.js');
if (fetchedAnnouncement) { const repoOwnerPubkey = requireNpubHexUtil(npub);
// Save fetched announcement to repo announcementToUse = await this.announcementManager.fetchAnnouncementFromRelays(repoOwnerPubkey, repoName);
await this.announcementManager.ensureAnnouncementInRepo(repoPath, fetchedAnnouncement); }
return { success: true, announcement: fetchedAnnouncement };
if (announcementToUse) {
// Save announcement to repo (this will create initial commit if repo is empty)
await this.announcementManager.ensureAnnouncementInRepo(repoPath, announcementToUse);
return { success: true, announcement: announcementToUse };
} }
// Repo exists but no announcement found - needs announcement // Repo exists but no announcement found - needs announcement

22
src/lib/services/nostr/nostr-client.ts

@ -448,17 +448,17 @@ export class NostrClient {
// Publish to relays - wrap in try-catch to catch synchronous errors // Publish to relays - wrap in try-catch to catch synchronous errors
try { try {
// SimplePool.publish returns a promise, but errors from individual relays // SimplePool.publish returns Promise<string>[] (array of promises, one per relay)
// may not be properly caught. We'll handle them at multiple levels. // We need to use Promise.all() to wait for all of them
const poolPublishPromise = this.pool.publish(targetRelays, event); const poolPublishPromises = this.pool.publish(targetRelays, event);
// Handle the promise result // Handle the promise results
poolPublishPromise Promise.all(poolPublishPromises)
.then(() => { .then((results) => {
clearTimeout(timeout); clearTimeout(timeout);
// If publish succeeded, all relays succeeded // results is string[] - the relay URLs that succeeded
// Note: SimplePool.publish doesn't return per-relay results, so we assume all succeeded // If all succeeded, results should contain all targetRelays
resolve(targetRelays); resolve(results);
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
clearTimeout(timeout); clearTimeout(timeout);
@ -488,12 +488,12 @@ export class NostrClient {
}); });
// Wait for publish with timeout and catch all errors // Wait for publish with timeout and catch all errors
const publishedRelays = await Promise.race([ const publishedRelays: string[] = await Promise.race([
publishPromise, publishPromise,
new Promise<string[]>((_, reject) => new Promise<string[]>((_, reject) =>
setTimeout(() => reject(new Error('Publish timeout')), 30000) setTimeout(() => reject(new Error('Publish timeout')), 30000)
) )
]).catch((error: unknown) => { ]).catch((error: unknown): string[] => {
// Log error but don't throw - we'll mark relays as failed below // Log error but don't throw - we'll mark relays as failed below
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
logger.debug({ error: errorMessage, eventId: event.id }, 'Error publishing event to relays'); logger.debug({ error: errorMessage, eventId: event.id }, 'Error publishing event to relays');

48
src/lib/services/nostr/relay-write-proof.ts

@ -94,20 +94,49 @@ export async function verifyRelayWriteProof(
} }
} }
// For very recent events (within 15 seconds), accept them even if not found on relays yet
// This handles the race condition where the event was just published but hasn't propagated yet
const veryRecentThreshold = 15; // seconds
const isVeryRecent = eventAge <= veryRecentThreshold;
// Try to verify the event exists on at least one default relay // Try to verify the event exists on at least one default relay
// User only needs write access to ONE of the default relays, not all // User only needs write access to ONE of the default relays, not all
// This is a trust mechanism - if they can write to any trusted relay, they're trusted // This is a trust mechanism - if they can write to any trusted relay, they're trusted
const nostrClient = new NostrClient(relays); const nostrClient = new NostrClient(relays);
try { try {
const events = await nostrClient.fetchEvents([ // For very recent events, try fetching with a short retry to handle propagation delay
{ let events: NostrEvent[] = [];
ids: [proofEvent.id], const maxRetries = isVeryRecent ? 2 : 1;
authors: [userPubkey], const retryDelay = 1000; // 1 second
limit: 1
for (let attempt = 0; attempt < maxRetries; attempt++) {
if (attempt > 0) {
// Wait before retry
await new Promise(resolve => setTimeout(resolve, retryDelay));
} }
]);
events = await nostrClient.fetchEvents([
{
ids: [proofEvent.id],
authors: [userPubkey],
limit: 1
}
]);
if (events.length > 0) {
break; // Found the event, no need to retry
}
}
if (events.length === 0) { if (events.length === 0) {
// If event is very recent and has valid signature, accept it even if not found on relays yet
// This handles the race condition where the event was just published
if (isVeryRecent) {
// Event is very recent and signature is valid - accept it as proof
// The user just published it, so they clearly have write access
return { valid: true, relay: relays[0] };
}
return { valid: false, error: 'Proof event not found on any default relay. User must be able to write to at least one default relay.' }; return { valid: false, error: 'Proof event not found on any default relay. User must be able to write to at least one default relay.' };
} }
@ -123,6 +152,13 @@ export async function verifyRelayWriteProof(
return { valid: true, relay: relays[0] }; // Return first relay as indication return { valid: true, relay: relays[0] }; // Return first relay as indication
} catch (error) { } catch (error) {
// Relay connection failed - this is a network/relay issue, not an auth failure // Relay connection failed - this is a network/relay issue, not an auth failure
// For very recent events with valid signatures, accept them even if relays are down
if (isVeryRecent) {
// Event is very recent and signature is valid - accept it as proof
// The user just published it, so they clearly have write access
return { valid: true, relay: relays[0] };
}
// Return a special error that indicates we should check cache // Return a special error that indicates we should check cache
return { return {
valid: false, valid: false,

6
src/lib/services/nostr/repo-polling.ts

@ -99,6 +99,12 @@ export class RepoPollingService {
// Filter for repos that list our domain // Filter for repos that list our domain
const relevantEvents = events.filter(event => { const relevantEvents = events.filter(event => {
// Skip local-only forks (synthetic announcements not published to Nostr)
const isLocalOnly = event.tags.some(t => t[0] === 'local-only' && t[1] === 'true');
if (isLocalOnly) {
return false;
}
const cloneUrls = this.extractCloneUrls(event); const cloneUrls = this.extractCloneUrls(event);
return cloneUrls.some(url => url.includes(this.domain)); return cloneUrls.some(url => url.includes(this.domain));
}); });

1
src/lib/styles/repo.css

@ -2317,6 +2317,7 @@ span.clone-more {
} }
.verification-badge.unverified { .verification-badge.unverified {
background: var(--error-bg);
color: var(--error-text); color: var(--error-text);
} }

10
src/lib/utils/api-context.ts

@ -88,9 +88,13 @@ export function extractRequestContext(
} }
// Extract common query parameters // Extract common query parameters
const ref = requestUrl.searchParams.get('ref') || undefined; // Normalize 'null' string to undefined (query params are always strings)
const path = requestUrl.searchParams.get('path') || undefined; const refParam = requestUrl.searchParams.get('ref');
const branch = requestUrl.searchParams.get('branch') || undefined; const ref = (refParam && refParam !== 'null') ? refParam : undefined;
const pathParam = requestUrl.searchParams.get('path');
const path = (pathParam && pathParam !== 'null') ? pathParam : undefined;
const branchParam = requestUrl.searchParams.get('branch');
const branch = (branchParam && branchParam !== 'null') ? branchParam : undefined;
const limit = requestUrl.searchParams.get('limit') const limit = requestUrl.searchParams.get('limit')
? parseInt(requestUrl.searchParams.get('limit')!, 10) ? parseInt(requestUrl.searchParams.get('limit')!, 10)
: undefined; : undefined;

6
src/routes/api/repos/[npub]/[repo]/branches/+server.ts

@ -10,7 +10,7 @@ import { createRepoGetHandler, createRepoPostHandler } from '$lib/utils/api-hand
import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { handleValidationError, handleApiError, handleNotFoundError } from '$lib/utils/error-handler.js'; import { handleValidationError, handleApiError, handleNotFoundError } from '$lib/utils/error-handler.js';
import { KIND } from '$lib/types/nostr.js'; import { KIND } from '$lib/types/nostr.js';
import { join, dirname } from 'path'; import { join, dirname, resolve } from 'path';
import { existsSync, accessSync, constants } from 'fs'; import { existsSync, accessSync, constants } from 'fs';
import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js'; import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js';
import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS } from '$lib/config.js'; import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS } from '$lib/config.js';
@ -44,9 +44,11 @@ function checkDirectoryWritable(dirPath: string, description: string): void {
} }
} }
const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT // Resolve GIT_REPO_ROOT to absolute path (handles both relative and absolute paths)
const repoRootEnv = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT
? process.env.GIT_REPO_ROOT ? process.env.GIT_REPO_ROOT
: '/repos'; : '/repos';
const repoRoot = resolve(repoRootEnv);
export const GET: RequestHandler = createRepoGetHandler( export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext, event: RequestEvent) => { async (context: RepoRequestContext, event: RequestEvent) => {

39
src/routes/api/repos/[npub]/[repo]/clone/+server.ts

@ -21,6 +21,7 @@ import { eventCache } from '$lib/services/nostr/event-cache.js';
import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js'; import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js';
import { repoManager, nostrClient } from '$lib/services/service-registry.js'; import { repoManager, nostrClient } from '$lib/services/service-registry.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import simpleGit from 'simple-git';
// Resolve GIT_REPO_ROOT to absolute path (handles both relative and absolute paths) // Resolve GIT_REPO_ROOT to absolute path (handles both relative and absolute paths)
const repoRootEnv = process.env.GIT_REPO_ROOT || '/repos'; const repoRootEnv = process.env.GIT_REPO_ROOT || '/repos';
@ -159,15 +160,43 @@ export const POST: RequestHandler = async (event) => {
try { try {
// Decode npub to get pubkey // Decode npub to get pubkey
const repoOwnerPubkey = requireNpubHex(npub); const repoOwnerPubkey = requireNpubHex(npub);
// Check if user is the repository owner
// Only owners can clone repositories directly; non-owners must create a fork
if (userPubkeyHex !== repoOwnerPubkey) {
logger.warn({
userPubkeyHex: userPubkeyHex.slice(0, 16) + '...',
repoOwnerPubkey: repoOwnerPubkey.slice(0, 16) + '...',
npub,
repo
}, 'Non-owner attempted to clone repository');
throw error(403, 'Only repository owners can clone repositories directly. Please create a fork instead.');
}
const repoPath = join(repoRoot, npub, `${repo}.git`); const repoPath = join(repoRoot, npub, `${repo}.git`);
// Check if repo already exists // Check if repo already exists
if (existsSync(repoPath)) { if (existsSync(repoPath)) {
return json({ // Check if repo is empty (no commits)
success: true, let isEmpty = false;
message: 'Repository already exists locally', try {
alreadyExists: true const bareGit = simpleGit(repoPath);
}); const logResult = await bareGit.log(['--all', '-1']);
isEmpty = logResult.total === 0;
} catch {
// If log fails, assume repo is empty
isEmpty = true;
}
// If repo is empty, we should still try to commit the announcement
if (!isEmpty) {
return json({
success: true,
message: 'Repository already exists locally',
alreadyExists: true
});
}
// If empty, continue to fetch announcement and commit it
logger.info({ npub, repo }, 'Repository exists but is empty, will commit announcement');
} }
// Fetch repository announcement (case-insensitive) // Fetch repository announcement (case-insensitive)

9
src/routes/api/repos/[npub]/[repo]/commits/+server.ts

@ -9,16 +9,18 @@ import { createRepoGetHandler } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext } from '$lib/utils/api-context.js'; import type { RepoRequestContext } from '$lib/utils/api-context.js';
import { handleApiError, handleNotFoundError } from '$lib/utils/error-handler.js'; import { handleApiError, handleNotFoundError } from '$lib/utils/error-handler.js';
import { KIND } from '$lib/types/nostr.js'; import { KIND } from '$lib/types/nostr.js';
import { join } from 'path'; import { join, resolve } from 'path';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js'; import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js';
import logger from '$lib/services/logger.js'; import logger from '$lib/services/logger.js';
import { eventCache } from '$lib/services/nostr/event-cache.js'; import { eventCache } from '$lib/services/nostr/event-cache.js';
import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js'; import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js';
const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT // Resolve GIT_REPO_ROOT to absolute path (handles both relative and absolute paths)
const repoRootEnv = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT
? process.env.GIT_REPO_ROOT ? process.env.GIT_REPO_ROOT
: '/repos'; : '/repos';
const repoRoot = resolve(repoRootEnv);
export const GET: RequestHandler = createRepoGetHandler( export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext) => { async (context: RepoRequestContext) => {
@ -77,7 +79,8 @@ export const GET: RequestHandler = createRepoGetHandler(
} }
// Get default branch if not specified // Get default branch if not specified
let branch = context.branch; // Normalize 'null' string to undefined (defensive check)
let branch = (context.branch && context.branch !== 'null') ? context.branch : undefined;
if (!branch) { if (!branch) {
try { try {
branch = await fileManager.getDefaultBranch(context.npub, context.repo); branch = await fileManager.getDefaultBranch(context.npub, context.repo);

205
src/routes/api/repos/[npub]/[repo]/fork/+server.ts

@ -90,12 +90,15 @@ export const POST: RequestHandler = async ({ params, request }) => {
try { try {
const body = await request.json(); const body = await request.json();
const { userPubkey, forkName } = body; const { userPubkey, forkName, localOnly } = body;
if (!userPubkey) { if (!userPubkey) {
return error(401, 'Authentication required. Please provide userPubkey.'); return error(401, 'Authentication required. Please provide userPubkey.');
} }
// Validate localOnly parameter
const isLocalOnly = localOnly === true;
// Decode original repo owner npub // Decode original repo owner npub
let originalOwnerPubkey: string; let originalOwnerPubkey: string;
try { try {
@ -253,9 +256,15 @@ export const POST: RequestHandler = async ({ params, request }) => {
['p', originalOwnerPubkey], // Original owner ['p', originalOwnerPubkey], // Original owner
]; ];
// Preserve visibility from original repo (defaults to public if not set) // Local-only forks are always private and marked as synthetic
if (originalVisibility !== 'public') { if (isLocalOnly) {
tags.push(['visibility', originalVisibility]); tags.push(['visibility', 'private']);
tags.push(['local-only', 'true']); // Mark as synthetic/local-only
} else {
// Preserve visibility from original repo (defaults to public if not set)
if (originalVisibility !== 'public') {
tags.push(['visibility', originalVisibility]);
}
} }
// Preserve project-relay tags from original repo // Preserve project-relay tags from original repo
@ -277,99 +286,117 @@ export const POST: RequestHandler = async ({ params, request }) => {
tags tags
}; };
// Sign and publish fork announcement // Sign fork announcement
const signedForkAnnouncement = await signEventWithNIP07(forkAnnouncementTemplate); const signedForkAnnouncement = await signEventWithNIP07(forkAnnouncementTemplate);
const { outbox } = await getUserRelays(userPubkeyHex, nostrClient);
const combinedRelays = combineRelays(outbox);
// Security: Truncate npub in logs and create context (must be before use) // Security: Truncate npub in logs and create context (must be before use)
const truncatedNpub = userNpub.length > 16 ? `${userNpub.slice(0, 12)}...` : userNpub; const truncatedNpub = userNpub.length > 16 ? `${userNpub.slice(0, 12)}...` : userNpub;
const truncatedOriginalNpub = npub.length > 16 ? `${npub.slice(0, 12)}...` : npub; const truncatedOriginalNpub = npub.length > 16 ? `${npub.slice(0, 12)}...` : npub;
const context = `[${truncatedOriginalNpub}/${repo}${truncatedNpub}/${forkRepoName}]`; const context = `[${truncatedOriginalNpub}/${repo}${truncatedNpub}/${forkRepoName}]`;
logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, relayCount: combinedRelays.length, relays: combinedRelays }, 'Starting fork process'); let publishResult: { success: string[]; failed: Array<{ relay: string; error: string }> } | null = null;
let ownershipPublishResult: { success: string[]; failed: Array<{ relay: string; error: string }> } | null = null;
const publishResult = await publishEventWithRetry( let signedOwnershipEvent: NostrEvent | null = null;
signedForkAnnouncement,
combinedRelays, if (isLocalOnly) {
'fork announcement', // Local-only fork: Skip publishing to Nostr relays
3, logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, localOnly: true }, 'Creating local-only fork (not publishing to Nostr)');
context publishResult = { success: [], failed: [] };
); ownershipPublishResult = { success: [], failed: [] };
// For local-only forks, create a synthetic ownership event (not published)
const ownershipService = new OwnershipTransferService([]);
const initialOwnershipEvent = ownershipService.createInitialOwnershipEvent(userPubkeyHex, forkRepoName);
signedOwnershipEvent = await signEventWithNIP07(initialOwnershipEvent);
logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}` }, 'Created synthetic ownership event for local-only fork');
} else {
// Public fork: Publish to Nostr relays
const { outbox } = await getUserRelays(userPubkeyHex, nostrClient);
const combinedRelays = combineRelays(outbox);
logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, relayCount: combinedRelays.length, relays: combinedRelays }, 'Starting fork process');
publishResult = await publishEventWithRetry(
signedForkAnnouncement,
combinedRelays,
'fork announcement',
3,
context
);
if (publishResult.success.length === 0) { if (publishResult.success.length === 0) {
// Clean up repo if announcement failed // Clean up repo if announcement failed
logger.error({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, failed: publishResult.failed }, 'Fork announcement failed after all retries. Cleaning up repository.'); logger.error({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, failed: publishResult.failed }, 'Fork announcement failed after all retries. Cleaning up repository.');
await rm(forkRepoPath, { recursive: true, force: true }).catch(() => {}); await rm(forkRepoPath, { recursive: true, force: true }).catch(() => {});
const errorDetails = `All relays failed: ${publishResult.failed.map(f => `${f.relay}: ${f.error}`).join('; ')}`; const errorDetails = `All relays failed: ${publishResult.failed.map(f => `${f.relay}: ${f.error}`).join('; ')}`;
return json({ return json({
success: false, success: false,
error: 'Failed to publish fork announcement to relays after 3 attempts', error: 'Failed to publish fork announcement to relays after 3 attempts',
details: errorDetails, details: errorDetails,
eventName: 'fork announcement' eventName: 'fork announcement'
}, { status: 500 }); }, { status: 500 });
} }
// Create and publish initial ownership proof (self-transfer event) // Create and publish initial ownership proof (self-transfer event)
// This MUST succeed for the fork to be valid - without it, there's no proof of ownership on Nostr // This MUST succeed for the fork to be valid - without it, there's no proof of ownership on Nostr
const ownershipService = new OwnershipTransferService(combinedRelays); const ownershipService = new OwnershipTransferService(combinedRelays);
const initialOwnershipEvent = ownershipService.createInitialOwnershipEvent(userPubkeyHex, forkRepoName); const initialOwnershipEvent = ownershipService.createInitialOwnershipEvent(userPubkeyHex, forkRepoName);
const signedOwnershipEvent = await signEventWithNIP07(initialOwnershipEvent); signedOwnershipEvent = await signEventWithNIP07(initialOwnershipEvent);
const ownershipPublishResult = await publishEventWithRetry(
signedOwnershipEvent,
combinedRelays,
'ownership transfer event',
3,
context
);
if (ownershipPublishResult.success.length === 0) { ownershipPublishResult = await publishEventWithRetry(
// Clean up repo if ownership proof failed signedOwnershipEvent,
logger.error({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, failed: ownershipPublishResult.failed }, 'Ownership transfer event failed after all retries. Cleaning up repository and publishing deletion request.');
await rm(forkRepoPath, { recursive: true, force: true }).catch(() => {});
// Publish deletion request (NIP-09) for the announcement since it's invalid without ownership proof
logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}` }, 'Publishing deletion request for invalid fork announcement...');
const deletionRequest = {
kind: KIND.DELETION_REQUEST, // NIP-09: Event Deletion Request
pubkey: userPubkeyHex,
created_at: Math.floor(Date.now() / 1000),
content: 'Fork failed: ownership transfer event could not be published after 3 attempts. This announcement is invalid.',
tags: [
['a', `${KIND.REPO_ANNOUNCEMENT}:${userPubkeyHex}:${forkRepoName}`], // Reference to the repo announcement
['k', KIND.REPO_ANNOUNCEMENT.toString()] // Kind of event being deleted
]
};
const signedDeletionRequest = await signEventWithNIP07(deletionRequest);
const deletionResult = await publishEventWithRetry(
signedDeletionRequest,
combinedRelays, combinedRelays,
'deletion request', 'ownership transfer event',
3, 3,
context context
); );
if (deletionResult.success.length > 0) { if (ownershipPublishResult.success.length === 0) {
logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}` }, 'Deletion request published successfully'); // Clean up repo if ownership proof failed
} else { logger.error({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, failed: ownershipPublishResult.failed }, 'Ownership transfer event failed after all retries. Cleaning up repository and publishing deletion request.');
logger.error({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, failed: deletionResult.failed }, 'Failed to publish deletion request'); await rm(forkRepoPath, { recursive: true, force: true }).catch(() => {});
}
// Publish deletion request (NIP-09) for the announcement since it's invalid without ownership proof
logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}` }, 'Publishing deletion request for invalid fork announcement...');
const deletionRequest = {
kind: KIND.DELETION_REQUEST, // NIP-09: Event Deletion Request
pubkey: userPubkeyHex,
created_at: Math.floor(Date.now() / 1000),
content: 'Fork failed: ownership transfer event could not be published after 3 attempts. This announcement is invalid.',
tags: [
['a', `${KIND.REPO_ANNOUNCEMENT}:${userPubkeyHex}:${forkRepoName}`], // Reference to the repo announcement
['k', KIND.REPO_ANNOUNCEMENT.toString()] // Kind of event being deleted
]
};
const signedDeletionRequest = await signEventWithNIP07(deletionRequest);
const deletionResult = await publishEventWithRetry(
signedDeletionRequest,
combinedRelays,
'deletion request',
3,
context
);
const errorDetails = `Fork is invalid without ownership proof. All relays failed: ${ownershipPublishResult.failed.map(f => `${f.relay}: ${f.error}`).join('; ')}. Deletion request ${deletionResult.success.length > 0 ? 'published' : 'failed to publish'}.`; if (deletionResult.success.length > 0) {
return json({ logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}` }, 'Deletion request published successfully');
success: false, } else {
error: 'Failed to publish ownership transfer event to relays after 3 attempts', logger.error({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, failed: deletionResult.failed }, 'Failed to publish deletion request');
details: errorDetails, }
eventName: 'ownership transfer event'
}, { status: 500 }); const errorDetails = `Fork is invalid without ownership proof. All relays failed: ${ownershipPublishResult.failed.map(f => `${f.relay}: ${f.error}`).join('; ')}. Deletion request ${deletionResult.success.length > 0 ? 'published' : 'failed to publish'}.`;
return json({
success: false,
error: 'Failed to publish ownership transfer event to relays after 3 attempts',
details: errorDetails,
eventName: 'ownership transfer event'
}, { status: 500 });
}
} }
// Provision the fork repo (this will create verification file and include self-transfer) // Provision the fork repo (this will create verification file and include self-transfer)
logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}` }, 'Provisioning fork repository...'); logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, localOnly: isLocalOnly }, 'Provisioning fork repository...');
await repoManager.provisionRepo(signedForkAnnouncement, signedOwnershipEvent, false); await repoManager.provisionRepo(signedForkAnnouncement, signedOwnershipEvent || undefined, false);
// Save fork announcement to repo (offline papertrail) in nostr/repo-events.jsonl // Save fork announcement to repo (offline papertrail) in nostr/repo-events.jsonl
try { try {
@ -412,26 +439,32 @@ export const POST: RequestHandler = async ({ params, request }) => {
operation: 'fork', operation: 'fork',
originalRepo: `${npub}/${repo}`, originalRepo: `${npub}/${repo}`,
forkRepo: `${userNpub}/${forkRepoName}`, forkRepo: `${userNpub}/${forkRepoName}`,
localOnly: isLocalOnly,
announcementId: signedForkAnnouncement.id, announcementId: signedForkAnnouncement.id,
ownershipTransferId: signedOwnershipEvent.id, ownershipTransferId: signedOwnershipEvent?.id,
announcementRelays: publishResult.success.length, announcementRelays: publishResult?.success.length || 0,
ownershipRelays: ownershipPublishResult.success.length ownershipRelays: ownershipPublishResult?.success.length || 0
}, 'Fork completed successfully'); }, 'Fork completed successfully');
const message = isLocalOnly
? 'Local-only fork created successfully! This fork is private and only exists on this server.'
: `Repository forked successfully! Published to ${publishResult?.success.length || 0} relay(s) for announcement and ${ownershipPublishResult?.success.length || 0} relay(s) for ownership proof.`;
return json({ return json({
success: true, success: true,
fork: { fork: {
npub: userNpub, npub: userNpub,
repo: forkRepoName, repo: forkRepoName,
url: forkGitUrl, url: forkGitUrl,
localOnly: isLocalOnly,
announcementId: signedForkAnnouncement.id, announcementId: signedForkAnnouncement.id,
ownershipTransferId: signedOwnershipEvent.id, ownershipTransferId: signedOwnershipEvent?.id,
publishedTo: { publishedTo: isLocalOnly ? null : {
announcement: publishResult.success.length, announcement: publishResult?.success.length || 0,
ownershipTransfer: ownershipPublishResult.success.length ownershipTransfer: ownershipPublishResult?.success.length || 0
} }
}, },
message: `Repository forked successfully! Published to ${publishResult.success.length} relay(s) for announcement and ${ownershipPublishResult.success.length} relay(s) for ownership proof.` message
}); });
} catch (err) { } catch (err) {
return handleApiError(err, { operation: 'fork', npub, repo }, 'Failed to fork repository'); return handleApiError(err, { operation: 'fork', npub, repo }, 'Failed to fork repository');

6
src/routes/api/repos/[npub]/[repo]/tree/+server.ts

@ -9,16 +9,18 @@ import { createRepoGetHandler } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { handleApiError, handleNotFoundError } from '$lib/utils/error-handler.js'; import { handleApiError, handleNotFoundError } from '$lib/utils/error-handler.js';
import { KIND } from '$lib/types/nostr.js'; import { KIND } from '$lib/types/nostr.js';
import { join } from 'path'; import { join, resolve } from 'path';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js'; import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js';
import logger from '$lib/services/logger.js'; import logger from '$lib/services/logger.js';
import { eventCache } from '$lib/services/nostr/event-cache.js'; import { eventCache } from '$lib/services/nostr/event-cache.js';
import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js'; import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js';
const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT // Resolve GIT_REPO_ROOT to absolute path (handles both relative and absolute paths)
const repoRootEnv = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT
? process.env.GIT_REPO_ROOT ? process.env.GIT_REPO_ROOT
: '/repos'; : '/repos';
const repoRoot = resolve(repoRootEnv);
export const GET: RequestHandler = createRepoGetHandler( export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext) => { async (context: RepoRequestContext) => {

89
src/routes/api/repos/[npub]/[repo]/verify/+server.ts

@ -68,55 +68,72 @@ export const GET: RequestHandler = createRepoGetHandler(
let localVerified = false; let localVerified = false;
let localOwner: string | null = null; let localOwner: string | null = null;
let localError: string | undefined; let localError: string | undefined;
const repoPath = join(repoRoot, context.npub, `${context.repo}.git`);
const repoExists = existsSync(repoPath);
try { if (repoExists) {
// Get current owner from the most recent announcement file in the repo // Repo is cloned - verify the announcement file matches
localOwner = await fileManager.getCurrentOwnerFromRepo(context.npub, context.repo); try {
// Get current owner from the most recent announcement file in the repo
if (localOwner) { localOwner = await fileManager.getCurrentOwnerFromRepo(context.npub, context.repo);
// Verify the announcement in nostr/repo-events.jsonl matches the announcement event
try { if (localOwner) {
const repoEventsFile = await fileManager.getFileContent(context.npub, context.repo, 'nostr/repo-events.jsonl', 'HEAD'); // Verify the announcement in nostr/repo-events.jsonl matches the announcement event
// Parse repo-events.jsonl and find the most recent announcement try {
const lines = repoEventsFile.content.trim().split('\n').filter(Boolean); const repoEventsFile = await fileManager.getFileContent(context.npub, context.repo, 'nostr/repo-events.jsonl', 'HEAD');
let repoAnnouncement: NostrEvent | null = null; // Parse repo-events.jsonl and find the most recent announcement
let latestTimestamp = 0; const lines = repoEventsFile.content.trim().split('\n').filter(Boolean);
let repoAnnouncement: NostrEvent | null = null;
for (const line of lines) { let latestTimestamp = 0;
try {
const entry = JSON.parse(line); for (const line of lines) {
if (entry.type === 'announcement' && entry.event && entry.timestamp) { try {
if (entry.timestamp > latestTimestamp) { const entry = JSON.parse(line);
latestTimestamp = entry.timestamp; if (entry.type === 'announcement' && entry.event && entry.timestamp) {
repoAnnouncement = entry.event; if (entry.timestamp > latestTimestamp) {
latestTimestamp = entry.timestamp;
repoAnnouncement = entry.event;
}
} }
} catch {
continue;
} }
} catch {
continue;
} }
}
if (repoAnnouncement) { if (repoAnnouncement) {
const verification = verifyRepositoryOwnership(announcement, JSON.stringify(repoAnnouncement)); const verification = verifyRepositoryOwnership(announcement, JSON.stringify(repoAnnouncement));
localVerified = verification.valid; localVerified = verification.valid;
if (!verification.valid) { if (!verification.valid) {
localError = verification.error; localError = verification.error;
}
} else {
localVerified = false;
localError = 'No announcement found in nostr/repo-events.jsonl';
} }
} else { } catch (err) {
localVerified = false; localVerified = false;
localError = 'No announcement found in nostr/repo-events.jsonl'; localError = 'Announcement file not found in repository';
} }
} catch (err) { } else {
localVerified = false; localVerified = false;
localError = 'Announcement file not found in repository'; localError = 'No announcement found in repository';
} }
} catch (err) {
localVerified = false;
localError = err instanceof Error ? err.message : 'Failed to verify local clone';
}
} else {
// Repo is not cloned yet - verify from Nostr announcement alone
// The announcement pubkey must match the repo owner
if (announcement.pubkey === context.repoOwnerPubkey) {
localVerified = true;
localOwner = context.repoOwnerPubkey;
localError = undefined;
} else { } else {
localVerified = false; localVerified = false;
localError = 'No announcement found in repository'; localOwner = announcement.pubkey;
localError = 'Announcement pubkey does not match repository owner';
} }
} catch (err) {
localVerified = false;
localError = err instanceof Error ? err.message : 'Failed to verify local clone';
} }
// Add local clone verification // Add local clone verification

161
src/routes/repos/+page.svelte

@ -12,8 +12,9 @@
import { hasUnlimitedAccess } from '$lib/utils/user-access.js'; import { hasUnlimitedAccess } from '$lib/utils/user-access.js';
// Registered repos (with domain in clone URLs) // Registered repos (with domain in clone URLs)
let registeredRepos = $state<Array<{ event: NostrEvent; npub: string; repoName: string }>>([]); // Also includes local-only forks (marked with isLocalOnly flag)
let allRegisteredRepos = $state<Array<{ event: NostrEvent; npub: string; repoName: string }>>([]); let registeredRepos = $state<Array<{ event: NostrEvent; npub: string; repoName: string; isLocalOnly?: boolean }>>([]);
let allRegisteredRepos = $state<Array<{ event: NostrEvent; npub: string; repoName: string; isLocalOnly?: boolean }>>([]);
// Local clones (repos without domain in clone URLs) // Local clones (repos without domain in clone URLs)
let localRepos = $state<Array<{ npub: string; repoName: string; announcement: NostrEvent | null; lastModified: number }>>([]); let localRepos = $state<Array<{ npub: string; repoName: string; announcement: NostrEvent | null; lastModified: number }>>([]);
@ -329,15 +330,33 @@
// API returns { registered, total } // API returns { registered, total }
registeredRepos = data.registered || []; registeredRepos = data.registered || [];
// Load local repos and merge them into registered repos list
await loadLocalRepos();
// Merge local repos into registered repos list with special icon
const mergedRepos = [...registeredRepos];
for (const localRepo of localRepos) {
// Check if this local repo is already in registered repos
const exists = mergedRepos.some(r => r.npub === localRepo.npub && r.repoName === localRepo.repoName);
if (!exists && localRepo.announcement) {
// Add local repo to registered list with a flag indicating it's local-only
mergedRepos.push({
event: localRepo.announcement,
npub: localRepo.npub,
repoName: localRepo.repoName,
isLocalOnly: true // Flag to show special icon
});
}
}
registeredRepos = mergedRepos;
allRegisteredRepos = [...registeredRepos]; allRegisteredRepos = [...registeredRepos];
// Load fork counts for registered repos (in parallel, but don't block) // Load fork counts for registered repos (in parallel, but don't block)
loadForkCounts(registeredRepos.map(r => r.event)).catch(err => { loadForkCounts(registeredRepos.map(r => r.event)).catch(err => {
console.warn('[RepoList] Failed to load some fork counts:', err); console.warn('[RepoList] Failed to load some fork counts:', err);
}); });
// Load local repos separately (async, don't block)
loadLocalRepos();
} catch (e) { } catch (e) {
error = String(e); error = String(e);
console.error('[RepoList] Failed to load repos:', e); console.error('[RepoList] Failed to load repos:', e);
@ -673,7 +692,10 @@
<span>Created: {new Date(repo.created_at * 1000).toLocaleDateString()}</span> <span>Created: {new Date(repo.created_at * 1000).toLocaleDateString()}</span>
{#if getForkCount(repo) > 0} {#if getForkCount(repo) > 0}
{@const forkCount = getForkCount(repo)} {@const forkCount = getForkCount(repo)}
<span class="fork-count">🍴 {forkCount} fork{forkCount === 1 ? '' : 's'}</span> <span class="fork-count">
<img src="/icons/git-fork.svg" alt="Fork" class="fork-icon" />
{forkCount} fork{forkCount === 1 ? '' : 's'}
</span>
{/if} {/if}
</div> </div>
</div> </div>
@ -724,7 +746,10 @@
<span class="favorite-count">{item.favoriteCount} {item.favoriteCount === 1 ? 'favorite' : 'favorites'}</span> <span class="favorite-count">{item.favoriteCount} {item.favoriteCount === 1 ? 'favorite' : 'favorites'}</span>
{#if getForkCount(repo) > 0} {#if getForkCount(repo) > 0}
{@const forkCount = getForkCount(repo)} {@const forkCount = getForkCount(repo)}
<span class="fork-count">🍴 {forkCount} fork{forkCount === 1 ? '' : 's'}</span> <span class="fork-count">
<img src="/icons/git-fork.svg" alt="Fork" class="fork-icon" />
{forkCount} fork{forkCount === 1 ? '' : 's'}
</span>
{/if} {/if}
</div> </div>
</div> </div>
@ -778,6 +803,7 @@
{#each registeredRepos as item} {#each registeredRepos as item}
{@const repo = item.event} {@const repo = item.event}
{@const repoImage = getRepoImage(repo)} {@const repoImage = getRepoImage(repo)}
{@const isLocalOnly = item.isLocalOnly || false}
<div class="repo-card repo-card-registered"> <div class="repo-card repo-card-registered">
<div class="repo-card-content"> <div class="repo-card-content">
<div class="repo-header"> <div class="repo-header">
@ -787,6 +813,9 @@
<img src={repoImage} alt="Repository" class="repo-avatar" /> <img src={repoImage} alt="Repository" class="repo-avatar" />
{/if} {/if}
<h3>{getRepoName(repo)}</h3> <h3>{getRepoName(repo)}</h3>
{#if isLocalOnly}
<img src="/icons/hard-drive.svg" alt="Local-only fork" class="local-only-icon" title="Local-only fork (not published to Nostr)" />
{/if}
</div> </div>
{#if getRepoDescription(repo)} {#if getRepoDescription(repo)}
<p class="description">{getRepoDescription(repo)}</p> <p class="description">{getRepoDescription(repo)}</p>
@ -798,6 +827,9 @@
</div> </div>
<div class="repo-meta"> <div class="repo-meta">
<span>Created: {new Date(repo.created_at * 1000).toLocaleDateString()}</span> <span>Created: {new Date(repo.created_at * 1000).toLocaleDateString()}</span>
{#if isLocalOnly}
<span class="local-only-badge" title="Local-only fork (not published to Nostr)">Local Fork</span>
{/if}
{#if getForkCount(repo) > 0} {#if getForkCount(repo) > 0}
{@const forkCount = getForkCount(repo)} {@const forkCount = getForkCount(repo)}
<span class="fork-count">🍴 {forkCount} fork{forkCount === 1 ? '' : 's'}</span> <span class="fork-count">🍴 {forkCount} fork{forkCount === 1 ? '' : 's'}</span>
@ -810,61 +842,6 @@
{/if} {/if}
</div> </div>
<!-- Local Clones Section -->
<div class="repo-section">
<div class="section-header">
<h3>Local Clones</h3>
<span class="section-badge">{localRepos.length}</span>
<span class="section-description">Repositories cloned locally but not registered with this domain</span>
</div>
{#if loadingLocal}
<div class="loading">Loading local repositories...</div>
{:else if localRepos.length === 0}
<div class="empty">No local clones found.</div>
{:else}
<div class="repos-list">
{#each localRepos as item}
{@const repo = item.announcement}
{@const repoImage = repo ? getRepoImage(repo) : null}
{@const canDelete = isOwner(item.npub, item.repoName)}
<div class="repo-card repo-card-local">
<div class="repo-card-content">
<div class="repo-header">
<div class="repo-header-text">
<div class="repo-title-row">
{#if repoImage}
<img src={repoImage} alt="Repository" class="repo-avatar" />
{/if}
<h3>{repo ? getRepoName(repo) : item.repoName}</h3>
</div>
{#if repo && getRepoDescription(repo)}
<p class="description">{getRepoDescription(repo)}</p>
{:else}
<p class="description">No description available</p>
{/if}
</div>
<div class="repo-actions">
<a href="/repos/{item.npub}/{item.repoName}" class="view-button" title="View repository">
<img src="/icons/arrow-right.svg" alt="View" />
</a>
</div>
</div>
<div class="repo-meta">
<span>Last modified: {new Date(item.lastModified).toLocaleDateString()}</span>
{#if repo}
<span>Created: {new Date(repo.created_at * 1000).toLocaleDateString()}</span>
{#if getForkCount(repo) > 0}
{@const forkCount = getForkCount(repo)}
<span class="fork-count">🍴 {forkCount} fork{forkCount === 1 ? '' : 's'}</span>
{/if}
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if} {/if}
</main> </main>
</div> </div>
@ -963,6 +940,23 @@
border: 1px solid var(--border-color, #e0e0e0); border: 1px solid var(--border-color, #e0e0e0);
} }
.local-only-icon {
width: 16px;
height: 16px;
opacity: 0.7;
flex-shrink: 0;
filter: brightness(0) invert(1);
}
.local-only-badge {
background: var(--bg-tertiary, #f5f5f5);
color: var(--text-secondary, #666);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
}
.repo-header-text { .repo-header-text {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@ -1016,33 +1010,6 @@
filter: brightness(0) invert(1); filter: brightness(0) invert(1);
} }
.repo-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.delete-button {
padding: 0.375rem 0.75rem;
background: var(--error, #dc3545);
color: var(--error-text, #ffffff);
border: none;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.delete-button:hover:not(:disabled) {
background: var(--error-hover, #c82333);
}
.delete-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.repo-meta { .repo-meta {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -1056,6 +1023,16 @@
.fork-count { .fork-count {
color: var(--text-secondary, #666); color: var(--text-secondary, #666);
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.fork-icon {
width: 14px;
height: 14px;
opacity: 0.8;
filter: brightness(0) invert(1);
} }
.favorite-count { .favorite-count {
@ -1104,12 +1081,6 @@
color: var(--text-secondary, #666); color: var(--text-secondary, #666);
} }
.section-description {
font-size: 0.875rem;
color: var(--text-secondary, #666);
margin-left: auto;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.repos-list { .repos-list {
grid-template-columns: 1fr; grid-template-columns: 1fr;

27
src/routes/repos/[npub]/[repo]/+page.svelte

@ -417,7 +417,32 @@
await cloneRepositoryService(state, { checkCloneStatus, loadBranches, loadFiles, loadReadme, loadTags, loadCommitHistory }); await cloneRepositoryService(state, { checkCloneStatus, loadBranches, loadFiles, loadReadme, loadTags, loadCommitHistory });
} }
async function forkRepository() { async function forkRepository() {
await forkRepositoryService(state); // Check if user is owner - if not, check if server is localhost
const isOwner = repoOwnerPubkeyDerived && state.user.pubkeyHex === repoOwnerPubkeyDerived;
// Only create local-only fork if:
// 1. User is not owner AND
// 2. Server is on localhost (not a public domain or Tor)
let localOnly = false;
if (!isOwner) {
// Check if server is localhost by checking window location or environment
let gitDomain = 'localhost:6543';
if (typeof window !== 'undefined') {
// Client-side: check current hostname
const hostname = window.location.hostname;
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1';
if (isLocalhost) {
gitDomain = `${hostname}:${window.location.port || '6543'}`;
} else {
// Public domain or Tor - don't use local-only
gitDomain = hostname;
}
}
const isLocalhost = gitDomain.startsWith('localhost') || gitDomain.startsWith('127.0.0.1');
localOnly = isLocalhost;
}
await forkRepositoryService(state, localOnly);
} }
// Discussion operations // Discussion operations

18
src/routes/repos/[npub]/[repo]/services/repo-operations.ts

@ -233,7 +233,8 @@ export async function cloneRepository(
* Fork repository * Fork repository
*/ */
export async function forkRepository( export async function forkRepository(
state: RepoState state: RepoState,
localOnly: boolean = false
): Promise<void> { ): Promise<void> {
if (!state.user.pubkey) { if (!state.user.pubkey) {
alert('Please connect your NIP-07 extension'); alert('Please connect your NIP-07 extension');
@ -246,7 +247,7 @@ export async function forkRepository(
try { try {
// Security: Truncate npub in logs // Security: Truncate npub in logs
const truncatedNpub = state.npub.length > 16 ? `${state.npub.slice(0, 12)}...` : state.npub; const truncatedNpub = state.npub.length > 16 ? `${state.npub.slice(0, 12)}...` : state.npub;
console.log(`[Fork UI] Starting fork of ${truncatedNpub}/${state.repo}...`); console.log(`[Fork UI] Starting ${localOnly ? 'local-only ' : ''}fork of ${truncatedNpub}/${state.repo}...`);
const data = await apiPost<{ const data = await apiPost<{
success?: boolean; success?: boolean;
@ -254,21 +255,28 @@ export async function forkRepository(
fork?: { fork?: {
npub: string; npub: string;
repo: string; repo: string;
publishedTo?: { announcement?: number }; localOnly?: boolean;
publishedTo?: { announcement?: number; ownershipTransfer?: number } | null;
announcementId?: string; announcementId?: string;
ownershipTransferId?: string; ownershipTransferId?: string;
}; };
error?: string; error?: string;
details?: string; details?: string;
eventName?: string; eventName?: string;
}>(`/api/repos/${state.npub}/${state.repo}/fork`, { userPubkey: state.user.pubkey }); }>(`/api/repos/${state.npub}/${state.repo}/fork`, {
userPubkey: state.user.pubkey,
localOnly
});
if (data.success !== false && data.fork) { if (data.success !== false && data.fork) {
const message = data.message || `Repository forked successfully! Published to ${data.fork.publishedTo?.announcement || 0} relay(s).`; const message = data.message || (data.fork.localOnly
? 'Local-only fork created successfully! This fork is private and only exists on this server.'
: `Repository forked successfully! Published to ${data.fork.publishedTo?.announcement || 0} relay(s).`);
console.log(`[Fork UI] ✓ ${message}`); console.log(`[Fork UI] ✓ ${message}`);
// Security: Truncate npub in logs // Security: Truncate npub in logs
const truncatedForkNpub = data.fork.npub.length > 16 ? `${data.fork.npub.slice(0, 12)}...` : data.fork.npub; const truncatedForkNpub = data.fork.npub.length > 16 ? `${data.fork.npub.slice(0, 12)}...` : data.fork.npub;
console.log(`[Fork UI] - Fork location: /repos/${truncatedForkNpub}/${data.fork.repo}`); console.log(`[Fork UI] - Fork location: /repos/${truncatedForkNpub}/${data.fork.repo}`);
console.log(`[Fork UI] - Local-only: ${data.fork.localOnly || false}`);
console.log(`[Fork UI] - Announcement ID: ${data.fork.announcementId}`); console.log(`[Fork UI] - Announcement ID: ${data.fork.announcementId}`);
console.log(`[Fork UI] - Ownership Transfer ID: ${data.fork.ownershipTransferId}`); console.log(`[Fork UI] - Ownership Transfer ID: ${data.fork.ownershipTransferId}`);

10
static/icons/git-fork.svg

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="3" r="2"/>
<circle cx="6" cy="21" r="2"/>
<circle cx="18" cy="21" r="2"/>
<line x1="12" y1="5" x2="12" y2="10"/>
<line x1="6" y1="19" x2="6" y2="14"/>
<line x1="18" y1="19" x2="18" y2="14"/>
<path d="M12 10c-2 0-4 1-4 4v5"/>
<path d="M12 10c2 0 4 1 4 4v5"/>
</svg>

After

Width:  |  Height:  |  Size: 483 B

7
static/icons/hard-drive.svg

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="8" width="18" height="12" rx="1"/>
<path d="M3 12h18"/>
<path d="M7 8V6a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v2"/>
<circle cx="9" cy="15" r="1"/>
<circle cx="15" cy="15" r="1"/>
</svg>

After

Width:  |  Height:  |  Size: 384 B

Loading…
Cancel
Save