commit
cf514964d4
23 changed files with 5517 additions and 0 deletions
@ -0,0 +1,10 @@ |
|||||||
|
.DS_Store |
||||||
|
node_modules |
||||||
|
/build |
||||||
|
/.svelte-kit |
||||||
|
/package |
||||||
|
.env |
||||||
|
.env.* |
||||||
|
!.env.example |
||||||
|
npm-debug.log |
||||||
|
yarn-error.log |
||||||
@ -0,0 +1,139 @@ |
|||||||
|
# Implementation Guide for git-http-backend Integration |
||||||
|
|
||||||
|
## Overview |
||||||
|
|
||||||
|
The git-http-backend integration needs to be implemented in `/src/routes/api/git/[...path]/+server.ts`. This route will handle all git HTTP operations (clone, push, pull). |
||||||
|
|
||||||
|
## URL Structure |
||||||
|
|
||||||
|
All git requests will follow this pattern: |
||||||
|
- `GET /api/git/{npub}/{repo-name}.git/info/refs?service=git-upload-pack` (clone/fetch) |
||||||
|
- `GET /api/git/{npub}/{repo-name}.git/info/refs?service=git-receive-pack` (push capability check) |
||||||
|
- `POST /api/git/{npub}/{repo-name}.git/git-upload-pack` (fetch) |
||||||
|
- `POST /api/git/{npub}/{repo-name}.git/git-receive-pack` (push) |
||||||
|
|
||||||
|
## Implementation Steps |
||||||
|
|
||||||
|
### 1. Parse Request Path |
||||||
|
|
||||||
|
Extract `npub` and `repo-name` from the path parameter: |
||||||
|
```typescript |
||||||
|
const match = params.path.match(/^([^\/]+)\/([^\/]+)\.git\/(.+)$/); |
||||||
|
if (!match) return new Response('Invalid path', { status: 400 }); |
||||||
|
const [, npub, repoName, gitPath] = match; |
||||||
|
``` |
||||||
|
|
||||||
|
### 2. Authenticate with NIP-98 |
||||||
|
|
||||||
|
For push operations, verify NIP-98 authentication: |
||||||
|
```typescript |
||||||
|
import { verifyEvent } from 'nostr-tools'; |
||||||
|
|
||||||
|
const authHeader = request.headers.get('Authorization'); |
||||||
|
if (!authHeader?.startsWith('Nostr ')) { |
||||||
|
return new Response('Unauthorized', { status: 401 }); |
||||||
|
} |
||||||
|
|
||||||
|
const nostrEvent = JSON.parse(authHeader.slice(7)); |
||||||
|
if (!verifyEvent(nostrEvent)) { |
||||||
|
return new Response('Invalid signature', { status: 401 }); |
||||||
|
} |
||||||
|
|
||||||
|
// Verify pubkey matches repo owner |
||||||
|
if (nostrEvent.pubkey !== expectedPubkey) { |
||||||
|
return new Response('Unauthorized', { status: 403 }); |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
### 3. Map to Git Repository Path |
||||||
|
|
||||||
|
Use `RepoManager` to get the full path: |
||||||
|
```typescript |
||||||
|
import { RepoManager } from '$lib/services/git/repo-manager.js'; |
||||||
|
|
||||||
|
const repoManager = new RepoManager(process.env.GIT_REPO_ROOT || '/repos'); |
||||||
|
const repoPath = join(repoManager.repoRoot, npub, `${repoName}.git`); |
||||||
|
|
||||||
|
if (!repoManager.repoExists(repoPath)) { |
||||||
|
return new Response('Repository not found', { status: 404 }); |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
### 4. Proxy to git-http-backend |
||||||
|
|
||||||
|
Execute git-http-backend as a subprocess: |
||||||
|
```typescript |
||||||
|
import { spawn } from 'child_process'; |
||||||
|
import { env } from '$env/dynamic/private'; |
||||||
|
|
||||||
|
const gitHttpBackend = '/usr/lib/git-core/git-http-backend'; // or wherever it's installed |
||||||
|
|
||||||
|
const envVars = { |
||||||
|
...process.env, |
||||||
|
GIT_PROJECT_ROOT: repoManager.repoRoot, |
||||||
|
GIT_HTTP_EXPORT_ALL: '1', |
||||||
|
REQUEST_METHOD: request.method, |
||||||
|
PATH_INFO: `/${npub}/${repoName}.git/${gitPath}`, |
||||||
|
QUERY_STRING: url.searchParams.toString(), |
||||||
|
CONTENT_TYPE: request.headers.get('Content-Type') || '', |
||||||
|
CONTENT_LENGTH: request.headers.get('Content-Length') || '0', |
||||||
|
}; |
||||||
|
|
||||||
|
const gitProcess = spawn(gitHttpBackend, [], { |
||||||
|
env: envVars, |
||||||
|
stdio: ['pipe', 'pipe', 'pipe'] |
||||||
|
}); |
||||||
|
|
||||||
|
// Pipe request body to git-http-backend |
||||||
|
if (request.body) { |
||||||
|
request.body.pipeTo(gitProcess.stdin); |
||||||
|
} |
||||||
|
|
||||||
|
// Return git-http-backend response |
||||||
|
return new Response(gitProcess.stdout, { |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/x-git-upload-pack-result', |
||||||
|
// or 'application/x-git-receive-pack-result' for push |
||||||
|
} |
||||||
|
}); |
||||||
|
``` |
||||||
|
|
||||||
|
### 5. Post-Receive Hook |
||||||
|
|
||||||
|
After successful push, sync to other remotes: |
||||||
|
```typescript |
||||||
|
// After successful git-receive-pack |
||||||
|
if (gitPath === 'git-receive-pack' && request.method === 'POST') { |
||||||
|
// Fetch NIP-34 announcement for this repo |
||||||
|
const announcement = await fetchRepoAnnouncement(npub, repoName); |
||||||
|
if (announcement) { |
||||||
|
const cloneUrls = extractCloneUrls(announcement); |
||||||
|
const otherUrls = cloneUrls.filter(url => !url.includes('git.imwald.eu')); |
||||||
|
await repoManager.syncToRemotes(repoPath, otherUrls); |
||||||
|
} |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
## Alternative: Use a Git Server Library |
||||||
|
|
||||||
|
Instead of calling git-http-backend directly, you could use a Node.js git server library: |
||||||
|
|
||||||
|
- `isomorphic-git` with `@isomorphic-git/http-server` |
||||||
|
- `node-git-server` |
||||||
|
- Custom implementation using `dugite` or `simple-git` |
||||||
|
|
||||||
|
## Testing |
||||||
|
|
||||||
|
Test with: |
||||||
|
```bash |
||||||
|
# Clone |
||||||
|
git clone https://git.imwald.eu/{npub}/{repo-name}.git |
||||||
|
|
||||||
|
# Push (requires NIP-98 auth) |
||||||
|
git push origin main |
||||||
|
``` |
||||||
|
|
||||||
|
For NIP-98 authentication, you'll need a git credential helper that: |
||||||
|
1. Intercepts git HTTP requests |
||||||
|
2. Signs a Nostr event with the user's key |
||||||
|
3. Adds `Authorization: Nostr {event}` header |
||||||
@ -0,0 +1,122 @@ |
|||||||
|
# gitrepublic-web |
||||||
|
|
||||||
|
A Nostr-based git server with NIP-34 repo announcements. Uses git-http-backend for git operations and provides a web interface for managing repositories. |
||||||
|
|
||||||
|
## Features |
||||||
|
|
||||||
|
- **NIP-34 Repo Announcements**: Display and manage repository announcements |
||||||
|
- **NIP-07 Authentication**: Sign up and authenticate using browser extensions |
||||||
|
- **Auto-provisioning**: Automatically create git repos from NIP-34 announcements |
||||||
|
- **Multi-remote Sync**: Sync repos to multiple remotes listed in announcements |
||||||
|
- **URL Structure**: `git.imwald.eu/{npub}/{repo-name}.git` |
||||||
|
- **User Relay Discovery**: Automatically fetches user's inbox/outbox relays from kind 10002 or 3 |
||||||
|
|
||||||
|
## Development |
||||||
|
|
||||||
|
```bash |
||||||
|
npm install |
||||||
|
npm run dev |
||||||
|
``` |
||||||
|
|
||||||
|
## Environment Variables |
||||||
|
|
||||||
|
- `NOSTRGIT_SECRET_KEY`: Server's nsec for signing repo announcements (optional, for server-side signing) |
||||||
|
- `GIT_REPO_ROOT`: Path to store git repositories (default: `/repos`) |
||||||
|
- `GIT_DOMAIN`: Domain for git repositories (default: `git.imwald.eu`) |
||||||
|
- `NOSTR_RELAYS`: Comma-separated list of Nostr relays (default: `wss://theforest.nostr1.com,wss://nostr.land,wss://relay.damus.io`) |
||||||
|
|
||||||
|
## Architecture |
||||||
|
|
||||||
|
- **Frontend**: SvelteKit + TypeScript |
||||||
|
- **Git Server**: git-http-backend wrapper (TODO: implement in `/src/routes/api/git/[...path]/+server.ts`) |
||||||
|
- **Authentication**: NIP-07 (browser extension) for web UI, NIP-98 (HTTP auth) for git operations |
||||||
|
- **Discovery**: NIP-34 repo announcements with automatic polling and provisioning |
||||||
|
|
||||||
|
## Project Structure |
||||||
|
|
||||||
|
``` |
||||||
|
src/ |
||||||
|
├── lib/ |
||||||
|
│ ├── services/ |
||||||
|
│ │ ├── nostr/ |
||||||
|
│ │ │ ├── nostr-client.ts # WebSocket client for Nostr relays |
||||||
|
│ │ │ ├── nip07-signer.ts # NIP-07 browser extension integration |
||||||
|
│ │ │ ├── nip19-utils.ts # Decode hex/nevent/naddr addresses |
||||||
|
│ │ │ ├── repo-polling.ts # Auto-provision repos from announcements |
||||||
|
│ │ │ └── user-relays.ts # Fetch user's preferred relays |
||||||
|
│ │ └── git/ |
||||||
|
│ │ └── repo-manager.ts # Server-side repo provisioning & syncing |
||||||
|
│ └── types/ |
||||||
|
│ └── nostr.ts # TypeScript types for Nostr |
||||||
|
├── routes/ |
||||||
|
│ ├── +page.svelte # Main page: list repos on server |
||||||
|
│ ├── signup/ |
||||||
|
│ │ └── +page.svelte # Sign-up: create/update repo announcements |
||||||
|
│ └── api/ |
||||||
|
│ └── git/ |
||||||
|
│ └── [...path]/ |
||||||
|
│ └── +server.ts # Git HTTP backend API (TODO) |
||||||
|
└── hooks.server.ts # Server initialization (starts polling) |
||||||
|
``` |
||||||
|
|
||||||
|
## Implementation Status |
||||||
|
|
||||||
|
✅ **Completed:** |
||||||
|
- NIP-07 authentication for sign-up page |
||||||
|
- Repo announcement display page |
||||||
|
- Repo announcement creation/update with hex/nevent/naddr support |
||||||
|
- User relay discovery (kind 10002 and 3) |
||||||
|
- NIP-34 polling and auto-provisioning service |
||||||
|
- Server-side repo manager for provisioning and syncing |
||||||
|
|
||||||
|
🚧 **In Progress:** |
||||||
|
- Git HTTP backend wrapper with Nostr authentication (NIP-98) |
||||||
|
|
||||||
|
## Next Steps |
||||||
|
|
||||||
|
1. **Implement git-http-backend integration** in `/src/routes/api/git/[...path]/+server.ts`: |
||||||
|
- Parse URL path to extract `{npub}/{repo-name}` |
||||||
|
- Authenticate using NIP-98 (HTTP Authorization header with Nostr event) |
||||||
|
- Proxy requests to `git-http-backend` CGI script |
||||||
|
- Handle git smart HTTP protocol (info/refs, git-upload-pack, git-receive-pack) |
||||||
|
- Trigger post-receive hooks to sync to other remotes |
||||||
|
|
||||||
|
2. **Set up git-http-backend**: |
||||||
|
- Install `git-http-backend` (usually comes with git) |
||||||
|
- Configure as CGI script or FastCGI |
||||||
|
- Set up proper permissions for repo directory |
||||||
|
|
||||||
|
3. **Implement NIP-98 authentication**: |
||||||
|
- Verify Nostr event signature in Authorization header |
||||||
|
- Check that pubkey matches repo owner (from URL) |
||||||
|
- Validate event timestamp (not too old) |
||||||
|
|
||||||
|
4. **Add post-receive hook**: |
||||||
|
- After successful push, extract other clone URLs from NIP-34 announcement |
||||||
|
- Sync to all other remotes using `git push --all` |
||||||
|
|
||||||
|
## Usage |
||||||
|
|
||||||
|
1. **Create a repository announcement:** |
||||||
|
- Go to `/signup` |
||||||
|
- Connect your NIP-07 extension |
||||||
|
- Enter repository name and description |
||||||
|
- Optionally load an existing announcement by providing hex ID, nevent, or naddr |
||||||
|
- Add clone URLs (git.imwald.eu will be added automatically) |
||||||
|
- Publish the announcement |
||||||
|
|
||||||
|
2. **View repositories:** |
||||||
|
- Go to `/` to see all repositories on git.imwald.eu |
||||||
|
- Repositories are automatically provisioned when announcements are published |
||||||
|
|
||||||
|
3. **Clone a repository:** |
||||||
|
```bash |
||||||
|
git clone https://git.imwald.eu/{npub}/{repo-name}.git |
||||||
|
``` |
||||||
|
|
||||||
|
4. **Push to repository:** |
||||||
|
```bash |
||||||
|
git remote add origin https://git.imwald.eu/{npub}/{repo-name}.git |
||||||
|
git push origin main |
||||||
|
``` |
||||||
|
(Requires NIP-98 authentication - TODO) |
||||||
@ -0,0 +1,31 @@ |
|||||||
|
{ |
||||||
|
"name": "gitrepublic-web", |
||||||
|
"version": "0.1.0", |
||||||
|
"type": "module", |
||||||
|
"description": "Nostr-based git server with NIP-34 repo announcements", |
||||||
|
"scripts": { |
||||||
|
"dev": "vite dev", |
||||||
|
"build": "tsc && vite build", |
||||||
|
"preview": "vite preview", |
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", |
||||||
|
"lint": "prettier --check . && eslint .", |
||||||
|
"format": "prettier --write ." |
||||||
|
}, |
||||||
|
"dependencies": { |
||||||
|
"@sveltejs/kit": "^2.0.0", |
||||||
|
"@sveltejs/vite-plugin-svelte": "^4.0.0", |
||||||
|
"nostr-tools": "^2.22.1", |
||||||
|
"svelte": "^5.0.0" |
||||||
|
}, |
||||||
|
"devDependencies": { |
||||||
|
"@sveltejs/adapter-node": "^5.0.0", |
||||||
|
"@types/node": "^20.0.0", |
||||||
|
"@typescript-eslint/eslint-plugin": "^6.0.0", |
||||||
|
"@typescript-eslint/parser": "^6.0.0", |
||||||
|
"eslint": "^8.0.0", |
||||||
|
"prettier": "^3.0.0", |
||||||
|
"svelte-check": "^3.0.0", |
||||||
|
"typescript": "^5.0.0", |
||||||
|
"vite": "^5.0.0" |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,12 @@ |
|||||||
|
// See https://kit.svelte.dev/docs/types#app
|
||||||
|
// for information about these types
|
||||||
|
declare global { |
||||||
|
namespace App { |
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface Platform {}
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export {}; |
||||||
@ -0,0 +1,12 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="utf-8" /> |
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" /> |
||||||
|
%sveltekit.head% |
||||||
|
</head> |
||||||
|
<body data-sveltekit-preload-data="hover"> |
||||||
|
<div style="display: contents">%sveltekit.body%</div> |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
/** |
||||||
|
* Server-side hooks for gitrepublic-web |
||||||
|
* Initializes repo polling service |
||||||
|
*/ |
||||||
|
|
||||||
|
import type { Handle } from '@sveltejs/kit'; |
||||||
|
import { RepoPollingService } from './lib/services/nostr/repo-polling.js'; |
||||||
|
import { GIT_DOMAIN } from './lib/config.js'; |
||||||
|
|
||||||
|
// Initialize polling service
|
||||||
|
const relays = (process.env.NOSTR_RELAYS || 'wss://theforest.nostr1.com,wss://nostr.land,wss://relay.damus.io').split(','); |
||||||
|
const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; |
||||||
|
const domain = GIT_DOMAIN; |
||||||
|
|
||||||
|
let pollingService: RepoPollingService | null = null; |
||||||
|
|
||||||
|
if (typeof process !== 'undefined') { |
||||||
|
pollingService = new RepoPollingService(relays, repoRoot, domain); |
||||||
|
pollingService.start(); |
||||||
|
console.log('Started repo polling service'); |
||||||
|
} |
||||||
|
|
||||||
|
export const handle: Handle = async ({ event, resolve }) => { |
||||||
|
return resolve(event); |
||||||
|
}; |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
/** |
||||||
|
* Application configuration |
||||||
|
* Centralized config that can be overridden by environment variables |
||||||
|
*/ |
||||||
|
|
||||||
|
/** |
||||||
|
* Git domain for repository URLs |
||||||
|
* Defaults to localhost:6543, can be overridden by GIT_DOMAIN env var |
||||||
|
*/ |
||||||
|
export const GIT_DOMAIN =
|
||||||
|
typeof process !== 'undefined' && process.env?.GIT_DOMAIN |
||||||
|
? process.env.GIT_DOMAIN |
||||||
|
: 'localhost:6543'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Get the full git URL for a repository |
||||||
|
*/ |
||||||
|
export function getGitUrl(npub: string, repoName: string): string { |
||||||
|
const protocol = GIT_DOMAIN.startsWith('localhost') ? 'http' : 'https'; |
||||||
|
return `${protocol}://${GIT_DOMAIN}/${npub}/${repoName}.git`; |
||||||
|
} |
||||||
@ -0,0 +1,145 @@ |
|||||||
|
/** |
||||||
|
* Repository manager for git repositories |
||||||
|
* Handles repo provisioning, syncing, and NIP-34 integration |
||||||
|
*/ |
||||||
|
|
||||||
|
import { exec } from 'child_process'; |
||||||
|
import { promisify } from 'util'; |
||||||
|
import { existsSync, mkdirSync } from 'fs'; |
||||||
|
import { join } from 'path'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
import { GIT_DOMAIN } from '../../config.js'; |
||||||
|
|
||||||
|
const execAsync = promisify(exec); |
||||||
|
|
||||||
|
export interface RepoPath { |
||||||
|
npub: string; |
||||||
|
repoName: string; |
||||||
|
fullPath: string; |
||||||
|
} |
||||||
|
|
||||||
|
export class RepoManager { |
||||||
|
private repoRoot: string; |
||||||
|
private domain: string; |
||||||
|
|
||||||
|
constructor(repoRoot: string = '/repos', domain: string = GIT_DOMAIN) { |
||||||
|
this.repoRoot = repoRoot; |
||||||
|
this.domain = domain; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse git domain URL to extract npub and repo name |
||||||
|
*/ |
||||||
|
parseRepoUrl(url: string): RepoPath | null { |
||||||
|
// Match: https://{domain}/{npub}/{repo-name}.git or http://{domain}/{npub}/{repo-name}.git
|
||||||
|
// Escape domain for regex (replace dots with \.)
|
||||||
|
const escapedDomain = this.domain.replace(/\./g, '\\.'); |
||||||
|
const match = url.match(new RegExp(`${escapedDomain}\\/(npub[a-z0-9]+)\\/([^\\/]+)\\.git`)); |
||||||
|
if (!match) return null; |
||||||
|
|
||||||
|
const [, npub, repoName] = match; |
||||||
|
const fullPath = join(this.repoRoot, npub, `${repoName}.git`); |
||||||
|
|
||||||
|
return { npub, repoName, fullPath }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create a bare git repository from a NIP-34 repo announcement |
||||||
|
*/ |
||||||
|
async provisionRepo(event: NostrEvent): Promise<void> { |
||||||
|
const cloneUrls = this.extractCloneUrls(event); |
||||||
|
const domainUrl = cloneUrls.find(url => url.includes(this.domain)); |
||||||
|
|
||||||
|
if (!domainUrl) { |
||||||
|
throw new Error(`No ${this.domain} URL found in repo announcement`); |
||||||
|
} |
||||||
|
|
||||||
|
const repoPath = this.parseRepoUrl(domainUrl); |
||||||
|
if (!repoPath) { |
||||||
|
throw new Error(`Invalid ${this.domain} URL format`); |
||||||
|
} |
||||||
|
|
||||||
|
// Create directory structure
|
||||||
|
const repoDir = join(this.repoRoot, repoPath.npub); |
||||||
|
if (!existsSync(repoDir)) { |
||||||
|
mkdirSync(repoDir, { recursive: true }); |
||||||
|
} |
||||||
|
|
||||||
|
// Create bare repository if it doesn't exist
|
||||||
|
if (!existsSync(repoPath.fullPath)) { |
||||||
|
await execAsync(`git init --bare "${repoPath.fullPath}"`); |
||||||
|
} |
||||||
|
|
||||||
|
// If there are other clone URLs, sync from them
|
||||||
|
const otherUrls = cloneUrls.filter(url => !url.includes(this.domain)); |
||||||
|
if (otherUrls.length > 0) { |
||||||
|
await this.syncFromRemotes(repoPath.fullPath, otherUrls); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Sync repository from multiple remote URLs |
||||||
|
*/ |
||||||
|
async syncFromRemotes(repoPath: string, remoteUrls: string[]): Promise<void> { |
||||||
|
for (const url of remoteUrls) { |
||||||
|
try { |
||||||
|
// Add remote if not exists
|
||||||
|
const remoteName = `remote-${remoteUrls.indexOf(url)}`; |
||||||
|
await execAsync(`cd "${repoPath}" && git remote add ${remoteName} "${url}" || true`); |
||||||
|
|
||||||
|
// Fetch from remote
|
||||||
|
await execAsync(`cd "${repoPath}" && git fetch ${remoteName} --all`); |
||||||
|
|
||||||
|
// Update all branches
|
||||||
|
await execAsync(`cd "${repoPath}" && git remote set-head ${remoteName} -a`); |
||||||
|
} catch (error) { |
||||||
|
console.error(`Failed to sync from ${url}:`, error); |
||||||
|
// Continue with other remotes
|
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Sync repository to multiple remote URLs after a push |
||||||
|
*/ |
||||||
|
async syncToRemotes(repoPath: string, remoteUrls: string[]): Promise<void> { |
||||||
|
for (const url of remoteUrls) { |
||||||
|
try { |
||||||
|
const remoteName = `remote-${remoteUrls.indexOf(url)}`; |
||||||
|
await execAsync(`cd "${repoPath}" && git remote add ${remoteName} "${url}" || true`); |
||||||
|
await execAsync(`cd "${repoPath}" && git push ${remoteName} --all --force`); |
||||||
|
await execAsync(`cd "${repoPath}" && git push ${remoteName} --tags --force`); |
||||||
|
} catch (error) { |
||||||
|
console.error(`Failed to sync to ${url}:`, error); |
||||||
|
// Continue with other remotes
|
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extract clone URLs from a NIP-34 repo announcement |
||||||
|
*/ |
||||||
|
private extractCloneUrls(event: NostrEvent): string[] { |
||||||
|
const urls: string[] = []; |
||||||
|
|
||||||
|
for (const tag of event.tags) { |
||||||
|
if (tag[0] === 'clone') { |
||||||
|
for (let i = 1; i < tag.length; i++) { |
||||||
|
const url = tag[i]; |
||||||
|
if (url && typeof url === 'string') { |
||||||
|
urls.push(url); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return urls; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if a repository exists |
||||||
|
*/ |
||||||
|
repoExists(repoPath: string): boolean { |
||||||
|
return existsSync(repoPath); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,32 @@ |
|||||||
|
/** |
||||||
|
* NIP-07 browser extension signer |
||||||
|
*/ |
||||||
|
|
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
declare global { |
||||||
|
interface Window { |
||||||
|
nostr?: { |
||||||
|
getPublicKey(): Promise<string>; |
||||||
|
signEvent(event: Omit<NostrEvent, 'sig' | 'id'>): Promise<NostrEvent>; |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function isNIP07Available(): boolean { |
||||||
|
return typeof window !== 'undefined' && typeof window.nostr !== 'undefined'; |
||||||
|
} |
||||||
|
|
||||||
|
export async function getPublicKeyWithNIP07(): Promise<string> { |
||||||
|
if (!isNIP07Available()) { |
||||||
|
throw new Error('NIP-07 extension not available'); |
||||||
|
} |
||||||
|
return await window.nostr!.getPublicKey(); |
||||||
|
} |
||||||
|
|
||||||
|
export async function signEventWithNIP07(event: Omit<NostrEvent, 'sig' | 'id'>): Promise<NostrEvent> { |
||||||
|
if (!isNIP07Available()) { |
||||||
|
throw new Error('NIP-07 extension not available'); |
||||||
|
} |
||||||
|
return await window.nostr!.signEvent(event); |
||||||
|
} |
||||||
@ -0,0 +1,58 @@ |
|||||||
|
/** |
||||||
|
* NIP-19 utilities for decoding bech32 addresses |
||||||
|
*/ |
||||||
|
|
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
|
||||||
|
export interface DecodedEvent { |
||||||
|
type: 'nevent' | 'naddr' | 'note'; |
||||||
|
id?: string; |
||||||
|
pubkey?: string; |
||||||
|
kind?: number; |
||||||
|
identifier?: string; |
||||||
|
relays?: string[]; |
||||||
|
} |
||||||
|
|
||||||
|
export function decodeNostrAddress(input: string): DecodedEvent | null { |
||||||
|
if (!input || input.trim() === '') return null; |
||||||
|
|
||||||
|
const trimmed = input.trim(); |
||||||
|
|
||||||
|
// If it's already hex (64 chars), treat as event ID
|
||||||
|
if (/^[a-f0-9]{64}$/i.test(trimmed)) { |
||||||
|
return { type: 'note', id: trimmed.toLowerCase() }; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const decoded = nip19.decode(trimmed); |
||||||
|
|
||||||
|
if (decoded.type === 'nevent') { |
||||||
|
const data = decoded.data as { id: string; pubkey?: string; relays?: string[] }; |
||||||
|
return { |
||||||
|
type: 'nevent', |
||||||
|
id: data.id, |
||||||
|
pubkey: data.pubkey, |
||||||
|
relays: data.relays |
||||||
|
}; |
||||||
|
} else if (decoded.type === 'naddr') { |
||||||
|
const data = decoded.data as { pubkey: string; kind: number; identifier: string; relays?: string[] }; |
||||||
|
return { |
||||||
|
type: 'naddr', |
||||||
|
pubkey: data.pubkey, |
||||||
|
kind: data.kind, |
||||||
|
identifier: data.identifier, |
||||||
|
relays: data.relays |
||||||
|
}; |
||||||
|
} else if (decoded.type === 'note') { |
||||||
|
return { |
||||||
|
type: 'note', |
||||||
|
id: decoded.data as string |
||||||
|
}; |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
// Not a valid bech32
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
@ -0,0 +1,122 @@ |
|||||||
|
/** |
||||||
|
* Nostr client for fetching and publishing events |
||||||
|
*/ |
||||||
|
|
||||||
|
import type { NostrEvent, NostrFilter } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
export class NostrClient { |
||||||
|
private relays: string[] = []; |
||||||
|
|
||||||
|
constructor(relays: string[]) { |
||||||
|
this.relays = relays; |
||||||
|
} |
||||||
|
|
||||||
|
async fetchEvents(filters: NostrFilter[]): Promise<NostrEvent[]> { |
||||||
|
const events: NostrEvent[] = []; |
||||||
|
|
||||||
|
// Fetch from all relays in parallel
|
||||||
|
const promises = this.relays.map(relay => this.fetchFromRelay(relay, filters)); |
||||||
|
const results = await Promise.allSettled(promises); |
||||||
|
|
||||||
|
for (const result of results) { |
||||||
|
if (result.status === 'fulfilled') { |
||||||
|
events.push(...result.value); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Deduplicate by event ID
|
||||||
|
const uniqueEvents = new Map<string, NostrEvent>(); |
||||||
|
for (const event of events) { |
||||||
|
if (!uniqueEvents.has(event.id) || event.created_at > uniqueEvents.get(event.id)!.created_at) { |
||||||
|
uniqueEvents.set(event.id, event); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return Array.from(uniqueEvents.values()); |
||||||
|
} |
||||||
|
|
||||||
|
private async fetchFromRelay(relay: string, filters: NostrFilter[]): Promise<NostrEvent[]> { |
||||||
|
return new Promise((resolve, reject) => { |
||||||
|
const ws = new WebSocket(relay); |
||||||
|
const events: NostrEvent[] = []; |
||||||
|
|
||||||
|
ws.onopen = () => { |
||||||
|
ws.send(JSON.stringify(['REQ', 'sub', ...filters])); |
||||||
|
}; |
||||||
|
|
||||||
|
ws.onmessage = (event: MessageEvent) => { |
||||||
|
const message = JSON.parse(event.data); |
||||||
|
|
||||||
|
if (message[0] === 'EVENT') { |
||||||
|
events.push(message[2]); |
||||||
|
} else if (message[0] === 'EOSE') { |
||||||
|
ws.close(); |
||||||
|
resolve(events); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
ws.onerror = (error) => { |
||||||
|
ws.close(); |
||||||
|
reject(error); |
||||||
|
}; |
||||||
|
|
||||||
|
setTimeout(() => { |
||||||
|
ws.close(); |
||||||
|
resolve(events); |
||||||
|
}, 5000); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
async publishEvent(event: NostrEvent, relays?: string[]): Promise<{ success: string[]; failed: Array<{ relay: string; error: string }> }> { |
||||||
|
const targetRelays = relays || this.relays; |
||||||
|
const success: string[] = []; |
||||||
|
const failed: Array<{ relay: string; error: string }> = []; |
||||||
|
|
||||||
|
const promises = targetRelays.map(async (relay) => { |
||||||
|
try { |
||||||
|
await this.publishToRelay(relay, event); |
||||||
|
success.push(relay); |
||||||
|
} catch (error) { |
||||||
|
failed.push({ relay, error: String(error) }); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
await Promise.allSettled(promises); |
||||||
|
|
||||||
|
return { success, failed }; |
||||||
|
} |
||||||
|
|
||||||
|
private async publishToRelay(relay: string, nostrEvent: NostrEvent): Promise<void> { |
||||||
|
return new Promise((resolve, reject) => { |
||||||
|
const ws = new WebSocket(relay); |
||||||
|
|
||||||
|
ws.onopen = () => { |
||||||
|
ws.send(JSON.stringify(['EVENT', nostrEvent])); |
||||||
|
}; |
||||||
|
|
||||||
|
ws.onmessage = (event: MessageEvent) => { |
||||||
|
const message = JSON.parse(event.data); |
||||||
|
|
||||||
|
if (message[0] === 'OK' && message[1] === nostrEvent.id) { |
||||||
|
if (message[2] === true) { |
||||||
|
ws.close(); |
||||||
|
resolve(); |
||||||
|
} else { |
||||||
|
ws.close(); |
||||||
|
reject(new Error(message[3] || 'Publish rejected')); |
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
ws.onerror = (error) => { |
||||||
|
ws.close(); |
||||||
|
reject(error); |
||||||
|
}; |
||||||
|
|
||||||
|
setTimeout(() => { |
||||||
|
ws.close(); |
||||||
|
reject(new Error('Timeout')); |
||||||
|
}, 5000); |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,107 @@ |
|||||||
|
/** |
||||||
|
* Service for polling NIP-34 repo announcements and auto-provisioning repos |
||||||
|
*/ |
||||||
|
|
||||||
|
import { NostrClient } from './nostr-client.js'; |
||||||
|
import { KIND } from '../../types/nostr.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
import { RepoManager } from '../git/repo-manager.js'; |
||||||
|
|
||||||
|
export class RepoPollingService { |
||||||
|
private nostrClient: NostrClient; |
||||||
|
private repoManager: RepoManager; |
||||||
|
private pollingInterval: number; |
||||||
|
private intervalId: NodeJS.Timeout | null = null; |
||||||
|
private domain: string; |
||||||
|
|
||||||
|
constructor( |
||||||
|
relays: string[], |
||||||
|
repoRoot: string, |
||||||
|
domain: string, |
||||||
|
pollingInterval: number = 60000 // 1 minute
|
||||||
|
) { |
||||||
|
this.nostrClient = new NostrClient(relays); |
||||||
|
this.repoManager = new RepoManager(repoRoot, domain); |
||||||
|
this.pollingInterval = pollingInterval; |
||||||
|
this.domain = domain; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Start polling for repo announcements |
||||||
|
*/ |
||||||
|
start(): void { |
||||||
|
if (this.intervalId) { |
||||||
|
this.stop(); |
||||||
|
} |
||||||
|
|
||||||
|
// Poll immediately
|
||||||
|
this.poll(); |
||||||
|
|
||||||
|
// Then poll at intervals
|
||||||
|
this.intervalId = setInterval(() => { |
||||||
|
this.poll(); |
||||||
|
}, this.pollingInterval); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Stop polling |
||||||
|
*/ |
||||||
|
stop(): void { |
||||||
|
if (this.intervalId) { |
||||||
|
clearInterval(this.intervalId); |
||||||
|
this.intervalId = null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Poll for new repo announcements and provision repos |
||||||
|
*/ |
||||||
|
private async poll(): Promise<void> { |
||||||
|
try { |
||||||
|
const events = await this.nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
||||||
|
limit: 100 |
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
// Filter for repos that list our domain
|
||||||
|
const relevantEvents = events.filter(event => { |
||||||
|
const cloneUrls = this.extractCloneUrls(event); |
||||||
|
return cloneUrls.some(url => url.includes(this.domain)); |
||||||
|
}); |
||||||
|
|
||||||
|
// Provision each repo
|
||||||
|
for (const event of relevantEvents) { |
||||||
|
try { |
||||||
|
await this.repoManager.provisionRepo(event); |
||||||
|
console.log(`Provisioned repo from announcement ${event.id}`); |
||||||
|
} catch (error) { |
||||||
|
console.error(`Failed to provision repo from ${event.id}:`, error); |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Error polling for repo announcements:', error); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extract clone URLs from a NIP-34 repo announcement |
||||||
|
*/ |
||||||
|
private extractCloneUrls(event: NostrEvent): string[] { |
||||||
|
const urls: string[] = []; |
||||||
|
|
||||||
|
for (const tag of event.tags) { |
||||||
|
if (tag[0] === 'clone') { |
||||||
|
for (let i = 1; i < tag.length; i++) { |
||||||
|
const url = tag[i]; |
||||||
|
if (url && typeof url === 'string') { |
||||||
|
urls.push(url); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return urls; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,77 @@ |
|||||||
|
/** |
||||||
|
* Service for fetching user's preferred relays from their inbox/outbox |
||||||
|
*/ |
||||||
|
|
||||||
|
import { NostrClient } from './nostr-client.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
const KIND_RELAY_LIST = 10002; |
||||||
|
const KIND_CONTACTS = 3; |
||||||
|
|
||||||
|
export async function getUserRelays( |
||||||
|
pubkey: string, |
||||||
|
nostrClient: NostrClient |
||||||
|
): Promise<{ inbox: string[]; outbox: string[] }> { |
||||||
|
const inbox: string[] = []; |
||||||
|
const outbox: string[] = []; |
||||||
|
|
||||||
|
try { |
||||||
|
// Fetch kind 10002 (relay list)
|
||||||
|
const relayListEvents = await nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND_RELAY_LIST], |
||||||
|
authors: [pubkey], |
||||||
|
limit: 1 |
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
if (relayListEvents.length > 0) { |
||||||
|
const event = relayListEvents[0]; |
||||||
|
for (const tag of event.tags) { |
||||||
|
if (tag[0] === 'relay' && tag[1]) { |
||||||
|
const relay = tag[1]; |
||||||
|
const read = tag[2] !== 'write'; |
||||||
|
const write = tag[2] !== 'read'; |
||||||
|
|
||||||
|
if (read) inbox.push(relay); |
||||||
|
if (write) outbox.push(relay); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Fallback to kind 3 (contacts) for older clients
|
||||||
|
if (inbox.length === 0 && outbox.length === 0) { |
||||||
|
const contactEvents = await nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND_CONTACTS], |
||||||
|
authors: [pubkey], |
||||||
|
limit: 1 |
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
if (contactEvents.length > 0) { |
||||||
|
const event = contactEvents[0]; |
||||||
|
// Extract relays from content (JSON) or tags
|
||||||
|
try { |
||||||
|
const content = JSON.parse(event.content); |
||||||
|
if (content.relays && Array.isArray(content.relays)) { |
||||||
|
inbox.push(...content.relays); |
||||||
|
outbox.push(...content.relays); |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// Not JSON, check tags
|
||||||
|
for (const tag of event.tags) { |
||||||
|
if (tag[0] === 'relay' && tag[1]) { |
||||||
|
inbox.push(tag[1]); |
||||||
|
outbox.push(tag[1]); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Failed to fetch user relays:', error); |
||||||
|
} |
||||||
|
|
||||||
|
return { inbox, outbox }; |
||||||
|
} |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
/** |
||||||
|
* Nostr type definitions |
||||||
|
*/ |
||||||
|
|
||||||
|
export interface NostrEvent { |
||||||
|
id: string; |
||||||
|
pubkey: string; |
||||||
|
created_at: number; |
||||||
|
kind: number; |
||||||
|
tags: string[][]; |
||||||
|
content: string; |
||||||
|
sig: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface NostrFilter { |
||||||
|
ids?: string[]; |
||||||
|
authors?: string[]; |
||||||
|
kinds?: number[]; |
||||||
|
'#e'?: string[]; |
||||||
|
'#p'?: string[]; |
||||||
|
'#d'?: string[]; |
||||||
|
since?: number; |
||||||
|
until?: number; |
||||||
|
limit?: number; |
||||||
|
} |
||||||
|
|
||||||
|
export const KIND = { |
||||||
|
REPO_ANNOUNCEMENT: 30617, |
||||||
|
} as const; |
||||||
@ -0,0 +1,12 @@ |
|||||||
|
/** |
||||||
|
* Root layout server load function |
||||||
|
* Provides config to all pages |
||||||
|
*/ |
||||||
|
|
||||||
|
import { GIT_DOMAIN } from '$lib/config.js'; |
||||||
|
|
||||||
|
export async function load() { |
||||||
|
return { |
||||||
|
gitDomain: GIT_DOMAIN |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,227 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import { goto } from '$app/navigation'; |
||||||
|
import { page } from '$app/stores'; |
||||||
|
import { NostrClient } from '../lib/services/nostr/nostr-client.js'; |
||||||
|
import { KIND } from '../lib/types/nostr.js'; |
||||||
|
import type { NostrEvent } from '../lib/types/nostr.js'; |
||||||
|
|
||||||
|
let repos = $state<NostrEvent[]>([]); |
||||||
|
let loading = $state(true); |
||||||
|
let error = $state<string | null>(null); |
||||||
|
|
||||||
|
const relays = [ |
||||||
|
'wss://theforest.nostr1.com', |
||||||
|
'wss://nostr.land', |
||||||
|
'wss://relay.damus.io' |
||||||
|
]; |
||||||
|
|
||||||
|
const nostrClient = new NostrClient(relays); |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
await loadRepos(); |
||||||
|
}); |
||||||
|
|
||||||
|
async function loadRepos() { |
||||||
|
loading = true; |
||||||
|
error = null; |
||||||
|
|
||||||
|
try { |
||||||
|
const events = await nostrClient.fetchEvents([ |
||||||
|
{ kinds: [KIND.REPO_ANNOUNCEMENT], limit: 100 } |
||||||
|
]); |
||||||
|
|
||||||
|
// Get git domain from layout data |
||||||
|
const gitDomain = $page.data.gitDomain || 'localhost:6543'; |
||||||
|
|
||||||
|
// Filter for repos that list our domain in clone tags |
||||||
|
repos = events.filter(event => { |
||||||
|
const cloneUrls = event.tags |
||||||
|
.filter(t => t[0] === 'clone') |
||||||
|
.flatMap(t => t.slice(1)) |
||||||
|
.filter(url => url && typeof url === 'string'); |
||||||
|
|
||||||
|
return cloneUrls.some(url => url.includes(gitDomain)); |
||||||
|
}); |
||||||
|
|
||||||
|
// Sort by created_at descending |
||||||
|
repos.sort((a, b) => b.created_at - a.created_at); |
||||||
|
} catch (e) { |
||||||
|
error = String(e); |
||||||
|
console.error('Failed to load repos:', e); |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function getRepoName(event: NostrEvent): string { |
||||||
|
const nameTag = event.tags.find(t => t[0] === 'name' && t[1]); |
||||||
|
if (nameTag?.[1]) return nameTag[1]; |
||||||
|
|
||||||
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1]; |
||||||
|
if (dTag) return dTag; |
||||||
|
|
||||||
|
return `Repository ${event.id.slice(0, 8)}`; |
||||||
|
} |
||||||
|
|
||||||
|
function getRepoDescription(event: NostrEvent): string { |
||||||
|
const descTag = event.tags.find(t => t[0] === 'description' && t[1]); |
||||||
|
return descTag?.[1] || ''; |
||||||
|
} |
||||||
|
|
||||||
|
function getCloneUrls(event: NostrEvent): string[] { |
||||||
|
const urls: string[] = []; |
||||||
|
|
||||||
|
for (const tag of event.tags) { |
||||||
|
if (tag[0] === 'clone') { |
||||||
|
for (let i = 1; i < tag.length; i++) { |
||||||
|
const url = tag[i]; |
||||||
|
if (url && typeof url === 'string') { |
||||||
|
urls.push(url); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return urls; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="container"> |
||||||
|
<header> |
||||||
|
<h1>gitrepublic</h1> |
||||||
|
<nav> |
||||||
|
<a href="/">Repositories</a> |
||||||
|
<a href="/signup">Sign Up</a> |
||||||
|
</nav> |
||||||
|
</header> |
||||||
|
|
||||||
|
<main> |
||||||
|
<div class="repos-header"> |
||||||
|
<h2>Repositories on {$page.data.gitDomain || 'localhost:6543'}</h2> |
||||||
|
<button onclick={loadRepos} disabled={loading}> |
||||||
|
{loading ? 'Loading...' : 'Refresh'} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if error} |
||||||
|
<div class="error"> |
||||||
|
Error loading repositories: {error} |
||||||
|
</div> |
||||||
|
{:else if loading} |
||||||
|
<div class="loading">Loading repositories...</div> |
||||||
|
{:else if repos.length === 0} |
||||||
|
<div class="empty">No repositories found.</div> |
||||||
|
{:else} |
||||||
|
<div class="repos-list"> |
||||||
|
{#each repos as repo} |
||||||
|
<div class="repo-card"> |
||||||
|
<h3>{getRepoName(repo)}</h3> |
||||||
|
{#if getRepoDescription(repo)} |
||||||
|
<p class="description">{getRepoDescription(repo)}</p> |
||||||
|
{/if} |
||||||
|
<div class="clone-urls"> |
||||||
|
<strong>Clone URLs:</strong> |
||||||
|
{#each getCloneUrls(repo) as url} |
||||||
|
<code>{url}</code> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
<div class="repo-meta"> |
||||||
|
<span>Created: {new Date(repo.created_at * 1000).toLocaleDateString()}</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</main> |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.container { |
||||||
|
max-width: 1200px; |
||||||
|
margin: 0 auto; |
||||||
|
padding: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
header { |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: center; |
||||||
|
margin-bottom: 2rem; |
||||||
|
border-bottom: 1px solid #e5e7eb; |
||||||
|
padding-bottom: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
nav { |
||||||
|
display: flex; |
||||||
|
gap: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
nav a { |
||||||
|
text-decoration: none; |
||||||
|
color: #3b82f6; |
||||||
|
} |
||||||
|
|
||||||
|
.repos-header { |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: center; |
||||||
|
margin-bottom: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
.repos-list { |
||||||
|
display: grid; |
||||||
|
gap: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.repo-card { |
||||||
|
border: 1px solid #e5e7eb; |
||||||
|
border-radius: 0.5rem; |
||||||
|
padding: 1.5rem; |
||||||
|
background: white; |
||||||
|
} |
||||||
|
|
||||||
|
.repo-card h3 { |
||||||
|
margin: 0 0 0.5rem 0; |
||||||
|
font-size: 1.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.description { |
||||||
|
color: #6b7280; |
||||||
|
margin: 0.5rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.clone-urls { |
||||||
|
margin: 1rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.clone-urls code { |
||||||
|
display: block; |
||||||
|
background: #f3f4f6; |
||||||
|
padding: 0.5rem; |
||||||
|
border-radius: 0.25rem; |
||||||
|
margin: 0.25rem 0; |
||||||
|
font-family: monospace; |
||||||
|
font-size: 0.875rem; |
||||||
|
} |
||||||
|
|
||||||
|
.repo-meta { |
||||||
|
font-size: 0.875rem; |
||||||
|
color: #6b7280; |
||||||
|
margin-top: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.error { |
||||||
|
background: #fee2e2; |
||||||
|
color: #991b1b; |
||||||
|
padding: 1rem; |
||||||
|
border-radius: 0.5rem; |
||||||
|
margin: 1rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.loading, .empty { |
||||||
|
text-align: center; |
||||||
|
padding: 2rem; |
||||||
|
color: #6b7280; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,40 @@ |
|||||||
|
/** |
||||||
|
* Git HTTP backend API route |
||||||
|
* Handles git clone, push, pull operations via git-http-backend |
||||||
|
*/ |
||||||
|
|
||||||
|
import { json } from '@sveltejs/kit'; |
||||||
|
import type { RequestHandler } from './$types'; |
||||||
|
|
||||||
|
// This will be implemented to proxy requests to git-http-backend
|
||||||
|
// For now, return a placeholder
|
||||||
|
export const GET: RequestHandler = async ({ params, url }) => { |
||||||
|
const path = params.path || ''; |
||||||
|
const service = url.searchParams.get('service'); |
||||||
|
|
||||||
|
// TODO: Implement git-http-backend integration
|
||||||
|
// This should:
|
||||||
|
// 1. Authenticate using NIP-98 (HTTP auth with Nostr)
|
||||||
|
// 2. Map URL path to git repo ({domain}/{npub}/{repo-name}.git)
|
||||||
|
// 3. Proxy request to git-http-backend
|
||||||
|
// 4. Handle git smart HTTP protocol
|
||||||
|
|
||||||
|
return json({
|
||||||
|
message: 'Git HTTP backend not yet implemented', |
||||||
|
path, |
||||||
|
service
|
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, url, request }) => { |
||||||
|
const path = params.path || ''; |
||||||
|
const service = url.searchParams.get('service'); |
||||||
|
|
||||||
|
// TODO: Implement git-http-backend integration for push operations
|
||||||
|
|
||||||
|
return json({
|
||||||
|
message: 'Git HTTP backend not yet implemented', |
||||||
|
path, |
||||||
|
service
|
||||||
|
}); |
||||||
|
}; |
||||||
@ -0,0 +1,475 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import { goto } from '$app/navigation'; |
||||||
|
import { page } from '$app/stores'; |
||||||
|
import { isNIP07Available, getPublicKeyWithNIP07, signEventWithNIP07 } from '../../lib/services/nostr/nip07-signer.js'; |
||||||
|
import { decodeNostrAddress } from '../../lib/services/nostr/nip19-utils.js'; |
||||||
|
import { NostrClient } from '../../lib/services/nostr/nostr-client.js'; |
||||||
|
import { KIND } from '../../lib/types/nostr.js'; |
||||||
|
import type { NostrEvent } from '../../lib/types/nostr.js'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
|
||||||
|
let nip07Available = $state(false); |
||||||
|
let loading = $state(false); |
||||||
|
let error = $state<string | null>(null); |
||||||
|
let success = $state(false); |
||||||
|
|
||||||
|
// Form fields |
||||||
|
let repoName = $state(''); |
||||||
|
let description = $state(''); |
||||||
|
let cloneUrls = $state<string[]>(['']); |
||||||
|
let existingRepoRef = $state(''); // hex, nevent, or naddr |
||||||
|
let loadingExisting = $state(false); |
||||||
|
|
||||||
|
const relays = [ |
||||||
|
'wss://theforest.nostr1.com', |
||||||
|
'wss://nostr.land', |
||||||
|
'wss://relay.damus.io' |
||||||
|
]; |
||||||
|
|
||||||
|
const nostrClient = new NostrClient(relays); |
||||||
|
|
||||||
|
onMount(() => { |
||||||
|
nip07Available = isNIP07Available(); |
||||||
|
}); |
||||||
|
|
||||||
|
function addCloneUrl() { |
||||||
|
cloneUrls = [...cloneUrls, '']; |
||||||
|
} |
||||||
|
|
||||||
|
function removeCloneUrl(index: number) { |
||||||
|
cloneUrls = cloneUrls.filter((_, i) => i !== index); |
||||||
|
} |
||||||
|
|
||||||
|
function updateCloneUrl(index: number, value: string) { |
||||||
|
const newUrls = [...cloneUrls]; |
||||||
|
newUrls[index] = value; |
||||||
|
cloneUrls = newUrls; |
||||||
|
} |
||||||
|
|
||||||
|
async function loadExistingRepo() { |
||||||
|
if (!existingRepoRef.trim()) return; |
||||||
|
|
||||||
|
loadingExisting = true; |
||||||
|
error = null; |
||||||
|
|
||||||
|
try { |
||||||
|
const decoded = decodeNostrAddress(existingRepoRef.trim()); |
||||||
|
if (!decoded) { |
||||||
|
error = 'Invalid format. Please provide a hex event ID, nevent, or naddr.'; |
||||||
|
loadingExisting = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
let event: NostrEvent | null = null; |
||||||
|
|
||||||
|
if (decoded.type === 'note' && decoded.id) { |
||||||
|
// Fetch by event ID |
||||||
|
const events = await nostrClient.fetchEvents([{ ids: [decoded.id], limit: 1 }]); |
||||||
|
event = events[0] || null; |
||||||
|
} else if (decoded.type === 'nevent' && decoded.id) { |
||||||
|
// Fetch by event ID |
||||||
|
const events = await nostrClient.fetchEvents([{ ids: [decoded.id], limit: 1 }]); |
||||||
|
event = events[0] || null; |
||||||
|
} else if (decoded.type === 'naddr' && decoded.pubkey && decoded.kind && decoded.identifier) { |
||||||
|
// Fetch parameterized replaceable event |
||||||
|
const events = await nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [decoded.kind], |
||||||
|
authors: [decoded.pubkey], |
||||||
|
'#d': [decoded.identifier], |
||||||
|
limit: 1 |
||||||
|
} |
||||||
|
]); |
||||||
|
event = events[0] || null; |
||||||
|
} |
||||||
|
|
||||||
|
if (!event) { |
||||||
|
error = 'Repository announcement not found. Make sure it exists on the relays.'; |
||||||
|
loadingExisting = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (event.kind !== KIND.REPO_ANNOUNCEMENT) { |
||||||
|
error = 'The provided event is not a repository announcement (kind 30617).'; |
||||||
|
loadingExisting = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Populate form with existing data |
||||||
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''; |
||||||
|
const nameTag = event.tags.find(t => t[0] === 'name')?.[1] || ''; |
||||||
|
const descTag = event.tags.find(t => t[0] === 'description')?.[1] || ''; |
||||||
|
|
||||||
|
repoName = nameTag || dTag; |
||||||
|
description = descTag; |
||||||
|
|
||||||
|
// Extract clone URLs |
||||||
|
const urls: string[] = []; |
||||||
|
for (const tag of event.tags) { |
||||||
|
if (tag[0] === 'clone') { |
||||||
|
for (let i = 1; i < tag.length; i++) { |
||||||
|
const url = tag[i]; |
||||||
|
if (url && typeof url === 'string') { |
||||||
|
urls.push(url); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
cloneUrls = urls.length > 0 ? urls : ['']; |
||||||
|
|
||||||
|
} catch (e) { |
||||||
|
error = `Failed to load repository: ${String(e)}`; |
||||||
|
} finally { |
||||||
|
loadingExisting = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function submit() { |
||||||
|
if (!nip07Available) { |
||||||
|
error = 'NIP-07 extension is required. Please install a Nostr browser extension.'; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (!repoName.trim()) { |
||||||
|
error = 'Repository name is required.'; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
loading = true; |
||||||
|
error = null; |
||||||
|
|
||||||
|
try { |
||||||
|
const pubkey = await getPublicKeyWithNIP07(); |
||||||
|
const npub = nip19.npubEncode(pubkey); |
||||||
|
|
||||||
|
// Normalize repo name to d-tag format |
||||||
|
const dTag = repoName |
||||||
|
.toLowerCase() |
||||||
|
.trim() |
||||||
|
.replace(/[^\w\s-]/g, '') |
||||||
|
.replace(/\s+/g, '-') |
||||||
|
.replace(/-+/g, '-') |
||||||
|
.replace(/^-+|-+$/g, ''); |
||||||
|
|
||||||
|
// Get git domain from layout data |
||||||
|
const gitDomain = $page.data.gitDomain || 'localhost:6543'; |
||||||
|
const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https'; |
||||||
|
const gitUrl = `${protocol}://${gitDomain}/${npub}/${dTag}.git`; |
||||||
|
|
||||||
|
// Build clone URLs - always include our domain |
||||||
|
const allCloneUrls = [ |
||||||
|
gitUrl, |
||||||
|
...cloneUrls.filter(url => url.trim() && !url.includes(gitDomain)) |
||||||
|
]; |
||||||
|
|
||||||
|
// Build tags |
||||||
|
const tags: string[][] = [ |
||||||
|
['d', dTag], |
||||||
|
['name', repoName], |
||||||
|
...(description ? [['description', description]] : []), |
||||||
|
...allCloneUrls.map(url => ['clone', url]), |
||||||
|
['relays', ...relays] |
||||||
|
]; |
||||||
|
|
||||||
|
// Build event |
||||||
|
const eventTemplate: Omit<NostrEvent, 'sig' | 'id'> = { |
||||||
|
kind: KIND.REPO_ANNOUNCEMENT, |
||||||
|
pubkey, |
||||||
|
created_at: Math.floor(Date.now() / 1000), |
||||||
|
content: '', // Empty per NIP-34 |
||||||
|
tags |
||||||
|
}; |
||||||
|
|
||||||
|
// Sign with NIP-07 |
||||||
|
const signedEvent = await signEventWithNIP07(eventTemplate); |
||||||
|
|
||||||
|
// Get user's inbox/outbox relays (from kind 3 or 10002) |
||||||
|
const { getUserRelays } = await import('../../lib/services/nostr/user-relays.js'); |
||||||
|
const { inbox, outbox } = await getUserRelays(pubkey, nostrClient); |
||||||
|
|
||||||
|
// Combine user's outbox with default relays |
||||||
|
const userRelays = [...new Set([...outbox, ...relays])]; |
||||||
|
|
||||||
|
// Publish to user's outboxes and standard relays |
||||||
|
const result = await nostrClient.publishEvent(signedEvent, userRelays); |
||||||
|
|
||||||
|
if (result.success.length > 0) { |
||||||
|
success = true; |
||||||
|
setTimeout(() => { |
||||||
|
goto('/'); |
||||||
|
}, 2000); |
||||||
|
} else { |
||||||
|
error = 'Failed to publish to any relays.'; |
||||||
|
} |
||||||
|
|
||||||
|
} catch (e) { |
||||||
|
error = `Failed to create repository announcement: ${String(e)}`; |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="container"> |
||||||
|
<header> |
||||||
|
<h1>gitrepublic</h1> |
||||||
|
<nav> |
||||||
|
<a href="/">Repositories</a> |
||||||
|
<a href="/signup">Sign Up</a> |
||||||
|
</nav> |
||||||
|
</header> |
||||||
|
|
||||||
|
<main> |
||||||
|
<h2>Create or Update Repository Announcement</h2> |
||||||
|
|
||||||
|
{#if !nip07Available} |
||||||
|
<div class="warning"> |
||||||
|
<p>NIP-07 browser extension is required to sign repository announcements.</p> |
||||||
|
<p>Please install a Nostr browser extension (like Alby, nos2x, or similar).</p> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if error} |
||||||
|
<div class="error">{error}</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if success} |
||||||
|
<div class="success"> |
||||||
|
Repository announcement published successfully! Redirecting... |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<form onsubmit={(e) => { e.preventDefault(); submit(); }}> |
||||||
|
<div class="form-group"> |
||||||
|
<label for="existing-repo-ref"> |
||||||
|
Load Existing Repository (optional) |
||||||
|
<small>Enter hex event ID, nevent, or naddr to update an existing announcement</small> |
||||||
|
</label> |
||||||
|
<div class="input-group"> |
||||||
|
<input |
||||||
|
id="existing-repo-ref" |
||||||
|
type="text" |
||||||
|
bind:value={existingRepoRef} |
||||||
|
placeholder="hex event ID, nevent1..., or naddr1..." |
||||||
|
disabled={loading || loadingExisting} |
||||||
|
/> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
onclick={loadExistingRepo} |
||||||
|
disabled={loading || loadingExisting || !existingRepoRef.trim()} |
||||||
|
> |
||||||
|
{loadingExisting ? 'Loading...' : 'Load'} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="form-group"> |
||||||
|
<label for="repo-name"> |
||||||
|
Repository Name * |
||||||
|
<small>Will be used as the d-tag (normalized to lowercase with hyphens)</small> |
||||||
|
</label> |
||||||
|
<input |
||||||
|
id="repo-name" |
||||||
|
type="text" |
||||||
|
bind:value={repoName} |
||||||
|
placeholder="my-awesome-repo" |
||||||
|
required |
||||||
|
disabled={loading} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="form-group"> |
||||||
|
<label for="description"> |
||||||
|
Description |
||||||
|
</label> |
||||||
|
<textarea |
||||||
|
id="description" |
||||||
|
bind:value={description} |
||||||
|
placeholder="Repository description" |
||||||
|
rows={3} |
||||||
|
disabled={loading} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="form-group"> |
||||||
|
<label> |
||||||
|
Clone URLs |
||||||
|
<small>{$page.data.gitDomain || 'localhost:6543'} will be added automatically</small> |
||||||
|
</label> |
||||||
|
{#each cloneUrls as url, index} |
||||||
|
<div class="input-group"> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
value={url} |
||||||
|
oninput={(e) => updateCloneUrl(index, e.currentTarget.value)} |
||||||
|
placeholder="https://github.com/user/repo.git" |
||||||
|
disabled={loading} |
||||||
|
/> |
||||||
|
{#if cloneUrls.length > 1} |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
onclick={() => removeCloneUrl(index)} |
||||||
|
disabled={loading} |
||||||
|
> |
||||||
|
Remove |
||||||
|
</button> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
onclick={addCloneUrl} |
||||||
|
disabled={loading} |
||||||
|
class="add-button" |
||||||
|
> |
||||||
|
+ Add Clone URL |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="form-actions"> |
||||||
|
<button |
||||||
|
type="submit" |
||||||
|
disabled={loading || !nip07Available} |
||||||
|
> |
||||||
|
{loading ? 'Publishing...' : 'Publish Repository Announcement'} |
||||||
|
</button> |
||||||
|
<a href="/" class="cancel-link">Cancel</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</main> |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.container { |
||||||
|
max-width: 800px; |
||||||
|
margin: 0 auto; |
||||||
|
padding: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
header { |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: center; |
||||||
|
margin-bottom: 2rem; |
||||||
|
border-bottom: 1px solid #e5e7eb; |
||||||
|
padding-bottom: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
nav { |
||||||
|
display: flex; |
||||||
|
gap: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
nav a { |
||||||
|
text-decoration: none; |
||||||
|
color: #3b82f6; |
||||||
|
} |
||||||
|
|
||||||
|
.warning { |
||||||
|
background: #fef3c7; |
||||||
|
border: 1px solid #f59e0b; |
||||||
|
border-radius: 0.5rem; |
||||||
|
padding: 1rem; |
||||||
|
margin-bottom: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.error { |
||||||
|
background: #fee2e2; |
||||||
|
color: #991b1b; |
||||||
|
padding: 1rem; |
||||||
|
border-radius: 0.5rem; |
||||||
|
margin-bottom: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.success { |
||||||
|
background: #d1fae5; |
||||||
|
color: #065f46; |
||||||
|
padding: 1rem; |
||||||
|
border-radius: 0.5rem; |
||||||
|
margin-bottom: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
form { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.form-group { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
label { |
||||||
|
font-weight: 500; |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
label small { |
||||||
|
font-weight: normal; |
||||||
|
color: #6b7280; |
||||||
|
font-size: 0.875rem; |
||||||
|
} |
||||||
|
|
||||||
|
input, textarea { |
||||||
|
padding: 0.75rem; |
||||||
|
border: 1px solid #d1d5db; |
||||||
|
border-radius: 0.375rem; |
||||||
|
font-size: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
input:disabled, textarea:disabled { |
||||||
|
background: #f3f4f6; |
||||||
|
cursor: not-allowed; |
||||||
|
} |
||||||
|
|
||||||
|
.input-group { |
||||||
|
display: flex; |
||||||
|
gap: 0.5rem; |
||||||
|
align-items: center; |
||||||
|
} |
||||||
|
|
||||||
|
.input-group input { |
||||||
|
flex: 1; |
||||||
|
} |
||||||
|
|
||||||
|
button { |
||||||
|
padding: 0.5rem 1rem; |
||||||
|
background: #3b82f6; |
||||||
|
color: white; |
||||||
|
border: none; |
||||||
|
border-radius: 0.375rem; |
||||||
|
cursor: pointer; |
||||||
|
font-size: 0.875rem; |
||||||
|
} |
||||||
|
|
||||||
|
button:disabled { |
||||||
|
background: #9ca3af; |
||||||
|
cursor: not-allowed; |
||||||
|
} |
||||||
|
|
||||||
|
.add-button { |
||||||
|
background: #10b981; |
||||||
|
margin-top: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.form-actions { |
||||||
|
display: flex; |
||||||
|
gap: 1rem; |
||||||
|
align-items: center; |
||||||
|
} |
||||||
|
|
||||||
|
.form-actions button[type="submit"] { |
||||||
|
padding: 0.75rem 1.5rem; |
||||||
|
font-size: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.cancel-link { |
||||||
|
color: #6b7280; |
||||||
|
text-decoration: none; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,12 @@ |
|||||||
|
import adapter from '@sveltejs/adapter-node'; |
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; |
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */ |
||||||
|
const config = { |
||||||
|
preprocess: vitePreprocess(), |
||||||
|
kit: { |
||||||
|
adapter: adapter() |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
export default config; |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
{ |
||||||
|
"extends": "./.svelte-kit/tsconfig.json", |
||||||
|
"compilerOptions": { |
||||||
|
"allowJs": true, |
||||||
|
"checkJs": true, |
||||||
|
"esModuleInterop": true, |
||||||
|
"forceConsistentCasingInFileNames": true, |
||||||
|
"resolveJsonModule": true, |
||||||
|
"skipLibCheck": true, |
||||||
|
"sourceMap": true, |
||||||
|
"strict": true, |
||||||
|
"moduleResolution": "bundler", |
||||||
|
"target": "ES2022", |
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"] |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue