Browse Source

refactoring 1

Nostr-Signature: 533e9f7acbdd4dc16dbe304245469d57d8d37f0c0cce53b60d99719e2acf4502 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 0fad2d7c44f086ceb06ce40ea8cea2d4d002ebe8caec7d78e83483348b1404cfb6256d8d3796ebd9ae6f7866a431ec4a1abe84e417d3e238b9b554b4a32481e4
main
Silberengel 2 weeks ago
parent
commit
c7ad2164c0
  1. 1
      nostr/commit-signatures.jsonl
  2. 19
      server-maintenance-commands.md
  3. 287
      src/lib/components/PublicationIndexViewer.svelte
  4. 2306
      src/lib/services/git/file-manager.ts
  5. 2398
      src/lib/services/git/file-manager.ts.backup
  6. 126
      src/lib/services/git/file-manager/branch-operations.ts
  7. 171
      src/lib/services/git/file-manager/commit-operations.ts
  8. 586
      src/lib/services/git/file-manager/file-manager-refactored.ts
  9. 295
      src/lib/services/git/file-manager/file-operations.ts
  10. 22
      src/lib/services/git/file-manager/index.ts
  11. 128
      src/lib/services/git/file-manager/path-validator.ts
  12. 187
      src/lib/services/git/file-manager/tag-operations.ts
  13. 207
      src/lib/services/git/file-manager/worktree-manager.ts
  14. 311
      src/lib/services/git/file-manager/write-operations.ts
  15. 172
      src/lib/services/logger.ts
  16. 177
      src/lib/utils/git-process.ts
  17. 1102
      src/routes/repos/[npub]/[repo]/+page.svelte
  18. 119
      src/routes/repos/[npub]/[repo]/components/CommitDialog.svelte
  19. 240
      src/routes/repos/[npub]/[repo]/components/DocsTab.svelte
  20. 242
      src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte
  21. 109
      src/routes/repos/[npub]/[repo]/components/FileBrowser.svelte
  22. 235
      src/routes/repos/[npub]/[repo]/components/FilesTab.svelte
  23. 272
      src/routes/repos/[npub]/[repo]/components/HistoryTab.svelte
  24. 158
      src/routes/repos/[npub]/[repo]/components/IssuesTab.svelte
  25. 131
      src/routes/repos/[npub]/[repo]/components/PRsTab.svelte
  26. 137
      src/routes/repos/[npub]/[repo]/components/PatchesTab.svelte
  27. 148
      src/routes/repos/[npub]/[repo]/components/StatusTabLayout.svelte
  28. 73
      src/routes/repos/[npub]/[repo]/components/TabLayout.svelte
  29. 204
      src/routes/repos/[npub]/[repo]/hooks/use-repo-api.ts
  30. 78
      src/routes/repos/[npub]/[repo]/hooks/use-repo-data.ts
  31. 180
      src/routes/repos/[npub]/[repo]/stores/repo-state.ts
  32. 121
      src/routes/repos/[npub]/[repo]/utils/repo-announcement.ts

1
nostr/commit-signatures.jsonl

@ -88,3 +88,4 @@ @@ -88,3 +88,4 @@
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772009058,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix doc page redicrects"]],"content":"Signed commit: fix doc page redicrects","id":"be18739cf8e9062e7163dca11c6768086cbf834d52f9758c884a420e4d9dceb7","sig":"2f068184caa9d921f38d6b132992614f39bab6ec6ea8040ac0d337db4c16de4e66de44c4d78162787f1cf8bf13978d01927b8152405f0b48adf608ca6bf34295"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772009909,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix cli sync and refine commit workflow"]],"content":"Signed commit: fix cli sync and refine commit workflow","id":"ddf0b49bb68139efbdacd6308b95b4a5329a37f479b319d609d712bee83e2d45","sig":"aacc22f02a3129d18cd2bdcfc4e2dda66e9358e552eac507cd4c4808bb47cd582298aed7d28f21b677418e1a91f3f1553c08f02671df8f1f43681cf7b19a744e"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772010107,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix build"]],"content":"Signed commit: fix build","id":"968af17f95f1ba0cf6a4d1f04ce108a6e4eb4ec3a4f72ca6a9d2529dacb92811","sig":"1891b6131effda70ec76577efadd9ea7374ebcbd4d738d0b0650e7dce46c3e7253eccb4b8455690297b63b7c30f61a0c7dcc1af0147b2f5a631bbd91c517c32b"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772011169,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","prevent zombie git processes"]],"content":"Signed commit: prevent zombie git processes","id":"fd370d2613105f16b0cfdd55b33f50c5b724ecef272109036a7cce5477da29bc","sig":"1d3cb4392f722b1b356247bde64691576d41fdb697e8dfe62d5e7ecd5ad8ea35757da2d56db310a2005e4b5528013aa1205256e37fc230f024d3b5a2e26735bf"}

19
server-maintenance-commands.md

@ -123,7 +123,9 @@ ps aux | grep -i plesk | grep -i defunct @@ -123,7 +123,9 @@ ps aux | grep -i plesk | grep -i defunct
- Handle signals correctly
- Prevent zombie processes
## Immediate Server Fix
## ⚠ URGENT: Restart Service IMMEDIATELY ⚠
**Zombie count is increasing rapidly (3300 → 5940). Restart NOW to stop the bleeding.**
**Option 1: Restart the GitRepublic service (RECOMMENDED)**
```bash
@ -131,13 +133,26 @@ ps aux | grep -i plesk | grep -i defunct @@ -131,13 +133,26 @@ ps aux | grep -i plesk | grep -i defunct
docker ps | grep gitrepublic
# or
systemctl list-units | grep -i gitrepublic
# or find the process
ps aux | grep "node build" | grep -v grep
# Restart it (this will clean up zombies temporarily)
# RESTART IT NOW (this will clean up zombies temporarily)
docker restart <container-id>
# or
systemctl restart <service-name>
# or if running directly
kill -TERM <pid> # Let systemd/docker restart it
```
**After restart, monitor zombie count:**
```bash
watch -n 2 'ps aux | awk '\''$8 ~ /^Z/ { count++ } END { print "Zombies:", count+0 }'\'''
```
**If zombies continue to increase after restart:**
- The code fix needs to be deployed
- Check if there are other services spawning git processes
**Option 2: Kill and let it restart (if managed by systemd/docker)**
```bash
# Find the process

287
src/lib/components/PublicationIndexViewer.svelte

@ -0,0 +1,287 @@ @@ -0,0 +1,287 @@
<script lang="ts">
/**
* Generic publication index viewer for Nostr kind 30040
* Renders publication indexes similar to NKBIP-01 and aitherboard
*/
import { onMount } from 'svelte';
import type { NostrEvent } from '$lib/types/nostr.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import logger from '$lib/services/logger.js';
export let indexEvent: NostrEvent | null = null;
export let relays: string[] = DEFAULT_NOSTR_RELAYS;
export let onItemClick: ((item: PublicationItem) => void) | null = null;
interface PublicationItem {
id: string;
title: string;
description?: string;
url?: string;
tags?: string[][];
[key: string]: any;
}
let items = $state<PublicationItem[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
$effect(() => {
if (indexEvent) {
loadIndex();
}
});
async function loadIndex() {
if (!indexEvent) return;
loading = true;
error = null;
try {
logger.operation('Loading publication index', { eventId: indexEvent.id });
// Parse index event - kind 30040 typically has items in tags or content
// Format: items can be in 'item' tags or JSON in content
const itemTags = indexEvent.tags.filter(t => t[0] === 'item' || t[0] === 'p');
if (indexEvent.content) {
try {
// Try parsing as JSON first
const parsed = JSON.parse(indexEvent.content);
if (Array.isArray(parsed)) {
items = parsed;
} else if (parsed.items && Array.isArray(parsed.items)) {
items = parsed.items;
} else {
// Fallback to tag-based parsing
items = parseItemsFromTags(itemTags);
}
} catch {
// Not JSON, try parsing from tags
items = parseItemsFromTags(itemTags);
}
} else {
items = parseItemsFromTags(itemTags);
}
// If we have item IDs, fetch full events
if (items.length > 0 && items[0].id) {
await fetchItemDetails();
}
logger.operation('Publication index loaded', { itemCount: items.length });
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load publication index';
logger.error({ error: err, eventId: indexEvent?.id }, 'Error loading publication index');
} finally {
loading = false;
}
}
function parseItemsFromTags(tags: string[][]): PublicationItem[] {
const items: PublicationItem[] = [];
for (const tag of tags) {
if (tag.length < 2) continue;
const [type, ...rest] = tag;
if (type === 'item' || type === 'p') {
// Format: ['item', 'event-id', 'relay-url', ...] or ['p', 'pubkey', 'relay', ...]
const item: PublicationItem = {
id: rest[0] || '',
title: rest[1] || rest[0] || 'Untitled',
url: rest[2] || undefined
};
// Look for title/description in subsequent tags
const titleTag = indexEvent?.tags.find(t => t[0] === 'title' && t[1] === item.id);
if (titleTag && titleTag[2]) {
item.title = titleTag[2];
}
const descTag = indexEvent?.tags.find(t => t[0] === 'description' && t[1] === item.id);
if (descTag && descTag[2]) {
item.description = descTag[2];
}
items.push(item);
}
}
return items;
}
async function fetchItemDetails() {
if (items.length === 0) return;
try {
const client = new NostrClient(relays);
const itemIds = items.map(item => item.id).filter(Boolean);
if (itemIds.length === 0) return;
// Fetch events for item IDs
const events = await client.fetchEvents([
{
ids: itemIds,
limit: itemIds.length
}
]);
// Merge event data into items
const eventMap = new Map(events.map(e => [e.id, e]));
items = items.map(item => {
const event = eventMap.get(item.id);
if (event) {
return {
...item,
title: item.title || extractTitle(event),
description: item.description || event.content?.substring(0, 200),
event
};
}
return item;
});
} catch (err) {
logger.warn({ error: err }, 'Failed to fetch item details');
}
}
function extractTitle(event: NostrEvent): string {
// Try to get title from tags
const titleTag = event.tags.find(t => t[0] === 'title');
if (titleTag && titleTag[1]) {
return titleTag[1];
}
// Try to get title from subject tag
const subjectTag = event.tags.find(t => t[0] === 'subject');
if (subjectTag && subjectTag[1]) {
return subjectTag[1];
}
// Fallback to first line of content
if (event.content) {
const firstLine = event.content.split('\n')[0].trim();
if (firstLine.length > 0 && firstLine.length < 100) {
return firstLine;
}
}
return 'Untitled';
}
</script>
<div class="publication-index">
{#if loading}
<div class="loading">Loading publication index...</div>
{:else if error}
<div class="error">{error}</div>
{:else if items.length === 0}
<div class="empty">No items found in publication index</div>
{:else}
<div class="items-list">
{#each items as item}
<div
class="item"
onclick={() => onItemClick?.(item)}
role="button"
tabindex="0"
>
<h3 class="item-title">{item.title}</h3>
{#if item.description}
<p class="item-description">{item.description}</p>
{/if}
{#if item.url}
<a href={item.url} class="item-url" onclick={(e) => e.stopPropagation()}>
{item.url}
</a>
{/if}
<div class="item-meta">
{#if item.id}
<span class="item-id">ID: {item.id.substring(0, 16)}...</span>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.publication-index {
padding: 1rem;
}
.loading, .error, .empty {
padding: 2rem;
text-align: center;
color: var(--text-secondary);
}
.error {
color: var(--accent-error);
}
.items-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.item {
padding: 1.5rem;
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
background: var(--bg-secondary);
}
.item:hover {
border-color: var(--accent-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.item-title {
margin: 0 0 0.5rem 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
}
.item-description {
margin: 0.5rem 0;
color: var(--text-secondary);
line-height: 1.5;
}
.item-url {
display: inline-block;
margin-top: 0.5rem;
color: var(--accent-color);
text-decoration: none;
font-size: 0.9rem;
word-break: break-all;
}
.item-url:hover {
text-decoration: underline;
}
.item-meta {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border-color);
font-size: 0.85rem;
color: var(--text-secondary);
}
.item-id {
font-family: monospace;
}
</style>

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

File diff suppressed because it is too large Load Diff

2398
src/lib/services/git/file-manager.ts.backup

File diff suppressed because it is too large Load Diff

126
src/lib/services/git/file-manager/branch-operations.ts

@ -0,0 +1,126 @@ @@ -0,0 +1,126 @@
/**
* Branch operations module
* Handles branch creation, deletion, and listing
*/
import simpleGit, { type SimpleGit } from 'simple-git';
import logger from '../../logger.js';
import { sanitizeError } from '../../../utils/security.js';
import { isValidBranchName } from '../../../utils/security.js';
import { validateRepoName, validateNpub } from './path-validator.js';
import { repoCache, RepoCache } from '../repo-cache.js';
export interface BranchListOptions {
npub: string;
repoName: string;
repoPath: string;
getDefaultBranch: (npub: string, repoName: string) => Promise<string>;
}
/**
* Get list of branches in a repository
*/
export async function getBranches(options: BranchListOptions): Promise<string[]> {
const { npub, repoName, repoPath, getDefaultBranch } = options;
// Validate inputs
const npubValidation = validateNpub(npub);
if (!npubValidation.valid) {
throw new Error(`Invalid npub: ${npubValidation.error}`);
}
const repoValidation = validateRepoName(repoName);
if (!repoValidation.valid) {
throw new Error(`Invalid repository name: ${repoValidation.error}`);
}
// Check cache first (cache for 2 minutes)
const cacheKey = RepoCache.branchListKey(npub, repoName);
const cached = repoCache.get<string[]>(cacheKey);
if (cached !== null) {
logger.debug({ npub, repoName, cachedCount: cached.length }, 'Returning cached branch list');
return cached;
}
const git: SimpleGit = simpleGit(repoPath);
try {
logger.operation('Listing branches', { npub, repoName });
const allBranches = new Set<string>();
// Get local branches
try {
const localBranches = await git.branchLocal();
localBranches.all
.filter(b => !b.startsWith('remotes/') && !b.includes('HEAD'))
.forEach(b => allBranches.add(b));
} catch {
// Ignore if local branches fail
}
// Get remote branches
try {
const remoteBranches = await git.branch(['-r']);
remoteBranches.all
.map(b => b.replace(/^origin\//, ''))
.filter(b => !b.includes('HEAD'))
.forEach(b => allBranches.add(b));
} catch {
// Ignore if remote branches fail
}
// If no branches found, try listing refs directly (for bare repos)
if (allBranches.size === 0) {
try {
const refs = await git.raw(['for-each-ref', '--format=%(refname:short)', 'refs/heads/']);
if (refs) {
refs.trim().split('\n').forEach(b => {
if (b && !b.includes('HEAD')) {
allBranches.add(b);
}
});
}
} catch {
// If that fails too, continue with empty set
}
}
// Sort branches: default branch first, then alphabetically
let branchList = Array.from(allBranches);
try {
const defaultBranch = await getDefaultBranch(npub, repoName);
if (defaultBranch) {
branchList.sort((a, b) => {
if (a === defaultBranch) return -1;
if (b === defaultBranch) return 1;
return a.localeCompare(b);
});
} else {
branchList.sort();
}
} catch {
branchList.sort();
}
// Cache the result (cache for 2 minutes)
repoCache.set(cacheKey, branchList, 2 * 60 * 1000);
logger.operation('Branches listed', { npub, repoName, count: branchList.length });
return branchList;
} catch (error) {
logger.error({ error, repoPath }, 'Error getting branches');
const defaultBranches = ['main', 'master'];
repoCache.set(cacheKey, defaultBranches, 30 * 1000);
return defaultBranches;
}
}
/**
* Validate branch name
*/
export function validateBranchName(branch: string): { valid: boolean; error?: string } {
if (!isValidBranchName(branch)) {
return { valid: false, error: `Invalid branch name: ${branch}` };
}
return { valid: true };
}

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

@ -0,0 +1,171 @@ @@ -0,0 +1,171 @@
/**
* Commit operations module
* Handles commit history and diff operations
*/
import simpleGit, { type SimpleGit } from 'simple-git';
import logger from '../../logger.js';
import { sanitizeError } from '../../../utils/security.js';
import { validateRepoName, validateNpub } from './path-validator.js';
import type { Commit, Diff } from '../file-manager.js';
export interface CommitHistoryOptions {
npub: string;
repoName: string;
branch?: string;
limit?: number;
path?: string;
repoPath: string;
}
export interface DiffOptions {
npub: string;
repoName: string;
fromRef: string;
toRef?: string;
filePath?: string;
repoPath: string;
}
/**
* Get commit history
*/
export async function getCommitHistory(options: CommitHistoryOptions): Promise<Commit[]> {
const { npub, repoName, branch = 'main', limit = 50, path, repoPath } = options;
// Validate inputs
const npubValidation = validateNpub(npub);
if (!npubValidation.valid) {
throw new Error(`Invalid npub: ${npubValidation.error}`);
}
const repoValidation = validateRepoName(repoName);
if (!repoValidation.valid) {
throw new Error(`Invalid repository name: ${repoValidation.error}`);
}
const git: SimpleGit = simpleGit(repoPath);
try {
logger.operation('Getting commit history', { npub, repoName, branch, limit, path });
const logOptions: {
maxCount: number;
from: string;
file?: string;
} = {
maxCount: limit,
from: branch
};
if (path) {
logOptions.file = path;
}
const log = await git.log(logOptions);
const commits = log.all.map(commit => ({
hash: commit.hash,
message: commit.message,
author: `${commit.author_name} <${commit.author_email}>`,
date: commit.date,
files: commit.diff?.files?.map((f: { file: string }) => f.file) || []
}));
logger.operation('Commit history retrieved', { npub, repoName, count: commits.length });
return commits;
} catch (error) {
logger.error({ error, repoPath, branch, limit }, 'Error getting commit history');
throw new Error(`Failed to get commit history: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Get diff between two commits or for a file
*/
export async function getDiff(options: DiffOptions): Promise<Diff[]> {
const { npub, repoName, fromRef, toRef = 'HEAD', filePath, repoPath } = options;
// Validate inputs
const npubValidation = validateNpub(npub);
if (!npubValidation.valid) {
throw new Error(`Invalid npub: ${npubValidation.error}`);
}
const repoValidation = validateRepoName(repoName);
if (!repoValidation.valid) {
throw new Error(`Invalid repository name: ${repoValidation.error}`);
}
const git: SimpleGit = simpleGit(repoPath);
try {
logger.operation('Getting diff', { npub, repoName, fromRef, toRef, filePath });
const diffOptions: string[] = [fromRef, toRef];
if (filePath) {
diffOptions.push('--', filePath);
}
const [diff, stats] = await Promise.all([
git.diff(diffOptions),
git.diffSummary(diffOptions)
]);
// Parse diff output
const files: Diff[] = [];
const diffLines = diff.split('\n');
let currentFile = '';
let currentDiff = '';
let inFileHeader = false;
for (const line of diffLines) {
if (line.startsWith('diff --git')) {
if (currentFile) {
files.push({
file: currentFile,
additions: 0,
deletions: 0,
diff: currentDiff
});
}
const match = line.match(/diff --git a\/(.+?) b\/(.+?)$/);
if (match) {
currentFile = match[2];
currentDiff = line + '\n';
inFileHeader = true;
}
} else {
currentDiff += line + '\n';
if (line.startsWith('@@')) {
inFileHeader = false;
}
}
}
if (currentFile) {
files.push({
file: currentFile,
additions: 0,
deletions: 0,
diff: currentDiff
});
}
// Add stats from diffSummary
if (stats.files && files.length > 0) {
for (const statFile of stats.files) {
const file = files.find(f => f.file === statFile.file);
if (file && 'insertions' in statFile && 'deletions' in statFile) {
file.additions = statFile.insertions;
file.deletions = statFile.deletions;
}
}
}
logger.operation('Diff retrieved', { npub, repoName, fileCount: files.length });
return files;
} catch (error) {
const sanitizedError = sanitizeError(error);
logger.error({ error: sanitizedError, repoPath, fromRef, toRef }, 'Error getting diff');
throw new Error(`Failed to get diff: ${sanitizedError}`);
}
}

586
src/lib/services/git/file-manager/file-manager-refactored.ts

@ -0,0 +1,586 @@ @@ -0,0 +1,586 @@
/**
* File Manager - Refactored to use modular components
* Main class that delegates to focused modules
*/
import { join, resolve } from 'path';
import simpleGit, { type SimpleGit } from 'simple-git';
import { RepoManager } from '../repo-manager.js';
import logger from '../../logger.js';
import { sanitizeError, isValidBranchName } from '../../../utils/security.js';
import { repoCache, RepoCache } from '../repo-cache.js';
// Import modular operations
import { getOrCreateWorktree, removeWorktree } from './worktree-manager.js';
import { validateFilePath, validateRepoName, validateNpub } from './path-validator.js';
import { listFiles, getFileContent } from './file-operations.js';
import { getBranches, validateBranchName } from './branch-operations.js';
import { writeFile, deleteFile } from './write-operations.js';
import { getCommitHistory, getDiff } from './commit-operations.js';
import { createTag, getTags } from './tag-operations.js';
// Types are defined below
export interface FileEntry {
name: string;
path: string;
type: 'file' | 'directory';
size?: number;
}
export interface FileContent {
content: string;
encoding: string;
size: number;
}
export interface Commit {
hash: string;
message: string;
author: string;
date: string;
files: string[];
}
export interface Diff {
file: string;
additions: number;
deletions: number;
diff: string;
}
export interface Tag {
name: string;
hash: string;
message?: string;
date?: number;
}
export class FileManager {
private repoManager: RepoManager;
private repoRoot: string;
private dirExistenceCache: Map<string, { exists: boolean; timestamp: number }> = new Map();
private readonly DIR_CACHE_TTL = 5 * 60 * 1000;
private fsPromises: typeof import('fs/promises') | null = null;
constructor(repoRoot: string = '/repos') {
this.repoRoot = repoRoot;
this.repoManager = new RepoManager(repoRoot);
}
private async getFsPromises(): Promise<typeof import('fs/promises')> {
if (!this.fsPromises) {
this.fsPromises = await import('fs/promises');
}
return this.fsPromises;
}
private async pathExists(path: string): Promise<boolean> {
try {
const fs = await this.getFsPromises();
await fs.access(path);
return true;
} catch {
return false;
}
}
private sanitizePathForError(path: string): string {
const resolvedPath = resolve(path).replace(/\\/g, '/');
const resolvedRoot = resolve(this.repoRoot).replace(/\\/g, '/');
if (resolvedPath.startsWith(resolvedRoot + '/')) {
return resolvedPath.slice(resolvedRoot.length + 1);
}
return path.split(/[/\\]/).pop() || path;
}
private async ensureDirectoryExists(dirPath: string, description: string): Promise<void> {
const exists = await this.pathExists(dirPath);
if (exists) return;
try {
const { mkdir } = await this.getFsPromises();
await mkdir(dirPath, { recursive: true });
logger.debug({ dirPath: this.sanitizePathForError(dirPath) }, `Created ${description}`);
} catch (err) {
logger.error({ error: err, dirPath: this.sanitizePathForError(dirPath) }, `Failed to create ${description}`);
throw new Error(`Failed to create ${description}: ${err instanceof Error ? err.message : String(err)}`);
}
}
getRepoPath(npub: string, repoName: string): string {
const repoPath = join(this.repoRoot, npub, `${repoName}.git`);
const resolvedPath = resolve(repoPath).replace(/\\/g, '/');
const resolvedRoot = resolve(this.repoRoot).replace(/\\/g, '/');
if (!resolvedPath.startsWith(resolvedRoot + '/')) {
throw new Error('Path traversal detected: repository path outside allowed root');
}
return repoPath;
}
repoExists(npub: string, repoName: string): boolean {
const npubValidation = validateNpub(npub);
if (!npubValidation.valid) return false;
const repoValidation = validateRepoName(repoName);
if (!repoValidation.valid) return false;
const cacheKey = RepoCache.repoExistsKey(npub, repoName);
const cached = repoCache.get<boolean>(cacheKey);
if (cached !== null) return cached;
const repoPath = this.getRepoPath(npub, repoName);
const exists = this.repoManager.repoExists(repoPath);
repoCache.set(cacheKey, exists, 60 * 1000);
return exists;
}
async getWorktree(repoPath: string, branch: string, npub: string, repoName: string): Promise<string> {
if (!isValidBranchName(branch)) {
throw new Error(`Invalid branch name: ${branch}`);
}
return getOrCreateWorktree({
repoPath,
branch,
npub,
repoName,
repoRoot: this.repoRoot
});
}
async removeWorktree(repoPath: string, worktreePath: string): Promise<void> {
return removeWorktree(repoPath, worktreePath);
}
async listFiles(npub: string, repoName: string, ref: string = 'HEAD', path: string = ''): Promise<FileEntry[]> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
return listFiles({ npub, repoName, ref, path, repoPath });
}
async getFileContent(npub: string, repoName: string, filePath: string, ref: string = 'HEAD'): Promise<FileContent> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
return getFileContent({ npub, repoName, filePath, ref, repoPath });
}
async writeFile(
npub: string,
repoName: string,
filePath: string,
content: string,
commitMessage: string,
authorName: string,
authorEmail: string,
branch: string = 'main',
signingOptions?: {
commitSignatureEvent?: any;
useNIP07?: boolean;
nip98Event?: any;
nsecKey?: string;
}
): Promise<void> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
// Check repo size
const repoSizeCheck = await this.repoManager.checkRepoSizeLimit(repoPath);
if (!repoSizeCheck.withinLimit) {
throw new Error(repoSizeCheck.error || 'Repository size limit exceeded');
}
const worktreePath = await this.getWorktree(repoPath, branch, npub, repoName);
// Save commit signature helper
const saveCommitSignature = async (worktreePath: string, event: any) => {
await this.saveCommitSignatureEventToWorktree(worktreePath, event);
};
// Check if repo is private
const isRepoPrivate = async (npub: string, repoName: string) => {
return this.isRepoPrivate(npub, repoName);
};
await writeFile({
npub,
repoName,
filePath,
content,
commitMessage,
authorName,
authorEmail,
branch,
repoPath,
worktreePath,
signingOptions,
saveCommitSignature,
isRepoPrivate
});
await this.removeWorktree(repoPath, worktreePath);
}
async deleteFile(
npub: string,
repoName: string,
filePath: string,
commitMessage: string,
authorName: string,
authorEmail: string,
branch: string = 'main',
signingOptions?: {
commitSignatureEvent?: any;
useNIP07?: boolean;
nip98Event?: any;
nsecKey?: string;
}
): Promise<void> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
const worktreePath = await this.getWorktree(repoPath, branch, npub, repoName);
const saveCommitSignature = async (worktreePath: string, event: any) => {
await this.saveCommitSignatureEventToWorktree(worktreePath, event);
};
await deleteFile({
npub,
repoName,
filePath,
commitMessage,
authorName,
authorEmail,
branch,
repoPath,
worktreePath,
signingOptions,
saveCommitSignature
});
await this.removeWorktree(repoPath, worktreePath);
}
async createFile(
npub: string,
repoName: string,
filePath: string,
content: string,
commitMessage: string,
authorName: string,
authorEmail: string,
branch: string = 'main',
signingOptions?: {
useNIP07?: boolean;
nip98Event?: any;
nsecKey?: string;
}
): Promise<void> {
return this.writeFile(npub, repoName, filePath, content, commitMessage, authorName, authorEmail, branch, signingOptions);
}
async getDefaultBranch(npub: string, repoName: string): Promise<string> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
const git: SimpleGit = simpleGit(repoPath);
try {
const defaultRef = await git.raw(['symbolic-ref', 'HEAD']);
if (defaultRef) {
const match = defaultRef.trim().match(/^refs\/heads\/(.+)$/);
if (match) return match[1];
}
} catch {
try {
const remoteHead = await git.raw(['symbolic-ref', 'refs/remotes/origin/HEAD']);
if (remoteHead) {
const match = remoteHead.trim().match(/^refs\/remotes\/origin\/(.+)$/);
if (match) return match[1];
}
} catch {
// Fall through
}
}
try {
const branches = await git.branch(['-r']);
const branchList = branches.all
.map(b => b.replace(/^origin\//, ''))
.filter(b => !b.includes('HEAD'));
if (branchList.length === 0) return 'main';
if (branchList.includes('main')) return 'main';
if (branchList.includes('master')) return 'master';
return branchList[0];
} catch {
return 'main';
}
}
async getBranches(npub: string, repoName: string): Promise<string[]> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
return getBranches({
npub,
repoName,
repoPath,
getDefaultBranch: (npub, repoName) => this.getDefaultBranch(npub, repoName)
});
}
async createBranch(
npub: string,
repoName: string,
branchName: string,
fromBranch: string = 'main'
): Promise<void> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
if (!isValidBranchName(branchName)) {
throw new Error(`Invalid branch name: ${branchName}`);
}
const git: SimpleGit = simpleGit(repoPath);
try {
await git.raw(['branch', branchName, fromBranch]);
const cacheKey = RepoCache.branchesKey(npub, repoName);
repoCache.delete(cacheKey);
} catch (error) {
logger.error({ error, repoPath, branchName, fromBranch }, 'Error creating branch');
throw new Error(`Failed to create branch: ${error instanceof Error ? error.message : String(error)}`);
}
}
async deleteBranch(npub: string, repoName: string, branchName: string): Promise<void> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
if (!isValidBranchName(branchName)) {
throw new Error(`Invalid branch name: ${branchName}`);
}
const git: SimpleGit = simpleGit(repoPath);
try {
await git.raw(['branch', '-D', branchName]).catch(async () => {
await git.raw(['update-ref', '-d', `refs/heads/${branchName}`]);
});
const cacheKey = RepoCache.branchesKey(npub, repoName);
repoCache.delete(cacheKey);
} catch (error) {
logger.error({ error, repoPath, branchName }, 'Error deleting branch');
throw new Error(`Failed to delete branch: ${error instanceof Error ? error.message : String(error)}`);
}
}
async getCommitHistory(
npub: string,
repoName: string,
branch: string = 'main',
limit: number = 50,
path?: string
): Promise<Commit[]> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
return getCommitHistory({ npub, repoName, branch, limit, path, repoPath });
}
async getDiff(
npub: string,
repoName: string,
fromRef: string,
toRef: string = 'HEAD',
filePath?: string
): Promise<Diff[]> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
return getDiff({ npub, repoName, fromRef, toRef, filePath, repoPath });
}
async createTag(
npub: string,
repoName: string,
tagName: string,
ref: string = 'HEAD',
message?: string,
authorName?: string,
authorEmail?: string
): Promise<void> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
return createTag({ npub, repoName, tagName, ref, message, authorName, authorEmail, repoPath });
}
async getTags(npub: string, repoName: string): Promise<Tag[]> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
return getTags({ npub, repoName, repoPath });
}
private async saveCommitSignatureEventToWorktree(worktreePath: string, event: any): Promise<void> {
try {
const { mkdir, writeFile } = await this.getFsPromises();
const nostrDir = join(worktreePath, 'nostr');
await mkdir(nostrDir, { recursive: true });
const jsonlFile = join(nostrDir, 'commit-signatures.jsonl');
const eventLine = JSON.stringify(event) + '\n';
await writeFile(jsonlFile, eventLine, { flag: 'a', encoding: 'utf-8' });
} catch (err) {
logger.debug({ error: err, worktreePath }, 'Failed to save commit signature event');
}
}
async saveRepoEventToWorktree(
worktreePath: string,
event: any,
eventType: 'announcement' | 'transfer',
skipIfExists: boolean = true
): Promise<boolean> {
try {
const { mkdir, writeFile, readFile } = await this.getFsPromises();
const nostrDir = join(worktreePath, 'nostr');
await mkdir(nostrDir, { recursive: true });
const jsonlFile = join(nostrDir, 'repo-events.jsonl');
if (skipIfExists) {
try {
const existingContent = await readFile(jsonlFile, 'utf-8');
const lines = existingContent.trim().split('\n').filter(l => l.trim());
for (const line of lines) {
try {
const parsed = JSON.parse(line);
if (parsed.event && parsed.event.id === event.id) {
return false;
}
} catch {
// Skip invalid lines
}
}
} catch {
// File doesn't exist yet
}
}
const eventLine = JSON.stringify({
type: eventType,
timestamp: event.created_at,
event
}) + '\n';
await writeFile(jsonlFile, eventLine, { flag: 'a', encoding: 'utf-8' });
return true;
} catch (err) {
logger.debug({ error: err, worktreePath, eventType }, 'Failed to save repo event');
return false;
}
}
private async isRepoPrivate(npub: string, repoName: string): Promise<boolean> {
try {
const { requireNpubHex } = await import('../../../utils/npub-utils.js');
const repoOwnerPubkey = requireNpubHex(npub);
const { NostrClient } = await import('../../nostr/nostr-client.js');
const { DEFAULT_NOSTR_RELAYS } = await import('../../../config.js');
const { KIND } = await import('../../../types/nostr.js');
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const events = await nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [repoOwnerPubkey],
'#d': [repoName],
limit: 1
}
]);
if (events.length === 0) return false;
const { isPrivateRepo: checkIsPrivateRepo } = await import('../../../utils/repo-privacy.js');
return checkIsPrivateRepo(events[0]);
} catch (err) {
logger.debug({ error: err, npub, repoName }, 'Failed to check repo privacy, defaulting to public');
return false;
}
}
async getCurrentOwnerFromRepo(npub: string, repoName: string): Promise<string | null> {
try {
if (!this.repoExists(npub, repoName)) return null;
const repoPath = this.getRepoPath(npub, repoName);
const git: SimpleGit = simpleGit(repoPath);
const logOutput = await git.raw(['log', '--all', '--format=%H', '--reverse', '--', 'nostr/repo-events.jsonl']);
const commitHashes = logOutput.trim().split('\n').filter(Boolean);
if (commitHashes.length === 0) return null;
const mostRecentCommit = commitHashes[commitHashes.length - 1];
const repoEventsFile = await this.getFileContent(npub, repoName, 'nostr/repo-events.jsonl', mostRecentCommit);
let announcementEvent: any = null;
let latestTimestamp = 0;
try {
const lines = repoEventsFile.content.trim().split('\n').filter(Boolean);
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (entry.type === 'announcement' && entry.event && entry.timestamp) {
if (entry.timestamp > latestTimestamp) {
latestTimestamp = entry.timestamp;
announcementEvent = entry.event;
}
}
} catch {
continue;
}
}
} catch (parseError) {
logger.warn({ error: parseError, npub, repoName }, 'Failed to parse repo-events.jsonl');
return null;
}
if (!announcementEvent) return null;
const { validateAnnouncementEvent } = await import('../../nostr/repo-verification.js');
const validation = validateAnnouncementEvent(announcementEvent, repoName);
if (!validation.valid) {
logger.warn({ error: validation.error, npub, repoName }, 'Announcement validation failed');
return null;
}
return announcementEvent.pubkey;
} catch (error) {
logger.error({ error, npub, repoName }, 'Error getting current owner from repo');
return null;
}
}
}

295
src/lib/services/git/file-manager/file-operations.ts

@ -0,0 +1,295 @@ @@ -0,0 +1,295 @@
/**
* File operations module
* Handles reading, writing, and listing files in git repositories
*/
import simpleGit, { type SimpleGit } from 'simple-git';
import { join } from 'path';
import logger from '../../logger.js';
import { sanitizeError } from '../../../utils/security.js';
import { validateFilePath, validateRepoName, validateNpub } from './path-validator.js';
import type { FileEntry, FileContent } from '../file-manager.js';
import { repoCache, RepoCache } from '../repo-cache.js';
export interface FileListOptions {
npub: string;
repoName: string;
ref?: string;
path?: string;
repoPath: string;
}
export interface FileReadOptions {
npub: string;
repoName: string;
filePath: string;
ref?: string;
repoPath: string;
}
/**
* List files and directories in a repository at a given path
*/
export async function listFiles(options: FileListOptions): Promise<FileEntry[]> {
const { npub, repoName, ref = 'HEAD', path = '', repoPath } = options;
// Validate inputs
const npubValidation = validateNpub(npub);
if (!npubValidation.valid) {
throw new Error(`Invalid npub: ${npubValidation.error}`);
}
const repoValidation = validateRepoName(repoName);
if (!repoValidation.valid) {
throw new Error(`Invalid repository name: ${repoValidation.error}`);
}
const pathValidation = validateFilePath(path);
if (!pathValidation.valid) {
throw new Error(`Invalid file path: ${pathValidation.error}`);
}
// Check cache first (cache for 2 minutes)
const cacheKey = RepoCache.fileListKey(npub, repoName, ref, path);
const cached = repoCache.get<FileEntry[]>(cacheKey);
if (cached !== null) {
logger.debug({ npub, repoName, path, ref, cachedCount: cached.length }, 'Returning cached file list');
return cached;
}
const git: SimpleGit = simpleGit(repoPath);
try {
const gitPath = path ? (path.endsWith('/') ? path : `${path}/`) : '.';
logger.operation('Listing files', { npub, repoName, path, ref, gitPath });
let tree: string;
try {
tree = await git.raw(['ls-tree', '-l', ref, gitPath]);
} catch (lsTreeError) {
const errorMsg = lsTreeError instanceof Error ? lsTreeError.message : String(lsTreeError);
const errorStr = String(lsTreeError).toLowerCase();
const errorMsgLower = errorMsg.toLowerCase();
const isEmptyBranchError =
errorMsgLower.includes('not a valid object') ||
errorMsgLower.includes('not found') ||
errorMsgLower.includes('bad revision') ||
errorMsgLower.includes('ambiguous argument') ||
errorStr.includes('not a valid object') ||
errorStr.includes('not found') ||
errorStr.includes('bad revision') ||
errorStr.includes('ambiguous argument') ||
(errorMsgLower.includes('fatal:') && (errorMsgLower.includes('master') || errorMsgLower.includes('refs/heads')));
if (isEmptyBranchError) {
logger.debug({ npub, repoName, path, ref }, 'Branch has no commits, returning empty list');
const emptyResult: FileEntry[] = [];
repoCache.set(cacheKey, emptyResult, 30 * 1000);
return emptyResult;
}
logger.error({ error: lsTreeError, npub, repoName, path, ref }, 'Unexpected error from git ls-tree');
throw lsTreeError;
}
if (!tree || !tree.trim()) {
const emptyResult: FileEntry[] = [];
repoCache.set(cacheKey, emptyResult, 30 * 1000);
return emptyResult;
}
const entries: FileEntry[] = [];
const lines = tree.trim().split('\n').filter(line => line.length > 0);
const normalizedPath = path ? (path.endsWith('/') ? path : `${path}/`) : '';
for (const line of lines) {
const tabIndex = line.lastIndexOf('\t');
if (tabIndex === -1) {
// Space-separated format
const match = line.match(/^(\d+)\s+(\w+)\s+(\w+)\s+(\d+|-)\s+(.+)$/);
if (match) {
const [, , type, , size, gitPath] = match;
const { fullPath, displayName } = parseGitPath(gitPath, normalizedPath, path);
entries.push({
name: displayName,
path: fullPath,
type: type === 'tree' ? 'directory' : 'file',
size: size !== '-' ? parseInt(size, 10) : undefined
});
}
} else {
// Tab-separated format (standard)
const beforeTab = line.substring(0, tabIndex);
const gitPath = line.substring(tabIndex + 1);
const match = beforeTab.match(/^(\d+)\s+(\w+)\s+(\w+)\s+(\d+|-)$/);
if (match) {
const [, , type, , size] = match;
const { fullPath, displayName } = parseGitPath(gitPath, normalizedPath, path);
entries.push({
name: displayName,
path: fullPath,
type: type === 'tree' ? 'directory' : 'file',
size: size !== '-' ? parseInt(size, 10) : undefined
});
}
}
}
const sortedEntries = entries.sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
// Cache the result (cache for 2 minutes)
repoCache.set(cacheKey, sortedEntries, 2 * 60 * 1000);
logger.operation('Files listed', { npub, repoName, path, count: sortedEntries.length });
return sortedEntries;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
const errorStr = String(error).toLowerCase();
const errorMsgLower = errorMsg.toLowerCase();
const isEmptyBranchError =
errorMsgLower.includes('not a valid object') ||
errorMsgLower.includes('not found') ||
errorMsgLower.includes('bad revision') ||
errorMsgLower.includes('ambiguous argument') ||
errorStr.includes('not a valid object') ||
errorStr.includes('not found') ||
errorStr.includes('bad revision') ||
errorStr.includes('ambiguous argument') ||
(errorMsgLower.includes('fatal:') && (errorMsgLower.includes('master') || errorMsgLower.includes('refs/heads')));
if (isEmptyBranchError) {
logger.debug({ npub, repoName, path, ref }, 'Branch has no commits, returning empty list');
const emptyResult: FileEntry[] = [];
repoCache.set(cacheKey, emptyResult, 30 * 1000);
return emptyResult;
}
logger.error({ error, repoPath, ref }, 'Error listing files');
throw new Error(`Failed to list files: ${errorMsg}`);
}
}
/**
* Parse git path and extract full path and display name
*/
function parseGitPath(
gitPath: string,
normalizedPath: string,
originalPath: string
): { fullPath: string; displayName: string } {
let fullPath: string;
let displayName: string;
if (normalizedPath) {
if (gitPath.startsWith(normalizedPath)) {
fullPath = gitPath;
const relativePath = gitPath.slice(normalizedPath.length);
const cleanRelative = relativePath.replace(/^\/+|\/+$/g, '');
displayName = cleanRelative.split('/')[0] || cleanRelative;
} else {
fullPath = join(originalPath, gitPath);
displayName = gitPath.split('/').pop() || gitPath;
}
} else {
fullPath = gitPath;
displayName = gitPath.split('/')[0];
}
return { fullPath, displayName };
}
/**
* Get file content from a repository
*/
export async function getFileContent(options: FileReadOptions): Promise<FileContent> {
const { npub, repoName, filePath, ref = 'HEAD', repoPath } = options;
// Validate inputs
const npubValidation = validateNpub(npub);
if (!npubValidation.valid) {
throw new Error(`Invalid npub: ${npubValidation.error}`);
}
const repoValidation = validateRepoName(repoName);
if (!repoValidation.valid) {
throw new Error(`Invalid repository name: ${repoValidation.error}`);
}
const pathValidation = validateFilePath(filePath);
if (!pathValidation.valid) {
throw new Error(`Invalid file path: ${pathValidation.error}`);
}
const git: SimpleGit = simpleGit(repoPath);
try {
logger.operation('Reading file', { npub, repoName, filePath, ref });
let content: string;
try {
content = await git.raw(['show', `${ref}:${filePath}`]);
} catch (gitError: any) {
const stderr = gitError?.stderr || gitError?.message || String(gitError);
const stderrLower = stderr.toLowerCase();
if (stderrLower.includes('not found') ||
stderrLower.includes('no such file') ||
stderrLower.includes('does not exist') ||
stderrLower.includes('fatal:') ||
stderr.includes('pathspec') ||
stderr.includes('ambiguous argument') ||
stderr.includes('unknown revision') ||
stderr.includes('bad revision')) {
throw new Error(`File not found: ${filePath} at ref ${ref}`);
}
throw new Error(`Git command failed: ${stderr}`);
}
if (content === undefined || content === null) {
throw new Error(`File not found: ${filePath} at ref ${ref}`);
}
const encoding = 'utf-8';
const size = Buffer.byteLength(content, encoding);
logger.operation('File read', { npub, repoName, filePath, size });
return {
content,
encoding,
size
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorLower = errorMessage.toLowerCase();
const errorString = String(error);
const errorStringLower = errorString.toLowerCase();
if (errorLower.includes('not found') ||
errorStringLower.includes('not found') ||
errorLower.includes('no such file') ||
errorStringLower.includes('no such file') ||
errorLower.includes('does not exist') ||
errorStringLower.includes('does not exist') ||
errorLower.includes('fatal:') ||
errorStringLower.includes('fatal:') ||
errorMessage.includes('pathspec') ||
errorString.includes('pathspec') ||
errorMessage.includes('ambiguous argument') ||
errorString.includes('ambiguous argument') ||
errorString.includes('unknown revision') ||
errorString.includes('bad revision')) {
throw new Error(`File not found: ${filePath} at ref ${ref}`);
}
logger.error({ error, repoPath, filePath, ref }, 'Error reading file');
throw new Error(`Failed to read file: ${errorMessage}`);
}
}

22
src/lib/services/git/file-manager/index.ts

@ -0,0 +1,22 @@ @@ -0,0 +1,22 @@
/**
* File Manager - Modular exports
* Re-exports all file manager functionality from focused modules
*/
// Re-export types
export type {
FileEntry,
FileContent,
Commit,
Diff,
Tag
} from '../file-manager.js';
// Re-export modules
export * from './worktree-manager.js';
export * from './path-validator.js';
export * from './file-operations.js';
export * from './branch-operations.js';
export * from './write-operations.js';
export * from './commit-operations.js';
export * from './tag-operations.js';

128
src/lib/services/git/file-manager/path-validator.ts

@ -0,0 +1,128 @@ @@ -0,0 +1,128 @@
/**
* Path validation utilities
* Security-focused path validation to prevent path traversal attacks
*/
import { normalize, resolve } from 'path';
import logger from '../../logger.js';
export interface ValidationResult {
valid: boolean;
error?: string;
normalized?: string;
}
/**
* Validate and sanitize file path to prevent path traversal attacks
*/
export function validateFilePath(filePath: string): ValidationResult {
// Allow empty string for root directory
if (filePath === '') {
return { valid: true, normalized: '' };
}
if (!filePath || typeof filePath !== 'string') {
return { valid: false, error: 'File path must be a non-empty string' };
}
// Normalize the path (resolves .. and .)
const normalized = normalize(filePath);
// Check for path traversal attempts
if (normalized.includes('..')) {
logger.security('Path traversal attempt detected', { filePath, normalized });
return { valid: false, error: 'Path traversal detected (..)' };
}
// Check for absolute paths
if (normalized.startsWith('/')) {
return { valid: false, error: 'Absolute paths are not allowed' };
}
// Check for null bytes
if (normalized.includes('\0')) {
logger.security('Null byte detected in path', { filePath });
return { valid: false, error: 'Null bytes are not allowed in paths' };
}
// Check for control characters
if (/[\x00-\x1f\x7f]/.test(normalized)) {
logger.security('Control character detected in path', { filePath });
return { valid: false, error: 'Control characters are not allowed in paths' };
}
// Limit path length (reasonable limit)
if (normalized.length > 4096) {
return { valid: false, error: 'Path is too long (max 4096 characters)' };
}
return { valid: true, normalized };
}
/**
* Validate repository name to prevent injection attacks
*/
export function validateRepoName(repoName: string): ValidationResult {
if (!repoName || typeof repoName !== 'string') {
return { valid: false, error: 'Repository name must be a non-empty string' };
}
// Check length
if (repoName.length > 100) {
return { valid: false, error: 'Repository name is too long (max 100 characters)' };
}
// Check for invalid characters (alphanumeric, hyphens, underscores, dots)
if (!/^[a-zA-Z0-9._-]+$/.test(repoName)) {
logger.security('Invalid characters in repo name', { repoName });
return { valid: false, error: 'Repository name contains invalid characters' };
}
// Check for path traversal
if (repoName.includes('..') || repoName.includes('/') || repoName.includes('\\')) {
logger.security('Path traversal attempt in repo name', { repoName });
return { valid: false, error: 'Repository name contains invalid path characters' };
}
return { valid: true, normalized: repoName };
}
/**
* Validate npub format
*/
export function validateNpub(npub: string): ValidationResult {
if (!npub || typeof npub !== 'string') {
return { valid: false, error: 'npub must be a non-empty string' };
}
// Basic npub format check (starts with npub, base58 encoded)
if (!npub.startsWith('npub1') || npub.length < 10 || npub.length > 100) {
return { valid: false, error: 'Invalid npub format' };
}
return { valid: true, normalized: npub };
}
/**
* Validate repository path is within allowed root
*/
export function validateRepoPath(
repoPath: string,
repoRoot: string
): ValidationResult {
try {
const resolvedPath = resolve(repoPath).replace(/\\/g, '/');
const resolvedRoot = resolve(repoRoot).replace(/\\/g, '/');
// Must be a subdirectory of repoRoot, not equal to it
if (!resolvedPath.startsWith(resolvedRoot + '/')) {
logger.security('Repository path outside allowed root', { repoPath, repoRoot });
return { valid: false, error: 'Path traversal detected: repository path outside allowed root' };
}
return { valid: true, normalized: resolvedPath };
} catch (error) {
logger.error({ error, repoPath, repoRoot }, 'Failed to validate repo path');
return { valid: false, error: 'Failed to validate repository path' };
}
}

187
src/lib/services/git/file-manager/tag-operations.ts

@ -0,0 +1,187 @@ @@ -0,0 +1,187 @@
/**
* Tag operations module
* Handles tag creation and listing
*/
import simpleGit, { type SimpleGit } from 'simple-git';
import logger from '../../logger.js';
import { validateRepoName, validateNpub } from './path-validator.js';
import type { Tag } from '../file-manager.js';
export interface CreateTagOptions {
npub: string;
repoName: string;
tagName: string;
ref?: string;
message?: string;
authorName?: string;
authorEmail?: string;
repoPath: string;
}
export interface GetTagsOptions {
npub: string;
repoName: string;
repoPath: string;
}
/**
* Create a tag
*/
export async function createTag(options: CreateTagOptions): Promise<void> {
const { npub, repoName, tagName, ref = 'HEAD', message, repoPath } = options;
// Validate inputs
const npubValidation = validateNpub(npub);
if (!npubValidation.valid) {
throw new Error(`Invalid npub: ${npubValidation.error}`);
}
const repoValidation = validateRepoName(repoName);
if (!repoValidation.valid) {
throw new Error(`Invalid repository name: ${repoValidation.error}`);
}
const git: SimpleGit = simpleGit(repoPath);
try {
logger.operation('Creating tag', { npub, repoName, tagName, ref });
// Check if repository has any commits
let hasCommits = false;
let actualRef = ref;
try {
const headCommit = await git.raw(['rev-parse', 'HEAD']).catch(() => null);
hasCommits = !!(headCommit && headCommit.trim().length > 0);
} catch {
// Check if any branch has commits
try {
const branches = await git.branch(['-a']);
for (const branch of branches.all) {
const branchName = branch.replace(/^remotes\/origin\//, '').replace(/^remotes\//, '');
if (branchName.includes('HEAD')) continue;
try {
const commitHash = await git.raw(['rev-parse', `refs/heads/${branchName}`]).catch(() => null);
if (commitHash && commitHash.trim().length > 0) {
hasCommits = true;
if (ref === 'HEAD') {
actualRef = branchName;
}
break;
}
} catch {
// Continue checking other branches
}
}
} catch {
// Could not check branches
}
}
if (!hasCommits) {
throw new Error('Cannot create tag: repository has no commits. Please create at least one commit first.');
}
// Validate that the ref exists
try {
await git.raw(['rev-parse', '--verify', actualRef]);
} catch (refErr) {
throw new Error(`Invalid reference '${actualRef}': ${refErr instanceof Error ? refErr.message : String(refErr)}`);
}
if (message) {
// Create annotated tag
if (actualRef !== 'HEAD') {
await git.raw(['tag', '-a', tagName, '-m', message, actualRef]);
} else {
await git.raw(['tag', '-a', tagName, '-m', message]);
}
} else {
// Create lightweight tag
if (actualRef !== 'HEAD') {
await git.raw(['tag', tagName, actualRef]);
} else {
await git.addTag(tagName);
}
}
logger.operation('Tag created', { npub, repoName, tagName });
} catch (error) {
logger.error({ error, repoPath, tagName, ref }, 'Error creating tag');
throw new Error(`Failed to create tag: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Get list of tags
*/
export async function getTags(options: GetTagsOptions): Promise<Tag[]> {
const { npub, repoName, repoPath } = options;
// Validate inputs
const npubValidation = validateNpub(npub);
if (!npubValidation.valid) {
throw new Error(`Invalid npub: ${npubValidation.error}`);
}
const repoValidation = validateRepoName(repoName);
if (!repoValidation.valid) {
throw new Error(`Invalid repository name: ${repoValidation.error}`);
}
const git: SimpleGit = simpleGit(repoPath);
try {
logger.operation('Getting tags', { npub, repoName });
const tags = await git.tags();
const tagList: Tag[] = [];
for (const tagName of tags.all) {
try {
// Get the commit hash the tag points to
const hash = await git.raw(['rev-parse', tagName]);
const commitHash = hash.trim();
// Get the commit date (Unix timestamp)
let commitDate: number | undefined;
try {
const dateStr = await git.raw(['log', '-1', '--format=%at', commitHash]);
commitDate = parseInt(dateStr.trim(), 10);
if (isNaN(commitDate)) {
commitDate = undefined;
}
} catch {
commitDate = undefined;
}
// Try to get tag message (for annotated tags)
try {
const tagInfo = await git.raw(['cat-file', '-p', tagName]);
const messageMatch = tagInfo.match(/^(.+)$/m);
tagList.push({
name: tagName,
hash: commitHash,
message: messageMatch ? messageMatch[1] : undefined,
date: commitDate
});
} catch {
// Lightweight tag - no message
tagList.push({
name: tagName,
hash: commitHash,
date: commitDate
});
}
} catch (err) {
logger.warn({ error: err, tagName }, 'Error processing tag, skipping');
}
}
logger.operation('Tags retrieved', { npub, repoName, count: tagList.length });
return tagList;
} catch (error) {
logger.error({ error, repoPath }, 'Error getting tags');
return [];
}
}

207
src/lib/services/git/file-manager/worktree-manager.ts

@ -0,0 +1,207 @@ @@ -0,0 +1,207 @@
/**
* Worktree management module
* Handles git worktree operations with proper cleanup
*/
import { join, resolve, dirname } from 'path';
import { spawn } from 'child_process';
import simpleGit, { type SimpleGit } from 'simple-git';
import logger from '../../logger.js';
import { sanitizeError } from '../../../utils/security.js';
import { execGitProcess } from '../../../utils/git-process.js';
export interface WorktreeOptions {
repoPath: string;
branch: string;
npub: string;
repoName: string;
repoRoot: string;
}
/**
* Get or create a worktree for a branch
*/
export async function getOrCreateWorktree(
options: WorktreeOptions
): Promise<string> {
const { repoPath, branch, npub, repoName, repoRoot } = options;
const worktreeRoot = join(repoRoot, npub, `${repoName}.worktrees`);
const worktreePath = resolve(join(worktreeRoot, branch));
const resolvedWorktreeRoot = resolve(worktreeRoot);
// Security: Ensure resolved path is still within worktreeRoot
const resolvedPath = worktreePath.replace(/\\/g, '/');
const resolvedRoot = resolvedWorktreeRoot.replace(/\\/g, '/');
if (!resolvedPath.startsWith(resolvedRoot + '/')) {
throw new Error('Path traversal detected: worktree path outside allowed root');
}
const { mkdir, rm } = await import('fs/promises');
// Ensure worktree root exists
await mkdir(resolvedWorktreeRoot, { recursive: true });
const git = simpleGit(repoPath);
// Check if worktree already exists
try {
const worktrees = await git.raw(['worktree', 'list', '--porcelain']);
const worktreeLines = worktrees.split('\n');
let currentWorktreePath = '';
for (const line of worktreeLines) {
if (line.startsWith('worktree ')) {
currentWorktreePath = line.substring(9).trim();
} else if (line.startsWith('branch ') && currentWorktreePath) {
const branchRef = line.substring(7).trim();
if (branchRef === `refs/heads/${branch}` || branchRef.endsWith(`/${branch}`)) {
logger.debug({ branch, worktreePath: currentWorktreePath }, 'Worktree already exists');
return currentWorktreePath;
}
}
}
} catch (err) {
logger.debug({ error: err }, 'Failed to list worktrees, will create new one');
}
// Check if directory exists but is not a valid worktree
try {
const { accessSync, constants } = await import('fs');
accessSync(worktreePath, constants.F_OK);
// Directory exists, check if it's a valid git repo
const worktreeGit = simpleGit(worktreePath);
await worktreeGit.status();
logger.debug({ branch, worktreePath }, 'Existing directory is valid worktree');
return worktreePath;
} catch {
// Directory doesn't exist or is invalid, will create new worktree
}
// Remove existing directory if it exists but is invalid
try {
await rm(worktreePath, { recursive: true, force: true });
} catch {
// Ignore errors - directory might not exist
}
// Create worktree
try {
await execGitProcess(['worktree', 'add', worktreePath, branch], {
cwd: repoPath,
timeoutMs: 5 * 60 * 1000 // 5 minutes
});
} catch (error: any) {
const stderr = error.message || '';
// If branch doesn't exist, create it first
if (stderr.includes('fatal: invalid reference') ||
stderr.includes('fatal: not a valid object name') ||
stderr.includes('Ungültige Referenz')) {
// Find source branch
const branches = await git.branch(['-a']);
let sourceBranch = 'HEAD';
if (branches.all.includes('HEAD') || branches.all.includes('origin/HEAD')) {
sourceBranch = 'HEAD';
} else if (branches.all.includes('main') || branches.all.includes('origin/main')) {
sourceBranch = 'main';
} else if (branches.all.includes('master') || branches.all.includes('origin/master')) {
sourceBranch = 'master';
} else {
const firstBranch = branches.all.find(b => !b.includes('HEAD'));
if (firstBranch) {
sourceBranch = firstBranch.replace(/^origin\//, '');
}
}
// Create branch
try {
await execGitProcess(['branch', branch, sourceBranch], {
cwd: repoPath,
timeoutMs: 2 * 60 * 1000
});
} catch (branchError: any) {
// Try creating orphan branch
if (branchError.message?.includes('fatal: invalid reference')) {
await execGitProcess(['branch', branch], {
cwd: repoPath,
timeoutMs: 2 * 60 * 1000
});
} else {
throw branchError;
}
}
// Retry worktree creation
try {
await execGitProcess(['worktree', 'add', worktreePath, branch], {
cwd: repoPath,
timeoutMs: 5 * 60 * 1000
});
} catch (retryError: any) {
// Try with --orphan as last resort
if (retryError.message?.includes('fatal: invalid reference')) {
await execGitProcess(['worktree', 'add', '--orphan', branch, worktreePath], {
cwd: repoPath,
timeoutMs: 5 * 60 * 1000
});
} else {
throw retryError;
}
}
} else {
throw error;
}
}
// Verify worktree was created
const { accessSync, constants } = await import('fs');
try {
accessSync(worktreePath, constants.F_OK);
} catch {
throw new Error(`Worktree directory was not created: ${worktreePath}`);
}
// Verify it's a valid git repository
const worktreeGit = simpleGit(worktreePath);
try {
await worktreeGit.status();
} catch {
throw new Error(`Created worktree directory is not a valid git repository: ${worktreePath}`);
}
logger.operation('Worktree created', { branch, worktreePath });
return worktreePath;
}
/**
* Remove a worktree
*/
export async function removeWorktree(
repoPath: string,
worktreePath: string
): Promise<void> {
try {
await execGitProcess(['worktree', 'remove', worktreePath], {
cwd: repoPath,
timeoutMs: 2 * 60 * 1000
});
} catch (error: any) {
// Try force remove
try {
await execGitProcess(['worktree', 'remove', '--force', worktreePath], {
cwd: repoPath,
timeoutMs: 2 * 60 * 1000
});
} catch {
// Last resort: delete directory
const { rm } = await import('fs/promises');
await rm(worktreePath, { recursive: true, force: true });
logger.warn({ worktreePath }, 'Force removed worktree directory');
}
}
logger.operation('Worktree removed', { worktreePath });
}

311
src/lib/services/git/file-manager/write-operations.ts

@ -0,0 +1,311 @@ @@ -0,0 +1,311 @@
/**
* Write operations module
* Handles file writing, deletion, and commit operations
*/
import { join, dirname, resolve } from 'path';
import simpleGit, { type SimpleGit } from 'simple-git';
import logger from '../../logger.js';
import { sanitizeError } from '../../../utils/security.js';
import { isValidBranchName } from '../../../utils/security.js';
import { validateFilePath, validateRepoName, validateNpub } from './path-validator.js';
import { getOrCreateWorktree, removeWorktree } from './worktree-manager.js';
import { createGitCommitSignature } from '../commit-signer.js';
import type { NostrEvent } from '../../../types/nostr.js';
export interface WriteFileOptions {
npub: string;
repoName: string;
filePath: string;
content: string;
commitMessage: string;
authorName: string;
authorEmail: string;
branch?: string;
repoPath: string;
worktreePath: string;
signingOptions?: {
commitSignatureEvent?: NostrEvent;
useNIP07?: boolean;
nip98Event?: NostrEvent;
nsecKey?: string;
};
saveCommitSignature?: (worktreePath: string, event: NostrEvent) => Promise<void>;
isRepoPrivate?: (npub: string, repoName: string) => Promise<boolean>;
}
/**
* Write file and commit changes
*/
export async function writeFile(options: WriteFileOptions): Promise<void> {
const {
npub,
repoName,
filePath,
content,
commitMessage,
authorName,
authorEmail,
branch = 'main',
repoPath,
worktreePath,
signingOptions,
saveCommitSignature,
isRepoPrivate
} = options;
// Validate inputs
const npubValidation = validateNpub(npub);
if (!npubValidation.valid) {
throw new Error(`Invalid npub: ${npubValidation.error}`);
}
const repoValidation = validateRepoName(repoName);
if (!repoValidation.valid) {
throw new Error(`Invalid repository name: ${repoValidation.error}`);
}
const pathValidation = validateFilePath(filePath);
if (!pathValidation.valid) {
throw new Error(`Invalid file path: ${pathValidation.error}`);
}
if (!isValidBranchName(branch)) {
throw new Error(`Invalid branch name: ${branch}`);
}
// Validate content size (500 MB max)
const maxFileSize = 500 * 1024 * 1024;
if (Buffer.byteLength(content, 'utf-8') > maxFileSize) {
throw new Error(`File is too large (max ${maxFileSize / 1024 / 1024} MB)`);
}
// Validate commit message
if (!commitMessage || typeof commitMessage !== 'string' || commitMessage.trim().length === 0) {
throw new Error('Commit message is required');
}
if (commitMessage.length > 1000) {
throw new Error('Commit message is too long (max 1000 characters)');
}
// Validate author info
if (!authorName || typeof authorName !== 'string' || authorName.trim().length === 0) {
throw new Error('Author name is required');
}
if (!authorEmail || typeof authorEmail !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authorEmail)) {
throw new Error('Valid author email is required');
}
try {
logger.operation('Writing file', { npub, repoName, filePath, branch });
const workGit: SimpleGit = simpleGit(worktreePath);
// Write the file
const validatedPath = pathValidation.normalized || filePath;
const fullFilePath = join(worktreePath, validatedPath);
const fileDir = dirname(fullFilePath);
// Security: ensure resolved path is within workDir
const resolvedPath = resolve(fullFilePath).replace(/\\/g, '/');
const resolvedWorkDir = resolve(worktreePath).replace(/\\/g, '/');
if (!resolvedPath.startsWith(resolvedWorkDir + '/') && resolvedPath !== resolvedWorkDir) {
throw new Error('Path validation failed: resolved path outside work directory');
}
// Ensure directory exists
const { mkdir } = await import('fs/promises');
await mkdir(fileDir, { recursive: true });
const { writeFile: writeFileFs } = await import('fs/promises');
await writeFileFs(fullFilePath, content, 'utf-8');
// Stage the file
await workGit.add(validatedPath);
// Sign commit if signing options are provided
let finalCommitMessage = commitMessage;
let signatureEvent: NostrEvent | null = null;
if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) {
try {
const result = await createGitCommitSignature(
commitMessage,
authorName,
authorEmail,
signingOptions
);
finalCommitMessage = result.signedMessage;
signatureEvent = signingOptions.commitSignatureEvent || result.signatureEvent;
} catch (err) {
const sanitizedErr = sanitizeError(err);
logger.warn({ error: sanitizedErr, repoPath, filePath }, 'Failed to sign commit');
}
}
// Commit
const commitResult = await workGit.commit(finalCommitMessage, [filePath], {
'--author': `${authorName} <${authorEmail}>`
}) as string | { commit: string };
// Get commit hash
let commitHash: string;
if (typeof commitResult === 'string') {
commitHash = commitResult.trim();
} else if (commitResult && typeof commitResult === 'object' && 'commit' in commitResult) {
commitHash = String(commitResult.commit);
} else {
commitHash = await workGit.revparse(['HEAD']);
}
// Save commit signature event if signing was used
if (signatureEvent && saveCommitSignature) {
try {
await saveCommitSignature(worktreePath, signatureEvent);
// Publish to relays if repo is public
if (isRepoPrivate && !(await isRepoPrivate(npub, repoName))) {
try {
const { NostrClient } = await import('../../nostr/nostr-client.js');
const { DEFAULT_NOSTR_RELAYS } = await import('../../../config.js');
const { getUserRelays } = await import('../../nostr/user-relays.js');
const { combineRelays } = await import('../../../config.js');
const { nip19 } = await import('nostr-tools');
const { requireNpubHex } = await import('../../../utils/npub-utils.js');
const userPubkeyHex = requireNpubHex(npub);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const { inbox, outbox } = await getUserRelays(userPubkeyHex, nostrClient);
const userRelays = outbox.length > 0
? combineRelays(outbox, DEFAULT_NOSTR_RELAYS)
: inbox.length > 0
? combineRelays(inbox, DEFAULT_NOSTR_RELAYS)
: DEFAULT_NOSTR_RELAYS;
const publishResult = await nostrClient.publishEvent(signatureEvent, userRelays);
if (publishResult.success.length > 0) {
logger.debug({
eventId: signatureEvent.id,
commitHash,
relays: publishResult.success
}, 'Published commit signature event to relays');
}
} catch (publishErr) {
logger.debug({ error: publishErr }, 'Failed to publish commit signature event to relays');
}
}
} catch (err) {
logger.debug({ error: err }, 'Failed to save commit signature event');
}
}
logger.operation('File written', { npub, repoName, filePath, commitHash });
} catch (error) {
logger.error({ error, repoPath, filePath, npub }, 'Error writing file');
throw new Error(`Failed to write file: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Delete a file
*/
export async function deleteFile(options: Omit<WriteFileOptions, 'content'>): Promise<void> {
const {
npub,
repoName,
filePath,
commitMessage,
authorName,
authorEmail,
branch = 'main',
repoPath,
worktreePath,
signingOptions,
saveCommitSignature
} = options;
// Validate inputs
const npubValidation = validateNpub(npub);
if (!npubValidation.valid) {
throw new Error(`Invalid npub: ${npubValidation.error}`);
}
const repoValidation = validateRepoName(repoName);
if (!repoValidation.valid) {
throw new Error(`Invalid repository name: ${repoValidation.error}`);
}
const pathValidation = validateFilePath(filePath);
if (!pathValidation.valid) {
throw new Error(`Invalid file path: ${pathValidation.error}`);
}
if (!isValidBranchName(branch)) {
throw new Error(`Invalid branch name: ${branch}`);
}
if (!commitMessage || typeof commitMessage !== 'string' || commitMessage.trim().length === 0) {
throw new Error('Commit message is required');
}
if (!authorName || typeof authorName !== 'string' || authorName.trim().length === 0) {
throw new Error('Author name is required');
}
if (!authorEmail || typeof authorEmail !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authorEmail)) {
throw new Error('Valid author email is required');
}
try {
logger.operation('Deleting file', { npub, repoName, filePath, branch });
const workGit: SimpleGit = simpleGit(worktreePath);
// Remove the file
const validatedPath = pathValidation.normalized || filePath;
const fullFilePath = join(worktreePath, validatedPath);
// Security: ensure resolved path is within workDir
const resolvedPath = resolve(fullFilePath).replace(/\\/g, '/');
const resolvedWorkDir = resolve(worktreePath).replace(/\\/g, '/');
if (!resolvedPath.startsWith(resolvedWorkDir + '/') && resolvedPath !== resolvedWorkDir) {
throw new Error('Path validation failed: resolved path outside work directory');
}
const { accessSync, constants, unlink } = await import('fs');
try {
accessSync(fullFilePath, constants.F_OK);
await unlink(fullFilePath);
} catch {
// File doesn't exist, that's fine - git rm will handle it
}
// Stage the deletion
await workGit.rm([validatedPath]);
// Sign commit if signing options are provided
let finalCommitMessage = commitMessage;
if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) {
try {
const { signedMessage } = await createGitCommitSignature(
commitMessage,
authorName,
authorEmail,
signingOptions
);
finalCommitMessage = signedMessage;
} catch (err) {
const sanitizedErr = sanitizeError(err);
logger.warn({ error: sanitizedErr, repoPath, filePath }, 'Failed to sign commit');
}
}
// Commit
await workGit.commit(finalCommitMessage, [filePath], {
'--author': `${authorName} <${authorEmail}>`
});
logger.operation('File deleted', { npub, repoName, filePath });
} catch (error) {
logger.error({ error, repoPath, filePath, npub }, 'Error deleting file');
throw new Error(`Failed to delete file: ${error instanceof Error ? error.message : String(error)}`);
}
}

172
src/lib/services/logger.ts

@ -1,30 +1,132 @@ @@ -1,30 +1,132 @@
/**
* Pino logger service
* Provides structured logging with pino-pretty for development
* Enhanced logging service with better console output and noise reduction
* Provides structured logging with pino for production, enhanced console for development
* Browser-safe: falls back to console in browser environments
*/
import type { Logger } from '../types/logger.js';
function createConsoleLogger(): Logger {
return {
info: (...args: unknown[]) => console.log('[INFO]', ...args),
error: (...args: unknown[]) => console.error('[ERROR]', ...args),
warn: (...args: unknown[]) => console.warn('[WARN]', ...args),
debug: (...args: unknown[]) => console.debug('[DEBUG]', ...args),
trace: (...args: unknown[]) => console.trace('[TRACE]', ...args),
fatal: (...args: unknown[]) => console.error('[FATAL]', ...args)
export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';
interface LogContext {
[key: string]: unknown;
}
interface EnhancedLogger extends Logger {
logWithContext(level: LogLevel, message: string, context?: LogContext): void;
performance(label: string, fn: () => Promise<unknown> | unknown): Promise<unknown>;
security(action: string, context?: LogContext): void;
operation(operation: string, context?: LogContext): void;
}
function formatContext(context?: LogContext): string {
if (!context || Object.keys(context).length === 0) return '';
try {
return ' ' + JSON.stringify(context, null, 0);
} catch {
return ' [context serialization failed]';
}
}
function shouldLog(level: LogLevel, minLevel: LogLevel = 'info'): boolean {
const levels: LogLevel[] = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'];
return levels.indexOf(level) >= levels.indexOf(minLevel);
}
function createConsoleLogger(): EnhancedLogger {
const minLevel = (typeof process !== 'undefined' && process.env?.LOG_LEVEL as LogLevel) || 'info';
const isDev = typeof process !== 'undefined' && process.env?.NODE_ENV === 'development';
const baseLogger = {
info: (message: string, ...args: unknown[]) => {
if (shouldLog('info', minLevel)) {
console.log(`[INFO] ${message}`, ...args);
}
},
error: (message: string, ...args: unknown[]) => {
if (shouldLog('error', minLevel)) {
console.error(`[ERROR] ${message}`, ...args);
}
},
warn: (message: string, ...args: unknown[]) => {
if (shouldLog('warn', minLevel)) {
console.warn(`[WARN] ${message}`, ...args);
}
},
debug: (message: string, ...args: unknown[]) => {
if (shouldLog('debug', minLevel)) {
console.debug(`[DEBUG] ${message}`, ...args);
}
},
trace: (message: string, ...args: unknown[]) => {
if (shouldLog('trace', minLevel)) {
console.trace(`[TRACE] ${message}`, ...args);
}
},
fatal: (message: string, ...args: unknown[]) => {
console.error(`[FATAL] ${message}`, ...args);
},
logWithContext: (level: LogLevel, message: string, context?: LogContext) => {
if (!shouldLog(level, minLevel)) return;
const contextStr = formatContext(context);
const timestamp = new Date().toISOString();
const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
switch (level) {
case 'error':
case 'fatal':
console.error(`${prefix} ${message}${contextStr}`);
break;
case 'warn':
console.warn(`${prefix} ${message}${contextStr}`);
break;
case 'debug':
case 'trace':
console.debug(`${prefix} ${message}${contextStr}`);
break;
default:
console.log(`${prefix} ${message}${contextStr}`);
}
},
performance: async (label: string, fn: () => Promise<unknown> | unknown) => {
const start = performance.now();
try {
const result = await fn();
const duration = performance.now() - start;
if (duration > 100 || isDev) {
console.log(`[PERF] ${label} took ${duration.toFixed(2)}ms`);
}
return result;
} catch (error) {
const duration = performance.now() - start;
console.error(`[PERF] ${label} failed after ${duration.toFixed(2)}ms:`, error);
throw error;
}
},
security: (action: string, context?: LogContext) => {
const contextStr = formatContext(context);
const timestamp = new Date().toISOString();
console.warn(`[SECURITY] [${timestamp}] ${action}${contextStr}`);
},
operation: (operation: string, context?: LogContext) => {
if (isDev || shouldLog('info', minLevel)) {
const contextStr = formatContext(context);
const timestamp = new Date().toISOString();
console.log(`[OP] [${timestamp}] ${operation}${contextStr}`);
}
}
};
return baseLogger as EnhancedLogger;
}
// Check if we're in a Node.js environment
const isNode = typeof process !== 'undefined' && process.versions?.node;
let logger: Logger;
let logger: EnhancedLogger;
if (isNode) {
// Server-side: use pino
// Use dynamic import to avoid bundling for browser
// Server-side: use pino with enhanced console output
const initPino = async () => {
try {
const pinoModule = await import('pino');
@ -32,7 +134,7 @@ if (isNode) { @@ -32,7 +134,7 @@ if (isNode) {
const logLevel = (typeof process !== 'undefined' && process.env?.LOG_LEVEL) || 'info';
const isDev = typeof process !== 'undefined' && process.env?.NODE_ENV === 'development';
return pino({
const pinoLogger = pino({
level: logLevel,
...(isDev && {
transport: {
@ -40,11 +142,47 @@ if (isNode) { @@ -40,11 +142,47 @@ if (isNode) {
options: {
colorize: true,
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname'
ignore: 'pid,hostname',
singleLine: false
}
}
})
});
// Enhance pino logger with console methods
const enhanced = createConsoleLogger();
return {
...pinoLogger,
logWithContext: enhanced.logWithContext,
performance: enhanced.performance,
security: enhanced.security,
operation: enhanced.operation,
// Override pino methods to also log to console in dev
info: (obj: unknown, msg?: string, ...args: unknown[]) => {
pinoLogger.info(obj, msg, ...args);
if (isDev) enhanced.info(typeof msg === 'string' ? msg : String(obj), ...args);
},
error: (obj: unknown, msg?: string, ...args: unknown[]) => {
pinoLogger.error(obj, msg, ...args);
enhanced.error(typeof msg === 'string' ? msg : String(obj), ...args);
},
warn: (obj: unknown, msg?: string, ...args: unknown[]) => {
pinoLogger.warn(obj, msg, ...args);
if (isDev) enhanced.warn(typeof msg === 'string' ? msg : String(obj), ...args);
},
debug: (obj: unknown, msg?: string, ...args: unknown[]) => {
pinoLogger.debug(obj, msg, ...args);
if (isDev) enhanced.debug(typeof msg === 'string' ? msg : String(obj), ...args);
},
trace: (obj: unknown, msg?: string, ...args: unknown[]) => {
pinoLogger.trace(obj, msg, ...args);
if (isDev) enhanced.trace(typeof msg === 'string' ? msg : String(obj), ...args);
},
fatal: (obj: unknown, msg?: string, ...args: unknown[]) => {
pinoLogger.fatal(obj, msg, ...args);
enhanced.fatal(typeof msg === 'string' ? msg : String(obj), ...args);
}
} as EnhancedLogger;
} catch {
return createConsoleLogger();
}
@ -62,8 +200,8 @@ if (isNode) { @@ -62,8 +200,8 @@ if (isNode) {
// Keep console logger if pino fails
});
} else {
// Browser-side: use console with similar API
// Browser-side: use enhanced console
logger = createConsoleLogger();
}
export default logger;
export default logger as EnhancedLogger;

177
src/lib/utils/git-process.ts

@ -0,0 +1,177 @@ @@ -0,0 +1,177 @@
/**
* Utility functions for safely spawning git processes
* Prevents zombie processes by ensuring proper cleanup
*/
import { spawn, type ChildProcess } from 'child_process';
import logger from '../services/logger.js';
export interface GitProcessOptions {
cwd?: string;
env?: Record<string, string>;
timeoutMs?: number;
stdio?: ('ignore' | 'pipe')[];
}
export interface GitProcessResult {
stdout: string;
stderr: string;
code: number | null;
signal: NodeJS.Signals | null;
}
/**
* Safely spawn a git process with proper cleanup to prevent zombies
*
* @param args - Git command arguments
* @param options - Process options
* @returns Promise that resolves with process output
*/
export function spawnGitProcess(
args: string[],
options: GitProcessOptions = {}
): Promise<GitProcessResult> {
const {
cwd,
env = {},
timeoutMs = 30 * 60 * 1000, // 30 minutes default
stdio = ['ignore', 'pipe', 'pipe']
} = options;
return new Promise((resolve, reject) => {
const gitProcess = spawn('git', args, {
cwd,
env: Object.keys(env).length > 0 ? env : undefined,
stdio,
detached: false // Keep in same process group to prevent zombies
});
let stdout = '';
let stderr = '';
let resolved = false;
// Set timeout to prevent hanging processes
const timeoutId = timeoutMs > 0 ? setTimeout(() => {
if (!resolved && !gitProcess.killed) {
resolved = true;
logger.warn({ args, timeoutMs }, 'Git process timeout, killing process');
// Kill the process tree to prevent zombies
try {
gitProcess.kill('SIGTERM');
// Force kill after grace period
const forceKillTimeout = setTimeout(() => {
if (gitProcess.pid && !gitProcess.killed) {
try {
gitProcess.kill('SIGKILL');
} catch (err) {
logger.warn({ err, pid: gitProcess.pid }, 'Failed to force kill git process');
}
}
}, 5000);
// Clear force kill timeout if process terminates
gitProcess.once('close', () => {
clearTimeout(forceKillTimeout);
});
} catch (err) {
logger.warn({ err }, 'Error killing timed out git process');
}
reject(new Error(`Git command timeout after ${timeoutMs}ms: ${args.join(' ')}`));
}
}, timeoutMs) : null;
// Collect stdout
if (gitProcess.stdout) {
gitProcess.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString();
});
}
// Collect stderr
if (gitProcess.stderr) {
gitProcess.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
}
// Handle process close (main cleanup point)
gitProcess.on('close', (code, signal) => {
if (timeoutId) clearTimeout(timeoutId);
if (resolved) return;
resolved = true;
// Ensure process is fully cleaned up
if (gitProcess.pid) {
try {
// Check if process still exists (this helps ensure cleanup)
process.kill(gitProcess.pid, 0);
} catch {
// Process already dead, that's fine
}
}
resolve({
stdout,
stderr,
code,
signal
});
});
// Handle process errors
gitProcess.on('error', (err) => {
if (timeoutId) clearTimeout(timeoutId);
if (resolved) return;
resolved = true;
logger.error({ err, args }, 'Git process error');
reject(err);
});
// Handle process exit (backup cleanup)
gitProcess.on('exit', (code, signal) => {
// This is primarily handled by 'close' event
// But we ensure we catch all cases
if (!resolved && code !== null && code !== 0) {
if (timeoutId) clearTimeout(timeoutId);
resolved = true;
const errorMsg = signal
? `Git command terminated by signal ${signal}: ${stderr || stdout}`
: `Git command failed with code ${code}: ${stderr || stdout}`;
reject(new Error(errorMsg));
}
});
});
}
/**
* Safely spawn a git process and throw on non-zero exit code
*
* @param args - Git command arguments
* @param options - Process options
* @returns Promise that resolves with stdout/stderr only on success
*/
export async function execGitProcess(
args: string[],
options: GitProcessOptions = {}
): Promise<{ stdout: string; stderr: string }> {
const result = await spawnGitProcess(args, options);
if (result.code !== 0) {
const errorMsg = result.signal
? `Git command terminated by signal ${result.signal}: ${result.stderr || result.stdout}`
: `Git command failed with code ${result.code}: ${result.stderr || result.stdout}`;
throw new Error(errorMsg);
}
return {
stdout: result.stdout,
stderr: result.stderr
};
}

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

File diff suppressed because it is too large Load Diff

119
src/routes/repos/[npub]/[repo]/components/CommitDialog.svelte

@ -0,0 +1,119 @@ @@ -0,0 +1,119 @@
<script lang="ts">
export let show: boolean = false;
export let commitMessage: string = '';
export let saving: boolean = false;
export let onCommit: () => void = () => {};
export let onCancel: () => void = () => {};
export let onMessageChange: (message: string) => void = () => {};
</script>
{#if show}
<div class="modal-overlay" onclick={onCancel}>
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
<h2>Commit Changes</h2>
<div class="form-group">
<label for="commit-message">Commit Message</label>
<textarea
id="commit-message"
bind:value={commitMessage}
oninput={(e) => onMessageChange(e.target.value)}
placeholder="Enter commit message..."
rows="5"
disabled={saving}
/>
</div>
<div class="modal-footer">
<button
class="primary-button"
onclick={onCommit}
disabled={saving || !commitMessage.trim()}
>
{saving ? 'Committing...' : 'Commit'}
</button>
<button
class="cancel-button"
onclick={onCancel}
disabled={saving}
>
Cancel
</button>
</div>
</div>
</div>
{/if}
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--bg-primary);
border-radius: 8px;
padding: 2rem;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.form-group {
margin: 1.5rem 0;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-family: inherit;
resize: vertical;
}
.modal-footer {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
.primary-button, .cancel-button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
}
.primary-button {
background: var(--accent-color);
color: white;
}
.primary-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.cancel-button {
background: var(--bg-secondary);
color: var(--text-primary);
}
</style>

240
src/routes/repos/[npub]/[repo]/components/DocsTab.svelte

@ -0,0 +1,240 @@ @@ -0,0 +1,240 @@
<script lang="ts">
/**
* Documentation tab component
* Handles markdown, asciidoc, and kind 30040 publication indexes
*/
import TabLayout from './TabLayout.svelte';
import DocsViewer from './DocsViewer.svelte';
import type { NostrEvent } from '$lib/types/nostr.js';
import { KIND } from '$lib/types/nostr.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import logger from '$lib/services/logger.js';
export let npub: string = '';
export let repo: string = '';
export let currentBranch: string | null = null;
export let relays: string[] = DEFAULT_NOSTR_RELAYS;
let documentationContent = $state<string | null>(null);
let documentationKind = $state<'markdown' | 'asciidoc' | 'text' | '30040' | null>(null);
let indexEvent = $state<NostrEvent | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
let docFiles: Array<{ name: string; path: string }> = $state([]);
let selectedDoc: string | null = $state(null);
$effect(() => {
if (npub && repo && currentBranch) {
loadDocumentation();
}
});
async function loadDocumentation() {
loading = true;
error = null;
documentationContent = null;
documentationKind = null;
indexEvent = null;
try {
logger.operation('Loading documentation', { npub, repo, branch: currentBranch });
// Try to find documentation files
const response = await fetch(`/api/repos/${npub}/${repo}/tree?ref=${currentBranch || 'HEAD'}&path=docs`);
if (response.ok) {
const data = await response.json();
docFiles = data.files || [];
// Look for README or index files first
const readmeFile = docFiles.find(f =>
f.name.toLowerCase() === 'readme.md' ||
f.name.toLowerCase() === 'readme.adoc' ||
f.name.toLowerCase() === 'index.md'
);
if (readmeFile) {
await loadDocFile(readmeFile.path);
} else if (docFiles.length > 0) {
// Load first file
await loadDocFile(docFiles[0].path);
}
} else {
// Try to load README from root
try {
const readmeResponse = await fetch(`/api/repos/${npub}/${repo}/readme?ref=${currentBranch || 'HEAD'}`);
if (readmeResponse.ok) {
const readmeData = await readmeResponse.json();
documentationContent = readmeData.content || '';
documentationKind = readmeData.type || 'markdown';
}
} catch {
// No README found
}
}
// Check for kind 30040 publication index
await checkForPublicationIndex();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load documentation';
logger.error({ error: err, npub, repo }, 'Error loading documentation');
} finally {
loading = false;
}
}
async function loadDocFile(path: string) {
try {
const response = await fetch(`/api/repos/${npub}/${repo}/raw?path=${encodeURIComponent(path)}&ref=${currentBranch || 'HEAD'}`);
if (response.ok) {
const content = await response.text();
documentationContent = content;
// Determine type from extension
const ext = path.split('.').pop()?.toLowerCase();
if (ext === 'md' || ext === 'markdown') {
documentationKind = 'markdown';
} else if (ext === 'adoc' || ext === 'asciidoc') {
documentationKind = 'asciidoc';
} else {
documentationKind = 'text';
}
selectedDoc = path;
}
} catch (err) {
logger.warn({ error: err, path }, 'Failed to load doc file');
}
}
async function checkForPublicationIndex() {
try {
// Look for kind 30040 events in the repo announcement
const { requireNpubHex } = await import('$lib/utils/npub-utils.js');
const repoOwnerPubkey = requireNpubHex(npub);
const client = new NostrClient(relays);
const events = await client.fetchEvents([
{
kinds: [30040],
authors: [repoOwnerPubkey],
'#d': [repo],
limit: 1
}
]);
if (events.length > 0) {
indexEvent = events[0];
documentationKind = '30040';
logger.debug({ eventId: indexEvent.id }, 'Found kind 30040 publication index');
}
} catch (err) {
logger.debug({ error: err }, 'No kind 30040 index found or error checking');
}
}
function handleItemClick(item: any) {
if (item.url) {
window.open(item.url, '_blank');
} else if (item.path) {
loadDocFile(item.path);
}
}
</script>
<TabLayout {loading} {error}>
{#snippet leftPane()}
<div class="docs-sidebar">
<h3>Documentation</h3>
{#if docFiles.length > 0}
<ul class="doc-list">
{#each docFiles as file}
<li>
<button
class="doc-item {selectedDoc === file.path ? 'selected' : ''}"
onclick={() => loadDocFile(file.path)}
>
{file.name}
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/snippet}
{#snippet rightPanel()}
{#if documentationKind === '30040' && indexEvent}
<DocsViewer
contentType="30040"
{indexEvent}
{relays}
onItemClick={handleItemClick}
/>
{:else if documentationContent}
<DocsViewer
content={documentationContent}
contentType={documentationKind || 'text'}
/>
{:else}
<div class="empty-docs">
<p>No documentation found</p>
<p class="hint">Add a README.md, README.adoc, or docs/ folder to your repository</p>
</div>
{/if}
{/snippet}
</TabLayout>
<style>
.docs-sidebar {
padding: 1rem;
}
.docs-sidebar h3 {
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 600;
}
.doc-list {
list-style: none;
padding: 0;
margin: 0;
}
.doc-item {
width: 100%;
padding: 0.75rem;
text-align: left;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 4px;
margin-bottom: 0.5rem;
cursor: pointer;
transition: all 0.2s;
}
.doc-item:hover {
background: var(--bg-hover);
border-color: var(--accent-color);
}
.doc-item.selected {
background: var(--bg-selected);
border-color: var(--accent-color);
}
.empty-docs {
padding: 3rem;
text-align: center;
color: var(--text-secondary);
}
.hint {
font-size: 0.9rem;
margin-top: 0.5rem;
}
</style>

242
src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte

@ -0,0 +1,242 @@ @@ -0,0 +1,242 @@
<script lang="ts">
/**
* Generic documentation viewer
* Handles markdown, asciidoc, and kind 30040 publication indexes
*/
import { onMount } from 'svelte';
import PublicationIndexViewer from '$lib/components/PublicationIndexViewer.svelte';
import type { NostrEvent } from '$lib/types/nostr.js';
import { KIND } from '$lib/types/nostr.js';
import logger from '$lib/services/logger.js';
export let content: string = '';
export let contentType: 'markdown' | 'asciidoc' | 'text' | '30040' = 'text';
export let indexEvent: NostrEvent | null = null;
export let relays: string[] = [];
let renderedContent = $state('');
let loading = $state(false);
let error = $state<string | null>(null);
$effect(() => {
if (contentType === '30040' && indexEvent) {
// Publication index - handled by PublicationIndexViewer
return;
}
if (content) {
renderContent();
}
});
async function renderContent() {
loading = true;
error = null;
try {
logger.operation('Rendering content', { contentType, length: content.length });
if (contentType === 'markdown') {
const MarkdownIt = (await import('markdown-it')).default;
const hljsModule = await import('highlight.js');
const hljs = hljsModule.default || hljsModule;
const md = new MarkdownIt({
highlight: function (str: string, lang: string): string {
if (lang && hljs.getLanguage(lang)) {
try {
return '<pre class="hljs"><code>' +
hljs.highlight(str, { language: lang }).value +
'</code></pre>';
} catch (err) {
// Fallback to escaped HTML
}
}
return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>';
}
});
renderedContent = md.render(content);
// Add IDs to headings for anchor links
renderedContent = renderedContent.replace(/<h([1-6])>(.*?)<\/h[1-6]>/g, (match, level, text) => {
const textContent = text.replace(/<[^>]*>/g, '').trim();
const slug = textContent
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
return `<h${level} id="${slug}">${text}</h${level}>`;
});
} else if (contentType === 'asciidoc') {
const asciidoctor = (await import('asciidoctor')).default();
renderedContent = asciidoctor.convert(content, {
safe: 'safe',
attributes: {
'source-highlighter': 'highlight.js'
}
});
} else {
// Plain text - escape HTML
renderedContent = content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
}
logger.operation('Content rendered', { contentType });
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to render content';
logger.error({ error: err, contentType }, 'Error rendering content');
} finally {
loading = false;
}
}
function handleItemClick(item: any) {
logger.debug({ item }, 'Publication index item clicked');
// Could navigate to item URL or emit event
if (item.url) {
window.open(item.url, '_blank');
}
}
</script>
<div class="docs-viewer">
{#if loading}
<div class="loading">Rendering content...</div>
{:else if error}
<div class="error">{error}</div>
{:else if contentType === '30040' && indexEvent}
<PublicationIndexViewer
{indexEvent}
{relays}
onItemClick={handleItemClick}
/>
{:else if renderedContent}
<div class="rendered-content" class:markdown={contentType === 'markdown'} class:asciidoc={contentType === 'asciidoc'}>
{@html renderedContent}
</div>
{:else}
<div class="empty">No content to display</div>
{/if}
</div>
<style>
.docs-viewer {
padding: 1rem;
max-width: 100%;
}
.loading, .error, .empty {
padding: 2rem;
text-align: center;
color: var(--text-secondary);
}
.error {
color: var(--accent-error);
}
.rendered-content {
line-height: 1.6;
}
.rendered-content :global(h1),
.rendered-content :global(h2),
.rendered-content :global(h3),
.rendered-content :global(h4),
.rendered-content :global(h5),
.rendered-content :global(h6) {
margin-top: 2rem;
margin-bottom: 1rem;
font-weight: 600;
}
.rendered-content :global(h1) {
font-size: 2rem;
border-bottom: 2px solid var(--border-color);
padding-bottom: 0.5rem;
}
.rendered-content :global(h2) {
font-size: 1.5rem;
}
.rendered-content :global(h3) {
font-size: 1.25rem;
}
.rendered-content :global(p) {
margin: 1rem 0;
}
.rendered-content :global(code) {
background: var(--bg-secondary);
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-family: monospace;
font-size: 0.9em;
}
.rendered-content :global(pre) {
background: var(--bg-secondary);
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin: 1rem 0;
}
.rendered-content :global(pre code) {
background: none;
padding: 0;
}
.rendered-content :global(blockquote) {
border-left: 4px solid var(--accent-color);
padding-left: 1rem;
margin: 1rem 0;
color: var(--text-secondary);
}
.rendered-content :global(ul),
.rendered-content :global(ol) {
margin: 1rem 0;
padding-left: 2rem;
}
.rendered-content :global(li) {
margin: 0.5rem 0;
}
.rendered-content :global(a) {
color: var(--accent-color);
text-decoration: none;
}
.rendered-content :global(a:hover) {
text-decoration: underline;
}
.rendered-content :global(table) {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
}
.rendered-content :global(th),
.rendered-content :global(td) {
border: 1px solid var(--border-color);
padding: 0.5rem;
text-align: left;
}
.rendered-content :global(th) {
background: var(--bg-secondary);
font-weight: 600;
}
</style>

109
src/routes/repos/[npub]/[repo]/components/FileBrowser.svelte

@ -0,0 +1,109 @@ @@ -0,0 +1,109 @@
<script lang="ts">
import type { FileEntry } from '$lib/services/git/file-manager.js';
export let files: FileEntry[] = [];
export let currentPath: string = '';
export let loading: boolean = false;
export let onFileClick: (file: FileEntry) => void = () => {};
export let onDirectoryClick: (path: string) => void = () => {};
export let onNavigateBack: () => void = () => {};
export let pathStack: string[] = [];
</script>
<div class="file-browser">
{#if loading}
<div class="loading">Loading files...</div>
{:else if files.length === 0}
<div class="empty">No files found</div>
{:else}
<div class="file-list">
{#if currentPath}
<button class="nav-back" onclick={onNavigateBack}>
← Back
</button>
{/if}
{#each files as file}
<div
class="file-item {file.type}"
onclick={() => file.type === 'directory' ? onDirectoryClick(file.path) : onFileClick(file)}
>
<span class="icon">
{#if file.type === 'directory'}
📁
{:else}
📄
{/if}
</span>
<span class="name">{file.name}</span>
{#if file.size !== undefined}
<span class="size">{(file.size / 1024).toFixed(1)} KB</span>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<style>
.file-browser {
padding: 1rem;
}
.loading, .empty {
padding: 2rem;
text-align: center;
color: var(--text-secondary);
}
.file-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.nav-back {
padding: 0.5rem 1rem;
margin-bottom: 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
}
.nav-back:hover {
background: var(--bg-hover);
}
.file-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
.file-item:hover {
background: var(--bg-hover);
}
.file-item.directory {
font-weight: 500;
}
.icon {
font-size: 1.2rem;
}
.name {
flex: 1;
}
.size {
color: var(--text-secondary);
font-size: 0.9rem;
}
</style>

235
src/routes/repos/[npub]/[repo]/components/FilesTab.svelte

@ -0,0 +1,235 @@ @@ -0,0 +1,235 @@
<script lang="ts">
/**
* Files tab component
* Handles file browser, editor, and README display
*/
import TabLayout from './TabLayout.svelte';
import FileBrowser from './FileBrowser.svelte';
import CodeEditor from '$lib/components/CodeEditor.svelte';
export let files: Array<{ name: string; path: string; type: 'file' | 'directory'; size?: number }> = [];
export let currentPath: string = '';
export let currentFile: string | null = null;
export let fileContent: string = '';
export let fileLanguage: 'markdown' | 'asciidoc' | 'text' = 'text';
export let editedContent: string = '';
export let hasChanges: boolean = false;
export let loading: boolean = false;
export let error: string | null = null;
export let pathStack: string[] = [];
export let onFileClick: (file: { name: string; path: string; type: 'file' | 'directory' }) => void = () => {};
export let onDirectoryClick: (path: string) => void = () => {};
export let onNavigateBack: () => void = () => {};
export let onContentChange: (content: string) => void = () => {};
export let isMaintainer: boolean = false;
export let readmeContent: string | null = null;
export let readmePath: string | null = null;
export let readmeHtml: string | null = null;
export let showFilePreview: boolean = false;
export let fileHtml: string | null = null;
export let highlightedFileContent: string | null = null;
export let isImageFile: boolean = false;
export let imageUrl: string | null = null;
export let wordWrap: boolean = false;
export let supportsPreview: (ext: string) => boolean = () => false;
export let onSave: () => void = () => {};
export let onTogglePreview: () => void = () => {};
export let onCopyFileContent: (e: Event) => void = () => {};
export let onDownloadFile: () => void = () => {};
export let copyingFile: boolean = false;
export let saving: boolean = false;
export let needsClone: boolean = false;
export let cloneTooltip: string = '';
export let branches: Array<string | { name: string }> = [];
export let currentBranch: string | null = null;
export let defaultBranch: string | null = null;
export let onBranchChange: (branch: string) => void = () => {};
export let userPubkey: string | null = null;
</script>
<TabLayout {loading} {error}>
{#snippet leftPane()}
<FileBrowser
{files}
{currentPath}
{onFileClick}
{onDirectoryClick}
{onNavigateBack}
{pathStack}
/>
{/snippet}
{#snippet rightPanel()}
{#if readmeContent && !currentFile}
<div class="readme-section">
<div class="readme-header">
<h3>README</h3>
<div class="readme-actions">
{#if readmePath && supportsPreview((readmePath.split('.').pop() || '').toLowerCase())}
<button
onclick={onTogglePreview}
class="preview-toggle-button"
title={showFilePreview ? 'Show raw' : 'Show preview'}
>
{showFilePreview ? 'Raw' : 'Preview'}
</button>
{/if}
{#if readmePath}
<a href={`/api/repos/${readmePath}`} target="_blank" class="raw-link">View Raw</a>
{/if}
</div>
</div>
{#if showFilePreview && readmeHtml && readmeHtml.trim()}
<div class="readme-content markdown">
{@html readmeHtml}
</div>
{:else if readmeContent}
<div class="readme-content">
<pre><code class="hljs language-text">{readmeContent}</code></pre>
</div>
{/if}
</div>
{:else if currentFile}
<div class="file-editor">
<div class="editor-header">
<span class="file-path">{currentFile}</span>
<div class="editor-actions">
{#if branches.length > 0 && isMaintainer}
<select
value={currentBranch || ''}
class="branch-selector"
disabled={saving || needsClone}
title="Select branch"
onchange={(e) => {
const target = e.target as HTMLSelectElement;
if (target.value) onBranchChange(target.value);
}}
>
{#each branches as branch}
{@const branchName = typeof branch === 'string' ? branch : branch.name}
<option value={branchName}>{branchName}{#if branchName === defaultBranch} (default){/if}</option>
{/each}
</select>
{:else if currentBranch && isMaintainer}
<span class="branch-display" title="Current branch">{currentBranch}</span>
{/if}
{#if hasChanges}
<span class="unsaved-indicator">● Unsaved changes</span>
{/if}
{#if currentFile && supportsPreview((currentFile.split('.').pop() || '').toLowerCase()) && !isMaintainer}
<button
onclick={onTogglePreview}
class="preview-toggle-button"
title={showFilePreview ? 'Show raw' : 'Show preview'}
>
{showFilePreview ? 'Raw' : 'Preview'}
</button>
{/if}
{#if currentFile && fileContent}
<button
onclick={onCopyFileContent}
disabled={copyingFile}
class="file-action-button"
title="Copy raw content to clipboard"
>
<img src="/icons/copy.svg" alt="Copy" class="icon-inline" />
</button>
<button
onclick={onDownloadFile}
class="file-action-button"
title="Download file"
>
<img src="/icons/download.svg" alt="Download" class="icon-inline" />
</button>
{/if}
{#if isMaintainer}
<button
onclick={onSave}
disabled={!hasChanges || saving || needsClone}
class="save-button"
title={needsClone ? cloneTooltip : (hasChanges ? 'Save changes' : 'No changes to save')}
>
{saving ? 'Saving...' : 'Save'}
</button>
{:else if userPubkey}
<span class="non-maintainer-notice">Only maintainers can edit files. Submit a PR instead.</span>
{/if}
</div>
</div>
{#if loading}
<div class="loading">Loading file...</div>
{:else}
<div class="editor-container">
{#if isMaintainer}
<CodeEditor
content={editedContent || fileContent}
language={fileLanguage}
readOnly={needsClone}
onChange={(value) => {
editedContent = value;
hasChanges = value !== fileContent;
onContentChange(value);
}}
/>
{:else}
<div class="read-only-editor" class:word-wrap={wordWrap}>
{#if isImageFile && imageUrl}
<div class="file-preview image-preview">
<img src={imageUrl} alt={currentFile?.split('/').pop() || 'Image'} class="file-image" />
</div>
{:else if currentFile && showFilePreview && fileHtml && supportsPreview((currentFile.split('.').pop() || '').toLowerCase())}
<div class="file-preview markdown">
{@html fileHtml}
</div>
{:else if highlightedFileContent}
{@html highlightedFileContent}
{:else}
<pre><code class="hljs">{fileContent}</code></pre>
{/if}
</div>
{/if}
</div>
{/if}
{#if hasChanges && isMaintainer}
<div class="editor-footer">
<span class="unsaved-indicator">Unsaved changes</span>
</div>
{/if}
</div>
{:else}
<div class="empty-state">
<p>Select a file to view or edit</p>
</div>
{/if}
{/snippet}
</TabLayout>
<style>
.file-editor {
height: 100%;
display: flex;
flex-direction: column;
}
.editor-footer {
padding: 0.5rem 1rem;
border-top: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.unsaved-indicator {
color: var(--accent-warning);
font-size: 0.9rem;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
}
</style>

272
src/routes/repos/[npub]/[repo]/components/HistoryTab.svelte

@ -0,0 +1,272 @@ @@ -0,0 +1,272 @@
<script lang="ts">
/**
* Commit history tab component
*/
import TabLayout from './TabLayout.svelte';
export let commits: Array<{
hash: string;
message: string;
author: string;
date: string;
files: string[];
verification?: any;
}> = [];
export let selectedCommit: string | null = null;
export let loading: boolean = false;
export let error: string | null = null;
export let onSelect: (hash: string) => void = () => {};
export let onVerify: (hash: string) => void = () => {};
export let verifyingCommits: Set<string> = new Set();
export let showDiff: boolean = false;
export let diffData: Array<{ file: string; additions: number; deletions: number; diff: string }> = [];
</script>
<TabLayout {loading} {error}>
{#snippet leftPane()}
<div class="commits-list">
<h3>Commits</h3>
{#if commits.length === 0}
<div class="empty">No commits found</div>
{:else}
<ul class="commit-list">
{#each commits as commit}
<li>
<button
class="commit-item {selectedCommit === commit.hash ? 'selected' : ''}"
onclick={() => onSelect(commit.hash)}
>
<div class="commit-hash">{commit.hash.slice(0, 7)}</div>
<div class="commit-message">{commit.message || 'No message'}</div>
<div class="commit-meta">
<span>{commit.author}</span>
<span>{new Date(commit.date).toLocaleString()}</span>
</div>
{#if commit.verification}
<div class="commit-verification">
{#if commit.verification.valid}
<span class="verified">✓ Verified</span>
{:else}
<span class="unverified">{commit.verification.error || 'Invalid'}</span>
{/if}
</div>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/snippet}
{#snippet rightPanel()}
{#if selectedCommit}
{@const commit = commits.find(c => c.hash === selectedCommit)}
{#if commit}
<div class="commit-detail">
<div class="commit-detail-header">
<h2>Commit {commit.hash.slice(0, 7)}</h2>
<button
onclick={() => onVerify(commit.hash)}
disabled={verifyingCommits.has(commit.hash)}
>
{verifyingCommits.has(commit.hash) ? 'Verifying...' : 'Verify Signature'}
</button>
</div>
<div class="commit-info">
<div class="info-row">
<strong>Author:</strong> {commit.author}
</div>
<div class="info-row">
<strong>Date:</strong> {new Date(commit.date).toLocaleString()}
</div>
<div class="info-row">
<strong>Message:</strong>
<div class="commit-message-text">{commit.message || 'No message'}</div>
</div>
{#if commit.files && commit.files.length > 0}
<div class="info-row">
<strong>Files ({commit.files.length}):</strong>
<ul class="files-list">
{#each commit.files as file}
<li>{file}</li>
{/each}
</ul>
</div>
{/if}
</div>
{#if showDiff && diffData.length > 0}
<div class="diff-section">
<h3>Changes</h3>
{#each diffData as diff}
<div class="diff-file">
<div class="diff-header">
<strong>{diff.file}</strong>
<span class="diff-stats">
+{diff.additions} -{diff.deletions}
</span>
</div>
<pre class="diff-content"><code>{diff.diff}</code></pre>
</div>
{/each}
</div>
{/if}
</div>
{/if}
{:else}
<div class="empty-state">
<p>Select a commit to view details</p>
</div>
{/if}
{/snippet}
</TabLayout>
<style>
.commits-list {
padding: 1rem;
}
.commits-list h3 {
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 600;
}
.empty {
padding: 2rem;
text-align: center;
color: var(--text-secondary);
}
.commit-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.commit-item {
width: 100%;
padding: 0.75rem;
text-align: left;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.commit-item:hover {
background: var(--bg-hover);
border-color: var(--accent-color);
}
.commit-item.selected {
background: var(--bg-selected);
border-color: var(--accent-color);
}
.commit-hash {
font-family: monospace;
font-weight: 600;
margin-bottom: 0.25rem;
}
.commit-message {
margin: 0.25rem 0;
font-weight: 500;
}
.commit-meta {
display: flex;
gap: 1rem;
font-size: 0.85rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}
.commit-verification {
margin-top: 0.5rem;
}
.verified {
color: var(--accent-success);
}
.unverified {
color: var(--accent-error);
}
.commit-detail {
padding: 1rem;
}
.commit-detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.commit-info {
margin: 1rem 0;
}
.info-row {
margin: 1rem 0;
}
.commit-message-text {
margin-top: 0.5rem;
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: 4px;
white-space: pre-wrap;
}
.files-list {
margin-top: 0.5rem;
padding-left: 1.5rem;
}
.diff-section {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid var(--border-color);
}
.diff-file {
margin: 1rem 0;
}
.diff-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: var(--bg-secondary);
border-radius: 4px 4px 0 0;
}
.diff-stats {
font-family: monospace;
font-size: 0.9rem;
}
.diff-content {
margin: 0;
padding: 1rem;
background: var(--bg-secondary);
border-radius: 0 0 4px 4px;
overflow-x: auto;
font-family: monospace;
font-size: 0.85rem;
}
</style>

158
src/routes/repos/[npub]/[repo]/components/IssuesTab.svelte

@ -0,0 +1,158 @@ @@ -0,0 +1,158 @@
<script lang="ts">
/**
* Issues tab component using hierarchical layout
*/
import StatusTabLayout from './StatusTabLayout.svelte';
export let issues: Array<{
id: string;
subject: string;
content: string;
status: string;
author: string;
created_at: number;
kind: number;
tags?: string[][];
}> = [];
export let selectedIssue: string | null = null;
export let loading: boolean = false;
export let error: string | null = null;
export let onSelect: (id: string) => void = () => {};
export let onStatusUpdate: (id: string, status: string) => void = () => {};
export let issueReplies: Array<any> = [];
export let loadingReplies: boolean = false;
const items = $derived(issues.map(issue => ({
id: issue.id,
title: issue.subject,
status: issue.status,
...issue
})));
const selectedId = $derived(selectedIssue);
</script>
<StatusTabLayout
{items}
{selectedId}
{loading}
{error}
{onSelect}
statusGroups={[
{ label: 'Open', value: 'open' },
{ label: 'Closed', value: 'closed' },
{ label: 'Resolved', value: 'resolved' }
]}
>
{#snippet itemRenderer({ item })}
<div class="issue-item-content">
<div class="issue-subject">{item.subject}</div>
<div class="issue-meta">
<span class="issue-id">#{item.id.slice(0, 7)}</span>
<span class="issue-date">{new Date(item.created_at * 1000).toLocaleDateString()}</span>
</div>
</div>
{/snippet}
{#snippet detailRenderer({ item })}
<div class="issue-detail">
<div class="issue-detail-header">
<h2>{item.subject}</h2>
<div class="issue-actions">
<select
value={item.status}
onchange={(e) => onStatusUpdate(item.id, (e.target as HTMLSelectElement).value)}
>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="resolved">Resolved</option>
</select>
</div>
</div>
<div class="issue-content">
{@html item.content || 'No content'}
</div>
{#if loadingReplies}
<div class="loading">Loading replies...</div>
{:else if issueReplies.length > 0}
<div class="issue-replies">
<h3>Replies</h3>
{#each issueReplies as reply}
<div class="reply">
<div class="reply-author">{reply.author}</div>
<div class="reply-content">{reply.content}</div>
<div class="reply-date">{new Date(reply.created_at * 1000).toLocaleString()}</div>
</div>
{/each}
</div>
{/if}
</div>
{/snippet}
</StatusTabLayout>
<style>
.issue-item-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.issue-subject {
font-weight: 500;
}
.issue-meta {
display: flex;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.issue-detail {
padding: 1rem;
}
.issue-detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.issue-content {
margin: 1rem 0;
line-height: 1.6;
}
.issue-replies {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid var(--border-color);
}
.reply {
padding: 1rem;
margin: 1rem 0;
border: 1px solid var(--border-color);
border-radius: 4px;
}
.reply-author {
font-weight: 500;
margin-bottom: 0.5rem;
}
.reply-content {
margin: 0.5rem 0;
}
.reply-date {
font-size: 0.85rem;
color: var(--text-secondary);
}
</style>

131
src/routes/repos/[npub]/[repo]/components/PRsTab.svelte

@ -0,0 +1,131 @@ @@ -0,0 +1,131 @@
<script lang="ts">
/**
* Pull Requests tab component using hierarchical layout
*/
import StatusTabLayout from './StatusTabLayout.svelte';
export let prs: Array<{
id: string;
subject: string;
content: string;
status: string;
author: string;
created_at: number;
commitId?: string;
kind: number;
}> = [];
export let selectedPR: string | null = null;
export let loading: boolean = false;
export let error: string | null = null;
export let onSelect: (id: string) => void = () => {};
export let onStatusUpdate: (id: string, status: string) => void = () => {};
const items = $derived(prs.map(pr => ({
id: pr.id,
title: pr.subject,
status: pr.status,
...pr
})));
const selectedId = $derived(selectedPR);
</script>
<StatusTabLayout
{items}
{selectedId}
{loading}
{error}
{onSelect}
statusGroups={[
{ label: 'Open', value: 'open' },
{ label: 'Closed', value: 'closed' },
{ label: 'Merged', value: 'merged' }
]}
>
{#snippet itemRenderer({ item })}
<div class="pr-item-content">
<div class="pr-subject">{item.subject}</div>
<div class="pr-meta">
<span class="pr-id">#{item.id.slice(0, 7)}</span>
{#if item.commitId}
<span class="pr-commit">Commit: {item.commitId.slice(0, 7)}</span>
{/if}
<span class="pr-date">{new Date(item.created_at * 1000).toLocaleDateString()}</span>
</div>
</div>
{/snippet}
{#snippet detailRenderer({ item })}
<div class="pr-detail">
<div class="pr-detail-header">
<h2>{item.subject}</h2>
<div class="pr-actions">
<select
value={item.status}
onchange={(e) => onStatusUpdate(item.id, (e.target as HTMLSelectElement).value)}
>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="merged">Merged</option>
</select>
</div>
</div>
<div class="pr-content">
{@html item.content || 'No content'}
</div>
{#if item.commitId}
<div class="pr-commit-info">
<strong>Commit:</strong> {item.commitId}
</div>
{/if}
</div>
{/snippet}
</StatusTabLayout>
<style>
.pr-item-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.pr-subject {
font-weight: 500;
}
.pr-meta {
display: flex;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.pr-detail {
padding: 1rem;
}
.pr-detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.pr-content {
margin: 1rem 0;
line-height: 1.6;
}
.pr-commit-info {
margin-top: 1rem;
padding: 1rem;
background: var(--bg-secondary);
border-radius: 4px;
font-family: monospace;
}
</style>

137
src/routes/repos/[npub]/[repo]/components/PatchesTab.svelte

@ -0,0 +1,137 @@ @@ -0,0 +1,137 @@
<script lang="ts">
/**
* Patches tab component using hierarchical layout
*/
import StatusTabLayout from './StatusTabLayout.svelte';
export let patches: Array<{
id: string;
subject: string;
content: string;
status: string;
author: string;
created_at: number;
[key: string]: any;
}> = [];
export let selectedPatch: string | null = null;
export let loading: boolean = false;
export let error: string | null = null;
export let onSelect: (id: string) => void = () => {};
export let onApply: (id: string) => void = () => {};
export let applying: Record<string, boolean> = {};
const items = $derived(patches.map(patch => ({
id: patch.id,
title: patch.subject,
status: patch.status || 'open',
...patch
})));
const selectedId = $derived(selectedPatch);
</script>
<StatusTabLayout
{items}
{selectedId}
{loading}
{error}
{onSelect}
statusGroups={[
{ label: 'Open', value: 'open' },
{ label: 'Applied', value: 'applied' },
{ label: 'Rejected', value: 'rejected' }
]}
>
{#snippet itemRenderer({ item })}
<div class="patch-item-content">
<div class="patch-subject">{item.subject}</div>
<div class="patch-meta">
<span class="patch-id">#{item.id.slice(0, 7)}</span>
<span class="patch-date">{new Date(item.created_at * 1000).toLocaleDateString()}</span>
</div>
</div>
{/snippet}
{#snippet detailRenderer({ item })}
<div class="patch-detail">
<div class="patch-detail-header">
<h2>{item.subject}</h2>
<div class="patch-actions">
{#if item.status === 'open'}
<button
onclick={() => onApply(item.id)}
disabled={applying[item.id]}
class="apply-button"
>
{applying[item.id] ? 'Applying...' : 'Apply Patch'}
</button>
{/if}
</div>
</div>
<div class="patch-content">
<pre><code>{item.content}</code></pre>
</div>
</div>
{/snippet}
</StatusTabLayout>
<style>
.patch-item-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.patch-subject {
font-weight: 500;
}
.patch-meta {
display: flex;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.patch-detail {
padding: 1rem;
}
.patch-detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.patch-content {
margin: 1rem 0;
}
.patch-content pre {
background: var(--bg-secondary);
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
font-family: monospace;
font-size: 0.9rem;
}
.apply-button {
padding: 0.5rem 1rem;
background: var(--accent-color);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.apply-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

148
src/routes/repos/[npub]/[repo]/components/StatusTabLayout.svelte

@ -0,0 +1,148 @@ @@ -0,0 +1,148 @@
<script lang="ts">
/**
* Status-based tab layout for issues, patches, and PRs
* Groups items by status (open, closed, etc.)
*/
import TabLayout from './TabLayout.svelte';
export let items: Array<{
id: string;
title: string;
status: string;
[key: string]: any;
}> = [];
export let selectedId: string | null = null;
export let loading: boolean = false;
export let error: string | null = null;
export let onSelect: (id: string) => void = () => {};
export let statusGroups: Array<{ label: string; value: string }> = [
{ label: 'Open', value: 'open' },
{ label: 'Closed', value: 'closed' }
];
let selectedItem = $derived(items.find(item => item.id === selectedId) || null);
function groupByStatus() {
const grouped: Record<string, typeof items> = {};
statusGroups.forEach(group => {
grouped[group.value] = [];
});
items.forEach(item => {
const status = item.status || 'open';
if (!grouped[status]) {
grouped[status] = [];
}
grouped[status].push(item);
});
return grouped;
}
const grouped = $derived(groupByStatus());
</script>
<TabLayout {loading} {error}>
{#snippet leftPane()}
<div class="status-groups">
{#each statusGroups as { label, value }}
{#if grouped[value] && grouped[value].length > 0}
<div class="status-group">
<h3 class="status-header">{label} ({grouped[value].length})</h3>
<div class="items-list">
{#each grouped[value] as item}
<div
class="item {selectedId === item.id ? 'selected' : ''}"
onclick={() => onSelect(item.id)}
>
<slot name="itemRenderer" {item}>
<div class="item-title">{item.title}</div>
<div class="item-meta">#{item.id.slice(0, 7)}</div>
</slot>
</div>
{/each}
</div>
</div>
{/if}
{/each}
</div>
{/snippet}
{#snippet rightPanel()}
{#if selectedItem}
<slot name="detailRenderer" item={selectedItem}>
<div class="detail-view">
<h2>{selectedItem.title}</h2>
<pre>{JSON.stringify(selectedItem, null, 2)}</pre>
</div>
</slot>
{/if}
{/snippet}
</TabLayout>
<style>
.status-groups {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.status-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.status-header {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0;
padding: 0.5rem 0;
}
.items-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.item {
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.item:hover {
background: var(--bg-hover);
border-color: var(--accent-color);
}
.item.selected {
background: var(--bg-selected);
border-color: var(--accent-color);
}
.item-title {
font-weight: 500;
margin-bottom: 0.25rem;
}
.item-meta {
font-size: 0.85rem;
color: var(--text-secondary);
}
.detail-view {
padding: 1rem;
}
.detail-view h2 {
margin-top: 0;
}
</style>

73
src/routes/repos/[npub]/[repo]/components/TabLayout.svelte

@ -0,0 +1,73 @@ @@ -0,0 +1,73 @@
<script lang="ts">
/**
* Hierarchical tab layout component
* Provides left-pane/right-panel structure for all tabs
*/
export let leftPane: any = null;
export let rightPanel: any = null;
export let loading: boolean = false;
export let error: string | null = null;
</script>
<div class="tab-layout">
<div class="left-pane">
{#if loading}
<div class="loading">Loading...</div>
{:else if error}
<div class="error">{error}</div>
{:else}
{#if leftPane}
{@render leftPane()}
{/if}
{/if}
</div>
<div class="right-panel">
{#if rightPanel}
{@render rightPanel()}
{:else}
<div class="empty-state">
<p>Select an item to view details</p>
</div>
{/if}
</div>
</div>
<style>
.tab-layout {
display: flex;
height: 100%;
gap: 1rem;
}
.left-pane {
flex: 0 0 300px;
border-right: 1px solid var(--border-color);
overflow-y: auto;
padding: 1rem;
}
.right-panel {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.loading, .error {
padding: 2rem;
text-align: center;
}
.error {
color: var(--accent-error);
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
}
</style>

204
src/routes/repos/[npub]/[repo]/hooks/use-repo-api.ts

@ -0,0 +1,204 @@ @@ -0,0 +1,204 @@
/**
* Repository API hooks
* Centralized API calls for repository operations
*/
import { buildApiHeaders } from '../utils/api-client.js';
import logger from '$lib/services/logger.js';
export interface LoadFilesOptions {
npub: string;
repo: string;
branch: string;
path?: string;
}
export interface LoadFileOptions {
npub: string;
repo: string;
branch: string;
filePath: string;
}
/**
* Load files from repository
*/
export async function loadFiles(options: LoadFilesOptions): Promise<Array<{ name: string; path: string; type: 'file' | 'directory'; size?: number }>> {
const { npub, repo, branch, path = '' } = options;
try {
logger.operation('Loading files', { npub, repo, branch, path });
const url = `/api/repos/${npub}/${repo}/tree?ref=${branch}${path ? `&path=${encodeURIComponent(path)}` : ''}`;
const response = await fetch(url, {
headers: buildApiHeaders()
});
if (!response.ok) {
throw new Error(`Failed to load files: ${response.statusText}`);
}
const data = await response.json();
logger.operation('Files loaded', { npub, repo, count: data.files?.length || 0 });
return data.files || [];
} catch (error) {
logger.error({ error, npub, repo, branch, path }, 'Error loading files');
throw error;
}
}
/**
* Load file content
*/
export async function loadFile(options: LoadFileOptions): Promise<{ content: string; type: string }> {
const { npub, repo, branch, filePath } = options;
try {
logger.operation('Loading file', { npub, repo, branch, filePath });
const url = `/api/repos/${npub}/${repo}/raw?path=${encodeURIComponent(filePath)}&ref=${branch}`;
const response = await fetch(url, {
headers: buildApiHeaders()
});
if (!response.ok) {
throw new Error(`Failed to load file: ${response.statusText}`);
}
const content = await response.text();
const ext = filePath.split('.').pop()?.toLowerCase() || '';
const type = ext === 'md' || ext === 'markdown' ? 'markdown' :
ext === 'adoc' || ext === 'asciidoc' ? 'asciidoc' : 'text';
logger.operation('File loaded', { npub, repo, filePath, size: content.length });
return { content, type };
} catch (error) {
logger.error({ error, npub, repo, branch, filePath }, 'Error loading file');
throw error;
}
}
/**
* Load branches
*/
export async function loadBranches(npub: string, repo: string): Promise<string[]> {
try {
logger.operation('Loading branches', { npub, repo });
const response = await fetch(`/api/repos/${npub}/${repo}/branches`, {
headers: buildApiHeaders()
});
if (!response.ok) {
throw new Error(`Failed to load branches: ${response.statusText}`);
}
const data = await response.json();
logger.operation('Branches loaded', { npub, repo, count: data.branches?.length || 0 });
return data.branches || [];
} catch (error) {
logger.error({ error, npub, repo }, 'Error loading branches');
throw error;
}
}
/**
* Load commit history
*/
export async function loadCommitHistory(
npub: string,
repo: string,
branch: string,
limit: number = 50
): Promise<Array<{ hash: string; message: string; author: string; date: string; files: string[] }>> {
try {
logger.operation('Loading commit history', { npub, repo, branch, limit });
const response = await fetch(`/api/repos/${npub}/${repo}/commits?branch=${branch}&limit=${limit}`, {
headers: buildApiHeaders()
});
if (!response.ok) {
throw new Error(`Failed to load commit history: ${response.statusText}`);
}
const data = await response.json();
logger.operation('Commit history loaded', { npub, repo, count: data.commits?.length || 0 });
return data.commits || [];
} catch (error) {
logger.error({ error, npub, repo, branch }, 'Error loading commit history');
throw error;
}
}
/**
* Load issues
*/
export async function loadIssues(npub: string, repo: string): Promise<Array<any>> {
try {
logger.operation('Loading issues', { npub, repo });
const response = await fetch(`/api/repos/${npub}/${repo}/issues`, {
headers: buildApiHeaders()
});
if (!response.ok) {
throw new Error(`Failed to load issues: ${response.statusText}`);
}
const data = await response.json();
logger.operation('Issues loaded', { npub, repo, count: data.issues?.length || 0 });
return data.issues || [];
} catch (error) {
logger.error({ error, npub, repo }, 'Error loading issues');
throw error;
}
}
/**
* Load pull requests
*/
export async function loadPRs(npub: string, repo: string): Promise<Array<any>> {
try {
logger.operation('Loading PRs', { npub, repo });
const response = await fetch(`/api/repos/${npub}/${repo}/prs`, {
headers: buildApiHeaders()
});
if (!response.ok) {
throw new Error(`Failed to load PRs: ${response.statusText}`);
}
const data = await response.json();
logger.operation('PRs loaded', { npub, repo, count: data.prs?.length || 0 });
return data.prs || [];
} catch (error) {
logger.error({ error, npub, repo }, 'Error loading PRs');
throw error;
}
}
/**
* Load patches
*/
export async function loadPatches(npub: string, repo: string): Promise<Array<any>> {
try {
logger.operation('Loading patches', { npub, repo });
const response = await fetch(`/api/repos/${npub}/${repo}/patches`, {
headers: buildApiHeaders()
});
if (!response.ok) {
throw new Error(`Failed to load patches: ${response.statusText}`);
}
const data = await response.json();
logger.operation('Patches loaded', { npub, repo, count: data.patches?.length || 0 });
return data.patches || [];
} catch (error) {
logger.error({ error, npub, repo }, 'Error loading patches');
throw error;
}
}

78
src/routes/repos/[npub]/[repo]/hooks/use-repo-data.ts

@ -0,0 +1,78 @@ @@ -0,0 +1,78 @@
/**
* Repository data hook
* Manages loading and state for repository data
*/
import { page } from '$app/stores';
import type { RepoState } from '../stores/repo-state.js';
import logger from '$lib/services/logger.js';
export interface RepoPageData {
title?: string;
description?: string;
image?: string;
banner?: string;
repoUrl?: string;
announcement?: any;
gitDomain?: string;
}
/**
* Initialize repository data from page store
*/
export function useRepoData(
state: RepoState,
setPageData: (data: RepoPageData) => void
): void {
// Update pageData from $page when available (client-side)
if (typeof window === 'undefined' || !state.isMounted) return;
try {
const data = $page.data as RepoPageData;
if (data && state.isMounted) {
setPageData(data || {});
logger.debug({ hasAnnouncement: !!data.announcement }, 'Page data loaded');
}
} catch (err) {
if (state.isMounted) {
logger.warn({ error: err }, 'Failed to update pageData');
}
}
}
/**
* Extract repository parameters from page
*/
export function useRepoParams(
state: RepoState,
setNpub: (npub: string) => void,
setRepo: (repo: string) => void
): void {
if (typeof window === 'undefined' || !state.isMounted) return;
try {
const params = $page.params as { npub?: string; repo?: string };
if (params && state.isMounted) {
if (params.npub && params.npub !== state.userPubkey) {
setNpub(params.npub);
}
if (params.repo) {
setRepo(params.repo);
}
}
} catch {
// If $page.params fails, try to parse from URL path
if (!state.isMounted) return;
try {
if (typeof window !== 'undefined') {
const pathParts = window.location.pathname.split('/').filter(Boolean);
if (pathParts[0] === 'repos' && pathParts[1] && pathParts[2] && state.isMounted) {
setNpub(pathParts[1]);
setRepo(pathParts[2]);
}
}
} catch {
// Ignore errors - params will be set eventually
}
}
}

180
src/routes/repos/[npub]/[repo]/stores/repo-state.ts

@ -0,0 +1,180 @@ @@ -0,0 +1,180 @@
/**
* Repository page state management
* Centralized state to prevent memory leaks and improve performance
*/
export interface RepoState {
// Loading states
loading: boolean;
error: string | null;
repoNotFound: boolean;
// File system state
files: Array<{ name: string; path: string; type: 'file' | 'directory'; size?: number }>;
currentPath: string;
currentFile: string | null;
fileContent: string;
fileLanguage: 'markdown' | 'asciidoc' | 'text';
editedContent: string;
hasChanges: boolean;
saving: boolean;
// Branch state
branches: Array<string | { name: string; commit?: any }>;
currentBranch: string | null;
defaultBranch: string | null;
// Commit state
commitMessage: string;
showCommitDialog: boolean;
// User state
userPubkey: string | null;
userPubkeyHex: string | null;
// UI state
activeTab: 'files' | 'history' | 'tags' | 'issues' | 'prs' | 'docs' | 'discussions' | 'patches' | 'releases' | 'code-search';
showRepoMenu: boolean;
// Navigation
pathStack: string[];
// File creation
showCreateFileDialog: boolean;
newFileName: string;
newFileContent: string;
// Branch creation
showCreateBranchDialog: boolean;
newBranchName: string;
newBranchFrom: string | null;
defaultBranchName: string;
// Commit history
commits: Array<{
hash: string;
message: string;
author: string;
date: string;
files: string[];
verification?: {
valid: boolean;
hasSignature?: boolean;
error?: string;
pubkey?: string;
npub?: string;
authorName?: string;
authorEmail?: string;
timestamp?: number;
eventId?: string;
};
}>;
loadingCommits: boolean;
selectedCommit: string | null;
showDiff: boolean;
diffData: Array<{ file: string; additions: number; deletions: number; diff: string }>;
verifyingCommits: Set<string>;
// Tags
tags: Array<{ name: string; hash: string; message?: string; date?: number }>;
selectedTag: string | null;
showCreateTagDialog: boolean;
newTagName: string;
newTagMessage: string;
newTagRef: string;
// Maintainer state
isMaintainer: boolean;
loadingMaintainerStatus: boolean;
allMaintainers: Array<{ pubkey: string; isOwner: boolean }>;
loadingMaintainers: boolean;
maintainersLoaded: boolean;
// Clone state
isRepoCloned: boolean | null;
checkingCloneStatus: boolean;
cloning: boolean;
copyingCloneUrl: boolean;
apiFallbackAvailable: boolean | null;
// Editor state
wordWrap: boolean;
// Component lifecycle
isMounted: boolean;
}
export function createRepoState(): RepoState {
return {
loading: true,
error: null,
repoNotFound: false,
files: [],
currentPath: '',
currentFile: null,
fileContent: '',
fileLanguage: 'text',
editedContent: '',
hasChanges: false,
saving: false,
branches: [],
currentBranch: null,
defaultBranch: null,
commitMessage: '',
showCommitDialog: false,
userPubkey: null,
userPubkeyHex: null,
activeTab: 'files',
showRepoMenu: false,
pathStack: [],
showCreateFileDialog: false,
newFileName: '',
newFileContent: '',
showCreateBranchDialog: false,
newBranchName: '',
newBranchFrom: null,
defaultBranchName: 'master',
commits: [],
loadingCommits: false,
selectedCommit: null,
showDiff: false,
diffData: [],
verifyingCommits: new Set(),
tags: [],
selectedTag: null,
showCreateTagDialog: false,
newTagName: '',
newTagMessage: '',
newTagRef: 'HEAD',
isMaintainer: false,
loadingMaintainerStatus: false,
allMaintainers: [],
loadingMaintainers: false,
maintainersLoaded: false,
isRepoCloned: null,
checkingCloneStatus: false,
cloning: false,
copyingCloneUrl: false,
apiFallbackAvailable: null,
wordWrap: false,
isMounted: true
};
}
/**
* Safely update state only if component is still mounted
*/
export function safeStateUpdate<T>(
isMounted: boolean,
updateFn: () => T
): T | null {
if (!isMounted) return null;
try {
return updateFn();
} catch (err) {
if (isMounted) {
console.warn('State update error (component may be destroying):', err);
}
return null;
}
}

121
src/routes/repos/[npub]/[repo]/utils/repo-announcement.ts

@ -0,0 +1,121 @@ @@ -0,0 +1,121 @@
/**
* Repository announcement utilities
* Extracts and processes announcement data
*/
import type { NostrEvent } from '$lib/types/nostr.js';
export interface RepoAnnouncementData {
name: string;
description: string;
cloneUrls: string[];
maintainers: string[];
ownerPubkey: string;
language?: string;
topics: string[];
website?: string;
isPrivate: boolean;
}
/**
* Extract repository data from announcement event
*/
export function extractRepoData(
announcement: NostrEvent | null | undefined,
fallbackRepo: string
): RepoAnnouncementData {
if (!announcement) {
return {
name: fallbackRepo,
description: '',
cloneUrls: [],
maintainers: [],
ownerPubkey: '',
topics: [],
isPrivate: false
};
}
const name = announcement.tags.find((t: string[]) => t[0] === 'name')?.[1] || fallbackRepo;
const description = announcement.tags.find((t: string[]) => t[0] === 'description')?.[1] || '';
const cloneUrls = announcement.tags
.filter((t: string[]) => t[0] === 'clone')
.flatMap((t: string[]) => t.slice(1))
.filter((url: string) => url && typeof url === 'string') as string[];
const maintainers = announcement.tags
.filter((t: string[]) => t[0] === 'maintainers')
.flatMap((t: string[]) => t.slice(1))
.filter((m: string) => m && typeof m === 'string') as string[];
const ownerPubkey = announcement.pubkey || '';
const language = announcement.tags.find((t: string[]) => t[0] === 'language')?.[1];
const topics = announcement.tags
.filter((t: string[]) => t[0] === 't' && t[1] !== 'private')
.map((t: string[]) => t[1])
.filter((t: string) => t && typeof t === 'string') as string[];
const website = announcement.tags.find((t: string[]) => t[0] === 'website')?.[1];
const isPrivate = announcement.tags.some((t: string[]) =>
(t[0] === 'private' && t[1] === 'true') || (t[0] === 't' && t[1] === 'private')
) || false;
return {
name,
description,
cloneUrls,
maintainers,
ownerPubkey,
language,
topics,
website,
isPrivate
};
}
/**
* Get safe page URL for SSR
*/
export function getSafePageUrl(
pageData: { repoUrl?: string } | null,
fallback?: () => string
): string {
try {
if (pageData?.repoUrl && typeof pageData.repoUrl === 'string' && pageData.repoUrl.trim()) {
return pageData.repoUrl;
}
if (typeof window === 'undefined') {
return '';
}
if (fallback) {
try {
return fallback();
} catch {
return '';
}
}
if (window?.location?.protocol && window?.location?.host && window?.location?.pathname) {
return `${window.location.protocol}//${window.location.host}${window.location.pathname}`;
}
return '';
} catch {
return '';
}
}
/**
* Get Twitter card type based on image availability
*/
export function getTwitterCardType(
banner?: string | null,
image?: string | null
): 'summary_large_image' | 'summary' {
try {
const hasImage = (banner && typeof banner === 'string' && banner.trim()) ||
(image && typeof image === 'string' && image.trim());
return hasImage ? 'summary_large_image' : 'summary';
} catch {
return 'summary';
}
}
Loading…
Cancel
Save