Browse Source

fix repo management and refactor

implement more GRASP support

Nostr-Signature: 6ae016621b13e22809e7bcebe34e5250fd6e0767d2b12ca634104def4ca78a29 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 99c34f66a8a67d352622621536545b7dee11cfd9d14a007ec0550d138109116a2f24483c6836fea59b94b9e96066fba548bcb7600bc55adbe0562d999c3c651d
main
Silberengel 3 weeks ago
parent
commit
7a9bb99dfe
  1. 21
      README.md
  2. 53
      docs/tutorial.md
  3. 1
      nostr/commit-signatures.jsonl
  4. 394
      src/lib/services/git/clone-url-reachability.ts
  5. 22
      src/lib/services/git/repo-manager.ts
  6. 62
      src/lib/styles/repo.css
  7. 117
      src/routes/api/repos/[npub]/[repo]/clone-urls/reachability/+server.ts
  8. 106
      src/routes/repos/[npub]/[repo]/+page.svelte

21
README.md

@ -62,6 +62,7 @@ All three interfaces use the same underlying Nostr-based authentication and repo @@ -62,6 +62,7 @@ All three interfaces use the same underlying Nostr-based authentication and repo
- **NIP-98 HTTP Authentication**: Git operations (clone, push, pull) authenticated using ephemeral Nostr events
- **Auto-provisioning**: Automatically creates git repositories from NIP-34 announcements
- **Multi-remote Sync**: Automatically syncs repositories to multiple remotes listed in announcements
- **GRASP Interoperability**: Minimal GRASP support for seamless compatibility with GRASP servers
- **Repository Size Limits**: Enforces 2 GB maximum repository size
- **Relay Write Proof**: Verifies users can write to at least one default Nostr relay before allowing operations
@ -415,12 +416,15 @@ The credential helper will automatically generate NIP-98 authentication tokens f @@ -415,12 +416,15 @@ The credential helper will automatically generate NIP-98 authentication tokens f
# List repositories
gitrep repos list
# Get repository details
# Get repository details (includes clone URL reachability)
gitrep repos get <npub> <repo>
# Push to all remotes
gitrep push-all main
# Pull from all remotes and merge
gitrep pull-all --merge
# Publish repository announcement
gitrep publish repo-announcement <repo-name>
```
@ -523,6 +527,21 @@ src/ @@ -523,6 +527,21 @@ src/
└── hooks.server.ts # Server initialization (starts polling)
```
## GRASP Support
GitRepublic provides **minimal GRASP (Git Repository Announcement and Synchronization Protocol) interoperability**:
- ✅ **GRASP Server Detection**: Automatically identifies GRASP servers from repository announcements
- ✅ **Clone URL Reachability**: Tests and displays reachability status for all clone URLs
- ✅ **Multi-Remote Sync**: Syncs to all remotes (including GRASP servers) when you push
- ✅ **Local Pull Command**: `gitrep pull-all --merge` to fetch and merge from all remotes
- ✅ **Standard Git Operations**: Full compatibility with GRASP servers (clone, push, pull)
**What we don't support** (by design):
- ❌ Full GRASP-01 server compliance (we're not a full GRASP server)
- ❌ GRASP-02 proactive sync (no server-side hourly pulls - user-controlled via CLI)
- ❌ GRASP-05 archive mode
## Additional Documentation
- [Architecture FAQ](./docs/ARCHITECTURE_FAQ.md) - Answers to common architecture questions

53
docs/tutorial.md

@ -177,6 +177,8 @@ This automatically configures the credential helper and commit signing hook. See @@ -177,6 +177,8 @@ This automatically configures the credential helper and commit signing hook. See
If a repository has multiple clone URLs configured, GitRepublic will automatically sync changes to all remotes when you push. You can see all clone URLs on the repository page.
For information about GRASP (Git Repository Announcement and Synchronization Protocol) support, including how to work with GRASP servers, see [GRASP.md](./GRASP.md).
---
## Making Changes and Pushing
@ -824,47 +826,44 @@ See the [NIP-34 documentation](/docs/nip34) for full details. @@ -824,47 +826,44 @@ See the [NIP-34 documentation](/docs/nip34) for full details.
### GRASP Protocol Support
GitRepublic supports the [GRASP (Git Repository Announcement and Synchronization Protocol)](https://gitworkshop.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/grasp) for interoperability with other git hosting services built on Nostr.
GitRepublic provides **minimal GRASP (Git Repository Announcement and Synchronization Protocol) interoperability** for seamless compatibility with GRASP servers.
**What is GRASP?**
GRASP is a protocol that extends NIP-34 to provide a standardized way for git repositories to be announced, synchronized, and managed across different hosting providers. GRASP servers implement additional NIP-34 event kinds (such as patches, pull request updates, repository state tracking, and user grasp lists) that enable more advanced collaboration features.
GRASP is a protocol specification for decentralized git hosting that combines git smart HTTP with Nostr relays. GRASP servers provide git repository hosting with Nostr-based announcements and state management.
**How GitRepublic Supports GRASP:**
**What GitRepublic Supports:**
While GitRepublic is directly git-based and doesn't require all NIP-34 features to function, we fully support repositories hosted on GRASP servers:
1. **GRASP Server Detection**: Automatically identifies GRASP servers from repository announcements using GRASP-01 identification (clone URL pattern + matching `relays` tag)
1. **Multi-Remote Synchronization**: When you create a repository announcement with multiple `clone` URLs (including GRASP server URLs), GitRepublic automatically:
- Syncs from GRASP servers when provisioning new repositories
- Pushes changes to all configured remotes (including GRASP servers) after each push
- Keeps your repository synchronized across all hosting providers
2. **Clone URL Reachability**: Tests and displays reachability status for all clone URLs, showing which remotes (including GRASP servers) are accessible
2. **On-Demand Repository Fetching**: If a repository is announced on Nostr but not yet provisioned locally, GitRepublic can:
- Automatically fetch the repository from any configured clone URL (including GRASP servers)
- Display and serve repositories hosted entirely on GRASP servers
- Clone repositories from GRASP servers when users access them
3. **Multi-Remote Synchronization**: When you push, automatically syncs to all remotes listed in your announcement, including GRASP servers
3. **Interoperability**: You can:
- Use GitRepublic as your primary git host while syncing to GRASP servers
- Host repositories on GRASP servers and have them accessible through GitRepublic
- Migrate repositories between GRASP servers and GitRepublic seamlessly
4. **Local Pull Command**: Use `gitrep pull-all --merge` to fetch and merge from all remotes (including GRASP servers)
- Checks reachability first, only pulls from accessible remotes
- Detects conflicts before merging (aborts unless `--allow-conflicts`)
**Example: Using GRASP Servers**
5. **Standard Git Operations**: Full compatibility with GRASP servers for clone, push, pull using standard git smart HTTP protocol
When creating a repository, you can add GRASP server URLs as clone URLs:
**What We Don't Support (By Design):**
```
https://grasp.example.com/user/repo.git
```
- Full GRASP-01 server compliance (we're not a full GRASP server)
- GRASP-02 proactive sync (no server-side hourly pulls - user-controlled via CLI)
- GRASP-05 archive mode
GitRepublic will automatically:
- Clone from the GRASP server if the repo doesn't exist locally
- Push all changes to the GRASP server after each push
- Keep both repositories in sync
**Example: Working with GRASP Servers**
This means you can use GitRepublic's direct git-based workflow while maintaining compatibility with GRASP-based services that use patches, PR updates, and other advanced NIP-34 features.
```bash
# Clone from a GRASP server (works just like any git server)
gitrep clone https://grasp.example.com/npub1.../repo.git
# Push to your repo (automatically syncs to all remotes including GRASP)
gitrep push origin main
For more information about the GRASP protocol, see the [GRASP Protocol Documentation](https://gitworkshop.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/grasp).
# Pull from all remotes including GRASP servers
gitrep pull-all --merge
```
### NIP-98 HTTP Authentication

1
nostr/commit-signatures.jsonl

@ -56,3 +56,4 @@ @@ -56,3 +56,4 @@
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771754094,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"146ea5bbc462c4f0188ec4a35a248c2cf518af7088714a4c1ce8e6e35f524e2a","sig":"dfc5d8d9a2f35e1898404d096f6e3e334885cdb0076caab0f3ea3efd1236e53d4172ed2b9ec16cff80ff364898c287ddb400b7a52cb65a3aedc05bb9df0f7ace"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771754488,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix menu responsivenes on repo-header"]],"content":"Signed commit: fix menu responsivenes on repo-header","id":"4dd8101d8edc9431df49d9fe23b7e1e545e11ef32b024b44f871bb962fb8ad4c","sig":"dbcfbfafe02495971b3f3d18466ecf1d894e4001a41e4038d17fd78bb65124de347017273a0a437c397a79ff8226ec6b0718436193e474ef8969392df027fa34"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771755811,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix creating new branch"]],"content":"Signed commit: fix creating new branch","id":"bc6c623532064f9b2db08fa41bbc6c5ff42419415ca7e1ecb1162a884face2eb","sig":"ad1152e2848755e1afa7d9350716fa6bb709698a5036e21efa61b3ac755d334155f02a0622ad49f6dc060d523f4f886eb2acc8c80356a426b0d8ba454fdcb8ee"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771829031,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix file management and refactor"]],"content":"Signed commit: fix file management and refactor","id":"626196cdbf9eab28b44990706281878083d66983b503e8a81df7421054ed6caf","sig":"516c0001a800083411a1e04340e82116a82c975f38b984e92ebe021b61271ba7d6f645466ddba3594320c228193e708675a5d7a144b2f3d5e9bfbc65c4c7372b"}

394
src/lib/services/git/clone-url-reachability.ts

@ -0,0 +1,394 @@ @@ -0,0 +1,394 @@
/**
* Service for testing clone URL reachability
* Checks if git clone URLs are accessible and responding
*/
import logger from '../logger.js';
/**
* Git server type classification
*
* Note: Both 'git' and 'grasp' servers use the same git smart HTTP protocol.
* The distinction is informational:
* - 'git': Regular git server (GitHub, GitLab, Gitea, etc.)
* - 'grasp': GRASP server (git server + Nostr relay + GRASP features)
* - 'unknown': Could not determine (shouldn't happen in practice)
*/
export type GitServerType = 'git' | 'grasp' | 'unknown';
export interface ReachabilityResult {
url: string;
reachable: boolean;
error?: string;
checkedAt: number;
serverType: GitServerType;
}
/**
* Check if a URL has npub in the path (potential GRASP server pattern)
* Note: This alone doesn't make it a GRASP server - need to check relays tag too
*/
function hasNpubInPath(url: string): boolean {
// GRASP URLs have npub (starts with npub1) in the path
return /\/npub1[a-z0-9]+/i.test(url);
}
/**
* Extract base domain from a URL (hostname without protocol)
*/
function getBaseDomain(url: string): string | null {
try {
const urlObj = new URL(url);
return urlObj.hostname;
} catch {
return null;
}
}
/**
* Extract base domain from a relay URL (wss:// or ws://)
*/
function getRelayBaseDomain(relayUrl: string): string | null {
try {
// Remove ws:// or wss:// prefix
const httpUrl = relayUrl.replace(/^wss?:\/\//, 'https://');
return getBaseDomain(httpUrl);
} catch {
return null;
}
}
/**
* Check if a clone URL's domain matches any relay URL from the relays tag
* This is the proper way to identify GRASP servers per GRASP-01 spec
*/
function isGraspServer(cloneUrl: string, relayUrls: string[]): boolean {
// Must have npub in path AND matching relay URL
if (!hasNpubInPath(cloneUrl)) {
return false;
}
const cloneDomain = getBaseDomain(cloneUrl);
if (!cloneDomain) {
return false;
}
// Check if any relay URL matches the clone URL's domain
for (const relayUrl of relayUrls) {
const relayDomain = getRelayBaseDomain(relayUrl);
if (relayDomain && relayDomain === cloneDomain) {
return true;
}
}
return false;
}
/**
* Detect server type from URL, response, and optional relays tags
*
* Per GRASP-01 spec: A GRASP server is identified by:
* 1. Clone URL pattern: [http|https]://<grasp-path>/<valid-npub>/<string>.git
* 2. AND relays tag: [ws/wss]://<grasp-path> (matching the clone URL's domain)
*
* Note: Both GRASP and regular git servers use the same git protocol.
* We distinguish them for informational purposes (user awareness, future GRASP-specific features).
*
* @param url - The clone URL
* @param relayUrls - Optional array of relay URLs from the announcement's relays tag
* @param response - Optional HTTP response (for future header-based detection)
* @returns Server type: 'grasp' if both URL pattern and relay match, 'git' otherwise
*/
function detectServerType(
url: string,
relayUrls?: string[],
response?: Response
): GitServerType {
// If we have relay URLs, use proper GRASP detection
if (relayUrls && relayUrls.length > 0) {
if (isGraspServer(url, relayUrls)) {
return 'grasp';
}
} else {
// Fallback: if URL has npub but no relays context, we can't be sure
// But we'll still check the pattern for informational purposes
// (This handles cases where relays tag isn't available)
if (hasNpubInPath(url)) {
// Without relays tag, we can't definitively say it's GRASP
// But it might be, so we'll mark it as 'git' (not GRASP) to be conservative
// The CLI has better context, so it can make this determination
}
}
// Could also check response headers for GRASP indicators in the future
// (e.g., NIP-11 document, GRASP-specific headers)
// For now, if it's not GRASP by proper detection, assume regular git server
return 'git';
}
/**
* Test if a clone URL is reachable
* Attempts to connect to the URL and check if it responds
*
* @param url - The clone URL to test
* @param timeout - Timeout in milliseconds (default: 5000)
* @param relayUrls - Optional array of relay URLs from announcement's relays tag (for GRASP detection)
* @returns Promise resolving to reachability result
*/
export async function testCloneUrlReachability(
url: string,
timeout: number = 5000,
relayUrls?: string[]
): Promise<ReachabilityResult> {
const startTime = Date.now();
try {
// Parse URL to extract base URL for testing
const urlObj = new URL(url);
// For git URLs, we test the base domain
// Try to fetch info/refs endpoint (lightweight git protocol check)
const testUrl = `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}/info/refs?service=git-upload-pack`;
// Use AbortController for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
// Use fetch with timeout and proper error handling
// Note: fetch is available in Node.js 18+ and browsers
const response = await fetch(testUrl, {
method: 'GET',
signal: controller.signal,
// Don't follow redirects - we want to know if the server responds
redirect: 'manual',
// Set a reasonable timeout
headers: {
'User-Agent': 'GitRepublic/1.0',
'Accept': '*/*'
}
} as RequestInit);
clearTimeout(timeoutId);
// Consider reachable if we get any response (even 404 means server is up)
// 200, 401, 403, 404 all mean the server is reachable
// 500 might mean server is up but has issues, still consider reachable
const isReachable = response.status < 600; // Any valid HTTP status means reachable
// Detect server type
const serverType = detectServerType(url, relayUrls, response);
return {
url,
reachable: isReachable,
error: isReachable ? undefined : `HTTP ${response.status}`,
checkedAt: Date.now(),
serverType
};
} catch (fetchError: unknown) {
clearTimeout(timeoutId);
// Handle abort (timeout)
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
const serverType = detectServerType(url, relayUrls);
return {
url,
reachable: false,
error: 'Timeout',
checkedAt: Date.now(),
serverType
};
}
// Handle network errors
if (fetchError instanceof TypeError) {
// Usually means DNS resolution failed or connection refused
const serverType = detectServerType(url, relayUrls);
return {
url,
reachable: false,
error: 'Network error (DNS or connection failed)',
checkedAt: Date.now(),
serverType
};
}
// Other errors
const serverType = detectServerType(url, relayUrls);
return {
url,
reachable: false,
error: fetchError instanceof Error ? fetchError.message : String(fetchError),
checkedAt: Date.now(),
serverType
};
}
} catch (urlError) {
// Invalid URL format
const serverType = detectServerType(url, relayUrls);
return {
url,
reachable: false,
error: urlError instanceof Error ? urlError.message : 'Invalid URL format',
checkedAt: Date.now(),
serverType
};
}
}
/**
* Test multiple clone URLs in parallel
*
* @param urls - Array of clone URLs to test
* @param timeout - Timeout per URL in milliseconds (default: 5000)
* @param relayUrls - Optional array of relay URLs from announcement's relays tag (for GRASP detection)
* @returns Promise resolving to array of reachability results
*/
export async function testCloneUrlsReachability(
urls: string[],
timeout: number = 5000,
relayUrls?: string[]
): Promise<ReachabilityResult[]> {
// Test all URLs in parallel
const results = await Promise.allSettled(
urls.map(url => testCloneUrlReachability(url, timeout, relayUrls))
);
// Convert settled results to reachability results
return results.map((result, index) => {
if (result.status === 'fulfilled') {
return result.value;
} else {
const url = urls[index];
return {
url,
reachable: false,
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
checkedAt: Date.now(),
serverType: detectServerType(url, relayUrls)
};
}
});
}
/**
* Cache for reachability results
* Key: URL, Value: { result, expiresAt }
*/
const reachabilityCache = new Map<string, { result: ReachabilityResult; expiresAt: number }>();
/**
* Cache duration: 5 minutes
*/
const CACHE_DURATION_MS = 5 * 60 * 1000;
/**
* Get cached reachability result or test if not cached/expired
*
* @param url - The clone URL to check
* @param timeout - Timeout in milliseconds (default: 5000)
* @param forceRefresh - Force refresh even if cached (default: false)
* @param relayUrls - Optional array of relay URLs from announcement's relays tag (for GRASP detection)
* @returns Promise resolving to reachability result
*/
export async function getCloneUrlReachability(
url: string,
timeout: number = 5000,
forceRefresh: boolean = false,
relayUrls?: string[]
): Promise<ReachabilityResult> {
const now = Date.now();
const cached = reachabilityCache.get(url);
// Return cached result if valid and not forcing refresh
// Note: We cache by URL only, so serverType might be incorrect if relayUrls change
// But this is acceptable since relayUrls rarely change for a given repo
if (!forceRefresh && cached && cached.expiresAt > now) {
return cached.result;
}
// Test reachability
const result = await testCloneUrlReachability(url, timeout, relayUrls);
// Cache the result
reachabilityCache.set(url, {
result,
expiresAt: now + CACHE_DURATION_MS
});
return result;
}
/**
* Get reachability for multiple URLs with caching
*
* @param urls - Array of clone URLs to check
* @param timeout - Timeout per URL in milliseconds (default: 5000)
* @param forceRefresh - Force refresh even if cached (default: false)
* @param relayUrls - Optional array of relay URLs from announcement's relays tag (for GRASP detection)
* @returns Promise resolving to array of reachability results
*/
export async function getCloneUrlsReachability(
urls: string[],
timeout: number = 5000,
forceRefresh: boolean = false,
relayUrls?: string[]
): Promise<ReachabilityResult[]> {
const now = Date.now();
const results: ReachabilityResult[] = [];
const urlsToTest: string[] = [];
const urlIndices: number[] = [];
// Check cache for each URL
urls.forEach((url, index) => {
const cached = reachabilityCache.get(url);
if (!forceRefresh && cached && cached.expiresAt > now) {
// Use cached result
results[index] = cached.result;
} else {
// Need to test
urlsToTest.push(url);
urlIndices.push(index);
}
});
// Test URLs that aren't cached
if (urlsToTest.length > 0) {
const testResults = await testCloneUrlsReachability(urlsToTest, timeout, relayUrls);
// Store results and cache them
testResults.forEach((result, testIndex) => {
const originalIndex = urlIndices[testIndex];
results[originalIndex] = result;
// Cache the result
reachabilityCache.set(result.url, {
result,
expiresAt: now + CACHE_DURATION_MS
});
});
}
return results;
}
/**
* Clear reachability cache
*/
export function clearReachabilityCache(): void {
reachabilityCache.clear();
}
/**
* Clear expired entries from cache
*/
export function clearExpiredCacheEntries(): void {
const now = Date.now();
for (const [url, cached] of reachabilityCache.entries()) {
if (cached.expiresAt <= now) {
reachabilityCache.delete(url);
}
}
}

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

@ -739,10 +739,9 @@ Your commits will all be signed by your Nostr keys and saved to the event files @@ -739,10 +739,9 @@ Your commits will all be signed by your Nostr keys and saved to the event files
try {
// Filter and convert URLs:
// 1. Skip SSH URLs (git@... or ssh://) - convert to HTTPS when possible
// 2. Filter out localhost and our own domain
// 3. Prioritize HTTPS non-GRASP URLs, then GRASP URLs
// Filter and convert URLs while respecting the repo owner's order in the clone list.
// The owner knows their infrastructure best and has ordered URLs by preference.
// We only filter out localhost/our domain and convert SSH to HTTPS when possible.
const httpsUrls: string[] = [];
const sshUrls: string[] = [];
@ -759,23 +758,20 @@ Your commits will all be signed by your Nostr keys and saved to the event files @@ -759,23 +758,20 @@ Your commits will all be signed by your Nostr keys and saved to the event files
// Check if it's an SSH URL
if (url.startsWith('git@') || url.startsWith('ssh://')) {
sshUrls.push(url);
// Try to convert to HTTPS
// Try to convert to HTTPS (preserve original order by appending)
const httpsUrl = this.convertSshToHttps(url);
if (httpsUrl) {
httpsUrls.push(httpsUrl);
}
} else {
// It's already HTTPS/HTTP
// It's already HTTPS/HTTP - preserve original order
httpsUrls.push(url);
}
}
// Separate HTTPS URLs into non-GRASP and GRASP
const nonGraspHttpsUrls = httpsUrls.filter(url => !isGraspUrl(url));
const graspHttpsUrls = httpsUrls.filter(url => isGraspUrl(url));
// Prioritize: non-GRASP HTTPS, then GRASP HTTPS, then converted SSH->HTTPS, finally SSH (if no HTTPS available)
remoteUrls = [...nonGraspHttpsUrls, ...graspHttpsUrls];
// Respect the repo owner's order: use HTTPS URLs in the order they appeared in clone list
// This assumes the owner has ordered them by preference (best first)
remoteUrls = httpsUrls;
// If no HTTPS URLs, try SSH URLs (but log a warning)
if (remoteUrls.length === 0 && sshUrls.length > 0) {
@ -783,7 +779,7 @@ Your commits will all be signed by your Nostr keys and saved to the event files @@ -783,7 +779,7 @@ Your commits will all be signed by your Nostr keys and saved to the event files
remoteUrls = sshUrls;
}
// If no external URLs, try any URL that's not our domain
// If no external URLs, try any URL that's not our domain (preserve order)
if (remoteUrls.length === 0) {
remoteUrls = cloneUrls.filter(url => !url.includes(this.domain));
}

62
src/lib/styles/repo.css

@ -1531,6 +1531,68 @@ span.clone-more { @@ -1531,6 +1531,68 @@ span.clone-more {
color: var(--error-text);
}
.reachability-badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.75rem;
margin-left: 0.25rem;
}
.reachability-badge.reachable {
color: #22c55e; /* green-500 */
}
.reachability-badge.unreachable {
color: #ef4444; /* red-500 */
}
.reachability-badge.loading {
opacity: 0.6;
}
.reachability-refresh-button {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
background: var(--bg-secondary, #e8e8e8);
border: 1px solid var(--border-color, #ccc);
border-radius: 4px;
cursor: pointer;
transition: opacity 0.2s;
}
.reachability-refresh-button:hover:not(:disabled) {
opacity: 0.8;
}
.reachability-refresh-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.server-type-badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.7rem;
font-weight: 500;
margin-left: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.server-type-badge.grasp-badge {
background: #8b5cf6; /* purple-500 */
color: white;
}
.server-type-badge.git-badge {
background: #6b7280; /* gray-500 */
color: white;
}
.empty {
padding: 2rem;
text-align: center;

117
src/routes/api/repos/[npub]/[repo]/clone-urls/reachability/+server.ts

@ -0,0 +1,117 @@ @@ -0,0 +1,117 @@
/**
* API endpoint for testing clone URL reachability
* POST: Test reachability of clone URLs
* GET: Get cached reachability status
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getCloneUrlsReachability, type ReachabilityResult } from '$lib/services/git/clone-url-reachability.js';
import { extractCloneUrls } from '$lib/utils/nostr-utils.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { KIND } from '$lib/types/nostr.js';
import { nip19 } from 'nostr-tools';
import { requireNpubHex } from '$lib/utils/npub-utils.js';
import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js';
import { eventCache } from '$lib/services/nostr/event-cache.js';
import logger from '$lib/services/logger.js';
/**
* GET: Get reachability status for clone URLs
* Query params:
* - forceRefresh: boolean (optional) - Force refresh even if cached
*/
export const GET: RequestHandler = async ({ params, url }) => {
const { npub, repo } = params;
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
}
try {
// Decode npub to get pubkey
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
return error(400, 'Invalid npub format');
}
const repoOwnerPubkey = decoded.data as string;
// Fetch repository announcement
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, repoOwnerPubkey, eventCache);
const announcement = findRepoAnnouncement(allEvents, repo);
if (!announcement) {
return error(404, 'Repository announcement not found');
}
// Extract clone URLs
const cloneUrls = extractCloneUrls(announcement, false);
if (cloneUrls.length === 0) {
return json({ results: [] });
}
// Extract relay URLs from relays tag (for proper GRASP server detection)
const relayUrls: string[] = [];
for (const tag of announcement.tags) {
if (tag[0] === 'relays') {
for (let i = 1; i < tag.length; i++) {
const relayUrl = tag[i];
if (relayUrl && typeof relayUrl === 'string' && (relayUrl.startsWith('ws://') || relayUrl.startsWith('wss://'))) {
relayUrls.push(relayUrl);
}
}
}
}
// Check if force refresh is requested
const forceRefresh = url.searchParams.get('forceRefresh') === 'true';
// Get reachability for all clone URLs (with relay URLs for GRASP detection)
const results = await getCloneUrlsReachability(cloneUrls, 5000, forceRefresh, relayUrls.length > 0 ? relayUrls : undefined);
return json({ results });
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
logger.error({ error: errorMessage, npub, repo }, 'Failed to check clone URL reachability');
return error(500, `Failed to check clone URL reachability: ${errorMessage}`);
}
};
/**
* POST: Test reachability of specific clone URLs
* Body: { urls: string[], forceRefresh?: boolean }
*/
export const POST: RequestHandler = async ({ request, params }) => {
const { npub, repo } = params;
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
}
try {
const body = await request.json();
const { urls, forceRefresh = false } = body;
if (!Array.isArray(urls) || urls.length === 0) {
return error(400, 'urls must be a non-empty array');
}
// Validate URLs are strings
if (!urls.every(url => typeof url === 'string')) {
return error(400, 'All URLs must be strings');
}
// Get reachability for specified URLs
const results = await getCloneUrlsReachability(urls, 5000, forceRefresh);
return json({ results });
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
logger.error({ error: errorMessage, npub, repo }, 'Failed to test clone URL reachability');
return error(500, `Failed to test clone URL reachability: ${errorMessage}`);
}
};

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

@ -794,10 +794,56 @@ @@ -794,10 +794,56 @@
// Show all clone URLs (beyond the first 3)
let showAllCloneUrls = $state(false);
// Clone URL reachability
let cloneUrlReachability = $state<Map<string, { reachable: boolean; error?: string; checkedAt: number; serverType: 'git' | 'grasp' | 'unknown' }>>(new Map());
let loadingReachability = $state(false);
let checkingReachability = $state<Set<string>>(new Set());
// Guard to prevent README auto-load loop
let readmeAutoLoadAttempted = $state(false);
let readmeAutoLoadTimeout: ReturnType<typeof setTimeout> | null = null;
// Load clone URL reachability status
async function loadCloneUrlReachability(forceRefresh: boolean = false) {
if (!pageData.repoCloneUrls || pageData.repoCloneUrls.length === 0) {
return;
}
if (loadingReachability) return;
loadingReachability = true;
try {
const response = await fetch(
`/api/repos/${npub}/${repo}/clone-urls/reachability${forceRefresh ? '?forceRefresh=true' : ''}`,
{
headers: buildApiHeaders()
}
);
if (response.ok) {
const data = await response.json();
const newMap = new Map<string, { reachable: boolean; error?: string; checkedAt: number }>();
if (data.results && Array.isArray(data.results)) {
for (const result of data.results) {
newMap.set(result.url, {
reachable: result.reachable,
error: result.error,
checkedAt: result.checkedAt
});
}
}
cloneUrlReachability = newMap;
}
} catch (err) {
console.warn('Failed to load clone URL reachability:', err);
} finally {
loadingReachability = false;
checkingReachability.clear();
}
}
async function loadReadme() {
if (repoNotFound) return;
loadingReadme = true;
@ -2084,6 +2130,9 @@ @@ -2084,6 +2130,9 @@
// Initialize bookmarks service
bookmarksService = new BookmarksService(DEFAULT_NOSTR_SEARCH_RELAYS);
// Load clone URL reachability status
loadCloneUrlReachability().catch(err => console.warn('Failed to load clone URL reachability:', err));
// Decode npub to get repo owner pubkey for bookmark address
try {
const decoded = nip19.decode(npub);
@ -2126,6 +2175,9 @@ @@ -2126,6 +2175,9 @@
await loadForkInfo();
await loadRepoImages();
// Load clone URL reachability status
loadCloneUrlReachability().catch(err => console.warn('Failed to load clone URL reachability:', err));
// Set up auto-save if enabled
setupAutoSave().catch(err => console.warn('Failed to setup auto-save:', err));
});
@ -4334,16 +4386,27 @@ @@ -4334,16 +4386,27 @@
{/if}
{#if pageData.repoCloneUrls && pageData.repoCloneUrls.length > 0}
<div class="repo-clone-urls">
<button
class="clone-label-button"
onclick={() => cloneUrlsExpanded = !cloneUrlsExpanded}
aria-expanded={cloneUrlsExpanded}
>
<span class="clone-label">Clone URLs:</span>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<button
class="clone-label-button"
onclick={() => cloneUrlsExpanded = !cloneUrlsExpanded}
aria-expanded={cloneUrlsExpanded}
>
<span class="clone-label">Clone URLs:</span>
<svg class="clone-toggle-icon" class:expanded={cloneUrlsExpanded} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
<button
class="reachability-refresh-button"
onclick={() => loadCloneUrlReachability(true)}
disabled={loadingReachability}
title="Refresh reachability status"
style="padding: 0.25rem 0.5rem; font-size: 0.875rem; background: var(--bg-secondary, #e8e8e8); border: 1px solid var(--border-color, #ccc); border-radius: 4px; cursor: pointer;"
>
{loadingReachability ? 'Checking...' : '🔄 Check Reachability'}
</button>
</div>
<div class="clone-url-list" class:collapsed={!cloneUrlsExpanded}>
{#if isRepoCloned === true}
<button
@ -4365,6 +4428,8 @@ @@ -4365,6 +4428,8 @@
normalizedCv.includes(normalizedClone) ||
normalizedClone.includes(normalizedCv);
})}
{@const reachability = cloneUrlReachability.get(cloneUrl)}
{@const isChecking = checkingReachability.has(cloneUrl)}
<div class="clone-url-wrapper">
<code class="clone-url">{cloneUrl}</code>
{#if loadingVerification}
@ -4393,6 +4458,35 @@ @@ -4393,6 +4458,35 @@
<img src="/icons/alert-triangle.svg" alt="Not checked" class="icon-inline" />
</span>
{/if}
{#if isChecking || loadingReachability}
<span class="reachability-badge loading" title="Checking reachability...">
<span style="opacity: 0.5;"></span>
</span>
{:else if reachability !== undefined}
<span
class="reachability-badge"
class:reachable={reachability.reachable}
class:unreachable={!reachability.reachable}
title={reachability.reachable
? `Reachable${reachability.serverType === 'grasp' ? ' (GRASP server)' : reachability.serverType === 'git' ? ' (Git server)' : ''}`
: (reachability.error || 'Unreachable')}
>
{#if reachability.reachable}
<img src="/icons/check-circle.svg" alt="Reachable" class="icon-inline" style="color: green;" />
{:else}
<img src="/icons/x-circle.svg" alt="Unreachable" class="icon-inline" style="color: red;" />
{/if}
</span>
{#if reachability.serverType === 'grasp'}
<span class="server-type-badge grasp-badge" title="GRASP server (git server with Nostr relay and GRASP features)">
GRASP
</span>
{:else if reachability.serverType === 'git'}
<span class="server-type-badge git-badge" title="Git server (standard git smart HTTP)">
Git
</span>
{/if}
{/if}
</div>
{/each}
{#if pageData.repoCloneUrls.length > 3}

Loading…
Cancel
Save