Compare commits

...

79 Commits

Author SHA1 Message Date
Arunavo Ray
4d8d75c8a6 chore: bump version to v2.17.0 2025-06-24 12:13:46 +05:30
Arunavo Ray
e6c4ca0731 feat: implement InlineDestinationEditor for repository destination management and add API support for updating destination organization 2025-06-24 12:11:10 +05:30
Arunavo Ray
f03405b87a feat: add Gitea organization URL support and update button tooltip logic 2025-06-24 11:45:35 +05:30
Arunavo Ray
ce367e3761 feat: implement MirrorDestinationEditor component for customizable organization destination 2025-06-24 11:33:02 +05:30
Arunavo Ray
cfe65cadca feat: add organization destination update API and enhance organization list editing functionality 2025-06-24 11:16:44 +05:30
Arunavo Ray
68108b8383 feat: add personal repositories organization override and update related configurations 2025-06-24 11:02:57 +05:30
Arunavo Ray
d2bec1d56e chore: bump version to v2.16.3 2025-06-20 11:16:27 +05:30
Arunavo Ray
c9404f2674 feat: implement custom 404 page with NotFound component and navigation options 2025-06-20 11:12:09 +05:30
Arunavo Ray
80ef19c634 feat: add openDelay prop to HoverCard in GitHubConfigForm and OrganizationStrategy components 2025-06-20 11:00:36 +05:30
Arunavo Ray
83c924566c feat: add HoverCard component and replace Popover usage in GitHubConfigForm and OrganizationStrategy 2025-06-20 10:58:46 +05:30
Arunavo Ray
7b58df375e chore: bump version to v2.16.2 2025-06-17 17:23:44 +05:30
Arunavo Ray
1d27bd31d8 feat: add bulk actions for repository management with selection support 2025-06-17 17:22:38 +05:30
Arunavo Ray
13d4257c4f refactor: enhance organization card display with status badges and improved layout 2025-06-17 16:52:15 +05:30
Arunavo Ray
818ba77693 chore: bump version to v2.16.1 2025-06-17 15:50:42 +05:30
Arunavo Ray
056970e577 refactor: improve repository owner handling and mirror strategy in Gitea integration 2025-06-17 15:44:04 +05:30
Arunavo Ray
65ea73e238 refactor: update label for starred repositories organization for consistency 2025-06-17 15:32:43 +05:30
Arunavo Ray
398f00aceb chore: bump version to v2.16.0 2025-06-17 15:16:10 +05:30
Arunavo Ray
50972713a3 refactor: improve version comparison logic in health API 2025-06-17 15:11:05 +05:30
Arunavo Ray
fbf3033455 refactor: remove ConnectionsForm and useMirror hook; update issue mirroring logic for starred repos 2025-06-17 15:06:54 +05:30
Arunavo Ray
cc4d8dabbc refactor: enhance starred repos content selection and improve layout 2025-06-17 14:54:23 +05:30
Arunavo Ray
8bba3d3521 chore: bump version to v2.15.0 2025-06-17 14:37:47 +05:30
Arunavo Ray
be63555e5c refactor: update titles and descriptions in strategyConfig for clarity 2025-06-17 14:34:17 +05:30
Arunavo Ray
32a906369f refactor: remove unused Building2 import in OrganizationConfiguration component 2025-06-17 14:28:49 +05:30
Arunavo Ray
064474fd13 refactor: remove unused tooltip imports in OrganizationStrategy component 2025-06-17 14:27:38 +05:30
Arunavo Ray
2ac933b599 refactor: streamline layout and improve tooltip descriptions in OrganizationConfiguration component 2025-06-17 14:26:52 +05:30
Arunavo Ray
403fe08bae refactor: update icons in GitHubMirrorSettings and OrganizationConfiguration components for improved clarity 2025-06-17 14:16:15 +05:30
Arunavo Ray
23c7ff7349 refactor: enhance layout and flexibility in GitHubConfigForm and GiteaConfigForm components 2025-06-17 14:05:10 +05:30
Arunavo Ray
3169af44cb refactor: improve layout and spacing in GitHubMirrorSettings component 2025-06-17 13:49:53 +05:30
Arunavo Ray
c1d93dbbc6 feat: enhance GitHubMirrorSettings with improved layout and metadata options 2025-06-17 13:43:17 +05:30
Arunavo Ray
047719cde9 feat: add OrganizationConfiguration component and integrate it into GiteaConfigForm 2025-06-17 13:20:03 +05:30
Arunavo Ray
13d4b03541 refactor: simplify OrganizationStrategy component by removing unused imports and details 2025-06-17 13:12:12 +05:30
Arunavo Ray
f07ae220b0 refactor: clean up imports and improve layout in OrganizationStrategy component 2025-06-17 12:59:29 +05:30
Arunavo Ray
01647445f2 Improved layout in larger screens 2025-06-17 12:48:38 +05:30
Arunavo Ray
13cbf86309 Updated Layout 2025-06-17 12:42:32 +05:30
Arunavo Ray
792096d209 Updates to Organisation Strategy Layouts 2025-06-17 11:39:41 +05:30
Arunavo Ray
e94de5c9ca fix: mirror to one org issue 2025-06-17 11:35:09 +05:30
Arunavo Ray
b3f42624d8 chore: bump version to v2.14.0 2025-06-17 10:34:59 +05:30
Arunavo Ray
d79e4fecf4 Updated Docker compose dev 2025-06-17 10:30:33 +05:30
Arunavo Ray
eb78f959c7 Updated Docs 2025-06-17 09:47:46 +05:30
Arunavo Ray
51e536c317 feat: add @radix-ui/react-accordion dependency 2025-06-17 09:35:14 +05:30
Arunavo Ray
7af1f6da17 fix: implement proper mirror strategies for starred and org repos 2025-06-17 09:26:26 +05:30
Arunavo Ray
c7e310b340 updated instructions 2025-06-16 00:35:48 +05:30
Arunavo Ray
23cfa45d89 chore: bump version to v2.13.2 2025-06-16 00:33:28 +05:30
Arunavo Ray
b1346e8c77 Updated Docs and Readme 2025-06-16 00:28:55 +05:30
Arunavo Ray
6e673249dc Docs Design updated 2025-06-16 00:01:45 +05:30
Arunavo Ray
ee801f5d0e chore: bump version to v2.13.1
- Updated package.json version from 2.13.0 to 2.13.1
- Added CHANGELOG.md entry for v2.13.1 with Docker workflow improvements
- Includes Docker Scout migration, CI/CD enhancements, and package updates
2025-06-15 23:06:05 +05:30
Arunavo Ray
caf680d999 fix: add wait step for image availability in Docker build workflow 2025-06-15 15:10:48 +05:30
Arunavo Ray
214599a5fd Dont fail workflow on security issues 2025-06-15 15:03:54 +05:30
Arunavo Ray
9e2285d614 fix: update Docker Scout image references and add wait step for image availability 2025-06-15 14:43:31 +05:30
Arunavo Ray
7f7e510400 fix: resolve Docker Scout image reference issues
- Use specific SHA-based image tags instead of multi-line tags output
- Add separate Docker Scout steps for push vs pull request workflows
- Use local image reference for PR scanning (local://gitea-mirror:scan)
- Optimize PR builds to single platform (linux/amd64) for faster scanning
- Maintain multi-platform builds for production pushes
2025-06-15 14:38:25 +05:30
Arunavo Ray
d1aa8810f7 fix: update Docker Scout action to use the latest version 2025-06-15 14:35:35 +05:30
Arunavo Ray
bfa4b4034c feat: add Docker Hub authentication for Docker Scout
- Add optional Docker Hub login for enhanced vulnerability data
- Use continue-on-error to make Docker Hub auth optional
- Requires DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets for full functionality
2025-06-15 14:23:44 +05:30
Arunavo Ray
8fbde95f92 feat: consolidate Docker workflows into comprehensive build, push & security scan
- Merge docker-build.yml and docker-scan.yml into single efficient workflow
- Add comprehensive Docker Scout security analysis with multiple commands
- Include vulnerability scanning, recommendations, and policy evaluation
- Add comparison to latest for pull requests
- Maintain SARIF output for GitHub Security tab integration
- Add proper path-based triggers and scheduled weekly scans
- Remove redundant docker-scan.yml workflow
2025-06-15 14:20:48 +05:30
Arunavo Ray
00fb66baa7 fix: add workflow file to Docker scan triggers to enable testing 2025-06-15 14:15:27 +05:30
Arunavo Ray
5fec1e6a58 fix: update Docker Scout action to specific version v1.18.1 2025-06-15 14:14:16 +05:30
Arunavo Ray
2ec55c6070 Migrate from Trivy to Docker scout 2025-06-15 14:11:45 +05:30
Arunavo Ray
546bda8514 Updated packages 2025-06-15 14:04:12 +05:30
Arunavo Ray
d05847dfe8 Wrong bun verison 2025-06-15 13:52:45 +05:30
Arunavo Ray
6551ea719c fix: update Bun base image and enhance security scanning
- Update Bun from 1.2.14 to 1.2.18 to address CVE-2025-22874
- Pin Trivy action to stable version (0.28.0)
- Add SARIF output for GitHub Security tab integration
- Set ignore-unfixed to false for comprehensive vulnerability detection
- Add security-events permission for uploading scan results
- Include fallback table output on scan failures
2025-06-15 13:48:58 +05:30
Arunavo Ray
ae57b1b320 docs: update CHANGELOG for v2.13.0 release 2025-06-15 13:33:44 +05:30
Arunavo Ray
4d3ad2a337 chore: bump version to 2.13.0 2025-06-15 13:32:35 +05:30
Arunavo Ray
e9c12bb9ff feat: enhance API config handling by adding mapping functions for UI and database structures 2025-06-15 13:31:06 +05:30
Arunavo Ray
42314ab0e3 feat: add collapsible component and integrate it into OrganizationStrategy for improved UI 2025-06-15 13:08:39 +05:30
Arunavo Ray
1be53bfa87 refactor: simplify imports and enhance styling in AutomationSettings component 2025-06-15 13:00:08 +05:30
Arunavo Ray
e8d48376a0 Updated Automation & Maintainence 2025-06-15 12:56:25 +05:30
Arunavo Ray
0cdb386f56 Re-Organsied options 2025-06-15 12:35:26 +05:30
Arunavo Ray
7456fe3fae Fixed contrast issues 2025-06-15 12:22:38 +05:30
Arunavo Ray
f4df7c3d19 Added some basic options 2025-06-15 12:15:14 +05:30
Arunavo Ray
544b60f881 refactor: update Card components to use self-start class for consistent alignment 2025-06-12 15:29:47 +05:30
Arunavo Ray
2eda800a7c refactor: update test files to use bun:test and remove vitest configuration 2025-06-12 10:24:55 +05:30
Arunavo Ray
51de51baa0 feat: add permissions section to workflows for consistent access control 2025-06-12 10:13:41 +05:30
Arunavo Ray
0d60c2fdf1 feat: implement createSecureErrorResponse for consistent error handling across API routes 2025-06-12 09:50:43 +05:30
Arunavo Ray
df8dac0e9b feat: add ConnectionsForm and ScheduleAndCleanupForm components with configuration forms 2025-06-11 22:17:31 +05:30
Arunavo Ray
8e0c31fbb9 feat: add ScrollArea and Separator components with Radix UI integration 2025-06-11 22:16:30 +05:30
Arunavo Ray
2c815b13f0 Added accordion 2025-06-11 21:51:39 +05:30
Arunavo Ray
bbd49d7d52 Tabs Config 2025-06-11 21:43:43 +05:30
Arunavo Ray
8f62da4572 Replace logo in LoginForm, SignupForm, and Header components with light and dark mode images 2025-06-11 20:36:07 +05:30
Arunavo Ray
0f671a4088 feat: add support for mirroring wiki pages in configuration 2025-06-11 19:48:24 +05:30
Arunavo Ray
108408be81 fix: update Proxmox VE installation script references in README files 2025-06-05 23:27:56 +05:30
98 changed files with 7280 additions and 2064 deletions

View File

@@ -0,0 +1,4 @@
Evaluate all the updates being made.
Update CHANGELOG.md
Use the chnages in the git log to determine if its a major, minor or a patch release.
Update the package.json first before you push the tag.

View File

@@ -19,6 +19,7 @@ JWT_SECRET=change-this-to-a-secure-random-string-in-production
# SKIP_FORKS=false
# PRIVATE_REPOSITORIES=false
# MIRROR_ISSUES=false
# MIRROR_WIKI=false
# MIRROR_STARRED=false
# MIRROR_ORGANIZATIONS=false
# PRESERVE_ORG_STRUCTURE=false

Binary file not shown.

Before

Width:  |  Height:  |  Size: 945 KiB

After

Width:  |  Height:  |  Size: 891 KiB

BIN
.github/assets/logo-no-bg.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -12,6 +12,10 @@ on:
- 'README.md'
- 'docs/**'
permissions:
contents: read
actions: read
jobs:
build-and-test:
name: Build and Test Astro Project

View File

@@ -1,14 +1,29 @@
name: Build and Push Docker Images
name: Docker Build, Push & Security Scan
on:
push:
branches: [main]
tags: ['v*']
paths:
- 'Dockerfile'
- '.dockerignore'
- 'package.json'
- 'bun.lock*'
- '.github/workflows/docker-build.yml'
pull_request:
paths:
- 'Dockerfile'
- '.dockerignore'
- 'package.json'
- 'bun.lock*'
- '.github/workflows/docker-build.yml'
schedule:
- cron: '0 0 * * 0' # Weekly security scan on Sunday at midnight
env:
REGISTRY: ghcr.io
IMAGE: ${{ github.repository }}
SHA: ${{ github.event.pull_request.head.sha || github.event.after }}
jobs:
docker:
@@ -17,19 +32,37 @@ jobs:
permissions:
contents: write
packages: write
security-events: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ env.SHA }}
- uses: docker/setup-buildx-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- uses: docker/login-action@v3
- name: Log into registry
uses: docker/login-action@v3
if: github.event_name != 'pull_request'
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Login to Docker Hub for Docker Scout (optional - provides better vulnerability data)
# Add DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets to enable this
- name: Log into Docker Hub
uses: docker/login-action@v3
continue-on-error: true
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Extract version from tag if present
- name: Extract version from tag
id: tag_version
@@ -42,12 +75,88 @@ jobs:
echo "No version tag, using 'latest'"
fi
- uses: docker/build-push-action@v5
# Extract metadata for Docker
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE }}
labels: |
org.opencontainers.image.revision=${{ env.SHA }}
tags: |
type=edge,branch=$repo.default_branch
type=semver,pattern=v{{version}}
type=sha,prefix=,suffix=,format=short
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=${{ steps.tag_version.outputs.VERSION }}
# Build and push Docker image
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
push: ${{ github.event_name != 'pull_request' }}
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }}
${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.tag_version.outputs.VERSION }}
load: ${{ github.event_name == 'pull_request' }}
tags: ${{ github.event_name == 'pull_request' && 'gitea-mirror:scan' || steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Wait for image to be available in registry
- name: Wait for image availability
if: github.event_name != 'pull_request'
run: |
echo "Waiting for image to be available in registry..."
sleep 5
# Docker Scout comprehensive security analysis
- name: Docker Scout - Vulnerability Analysis & Recommendations
uses: docker/scout-action@v1
if: github.event_name != 'pull_request'
with:
command: cves,recommendations
image: ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
sarif-file: scout-results.sarif
summary: true
exit-code: false
only-severities: critical,high
write-comment: true
github-token: ${{ secrets.GITHUB_TOKEN }}
# Docker Scout for Pull Requests (using local image)
- name: Docker Scout - Vulnerability Analysis (PR)
uses: docker/scout-action@v1
if: github.event_name == 'pull_request'
with:
command: cves,recommendations
image: local://gitea-mirror:scan
sarif-file: scout-results.sarif
summary: true
exit-code: false
only-severities: critical,high
write-comment: true
github-token: ${{ secrets.GITHUB_TOKEN }}
# Compare to latest for PRs and pushes
- name: Docker Scout - Compare to Latest
uses: docker/scout-action@v1
if: github.event_name == 'pull_request'
with:
command: compare
image: local://gitea-mirror:scan
to: ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
ignore-unchanged: true
only-severities: critical,high
write-comment: true
github-token: ${{ secrets.GITHUB_TOKEN }}
# Upload security scan results to GitHub Security tab
- name: Upload Docker Scout scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
if: always()
continue-on-error: true
with:
sarif_file: scout-results.sarif

View File

@@ -1,53 +0,0 @@
name: Docker Security Scan
on:
push:
branches: [ main ]
paths:
- 'Dockerfile'
- '.dockerignore'
- 'package.json'
- 'bun.lock*'
pull_request:
branches: [ main ]
paths:
- 'Dockerfile'
- '.dockerignore'
- 'package.json'
- 'bun.lock*'
schedule:
- cron: '0 0 * * 0' # Run weekly on Sunday at midnight
jobs:
scan:
name: Scan Docker Image
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Build Docker image
uses: docker/build-push-action@v5
with:
context: .
push: false
load: true
tags: gitea-mirror:scan
# Disable GitHub Actions cache for this workflow
no-cache: true
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: gitea-mirror:scan
format: 'table'
exit-code: '1'
ignore-unfixed: true
vuln-type: 'os,library'
severity: 'CRITICAL,HIGH'

View File

@@ -5,6 +5,159 @@ All notable changes to the Gitea Mirror project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [2.17.0] - 2025-06-24
### Added
- Custom destination control for individual repositories with inline editing
- Organization-level destination overrides with visual destination editor
- Personal repositories organization override configuration option
- Visual indicators for starred repositories (⭐ icon) in repository list
- Repository-level destination override API endpoint
- Destination customization priority hierarchy system
- "View on Gitea" buttons for organizations with smart tooltip states
### Changed
- Enhanced repository table with destination column showing both GitHub org and Gitea destination
- Updated organization cards to display custom destinations with visual indicators
- Improved getGiteaRepoOwnerAsync to support repository-level destination overrides
### Improved
- Better visual feedback for custom destinations with badges and inline editing
- Enhanced user experience with hover-based edit buttons
- Comprehensive destination customization documentation in README
## [2.16.3] - 2025-06-20
### Added
- Custom 404 error page with helpful navigation links
- HoverCard components for better UX in configuration forms
### Improved
- Replaced popover components with hover cards for information tooltips
- Enhanced user experience with responsive hover interactions
## [2.16.2] - 2025-06-17
### Added
- Bulk actions for repository management with selection support
### Improved
- Enhanced organization card display with status badges and improved layout
## [2.16.1] - 2025-06-17
### Improved
- Improved repository owner handling and mirror strategy in Gitea integration
- Updated label for starred repositories organization for consistency
## [2.16.0] - 2025-06-17
### Added
- Enhanced OrganizationConfiguration component with improved layout and metadata options
- New GitHubMirrorSettings component with better organization and flexibility
- Enhanced starred repositories content selection and improved layout
### Improved
- Enhanced configuration interface layout and spacing across multiple components
- Streamlined OrganizationStrategy component with cleaner imports and better organization
- Improved responsive layout for larger screens in configuration forms
- Better icon usage and clarity in configuration components
- Enhanced tooltip descriptions and component organization
- Improved version comparison logic in health API
- Enhanced issue mirroring logic for starred repositories
### Fixed
- Fixed mirror to single organization functionality
- Resolved organization strategy layout issues
- Cleaned up unused imports across multiple components
### Refactored
- Simplified component structures by removing unused imports and dependencies
- Enhanced layout flexibility in GitHubConfigForm and GiteaConfigForm components
- Improved component organization and code clarity
- Removed ConnectionsForm and useMirror hook for better code organization
## [2.14.0] - 2025-06-17
### Added
- Enhanced UI components with @radix-ui/react-accordion dependency for improved configuration interface
### Fixed
- Mirror strategies now properly route repositories based on selected strategy
- Starred repositories now correctly go to the designated starred repos organization
- Organization routing for single-org and flat-user strategies
### Improved
- Documentation now explains all three mirror strategies (preserve, single-org, flat-user)
- Added detailed mirror strategy configuration guide
- Updated CLAUDE.md with mirror strategy architecture information
- Enhanced Docker Compose development configuration
## [2.13.2] - 2025-06-15
### Improved
- Enhanced documentation design and layout
- Updated README with improved formatting and content
## [2.13.1] - 2025-06-15
### Added
- Docker Hub authentication for Docker Scout security scanning
- Comprehensive Docker workflow consolidation with build, push & security scan
### Improved
- Enhanced CI/CD pipeline reliability with better error handling
- Updated Bun base image to latest version for improved security
- Migrated from Trivy to Docker Scout for more comprehensive security scanning
- Enhanced Docker workflow with wait steps for image availability
### Fixed
- Docker Scout action integration issues and image reference problems
- Workflow reliability improvements with proper error handling
- Security scanning workflow now continues on security issues without failing the build
### Changed
- Updated package dependencies to latest versions
- Consolidated multiple Docker workflows into single comprehensive workflow
- Enhanced security scanning with Docker Scout integration
## [2.13.0] - 2025-06-15
### Added
- Enhanced Configuration Interface with collapsible components and improved organization strategy UI
- Wiki Mirroring Support in configuration settings
- Auto-Save Functionality for all config forms, eliminating manual save buttons
- Live Refresh functionality with configuration status hooks and enhanced UI components
- Enhanced API Config Handling with mapping functions for UI and database structures
- Secure Error Responses with createSecureErrorResponse for consistent error handling
- Automatic Database Cleanup feature with configuration options and API support
- Enhanced Job Recovery with improved database schema and recovery mechanisms
- Fork tags to repository UI and enhanced organization cards with repository breakdown
- Skeleton loaders and better loading state management across the application
### Improved
- Navigation context and component loading states across the application
- Card components alignment and styling consistency
- Error logging and structured error message parsing
- HTTP client standardization across the application
- Database initialization and management processes
- Visual consistency with updated icons and custom logo integration
### Fixed
- Repository mirroring status inconsistencies
- Organizations getting stuck on mirroring status when empty
- JSON parsing errors and improved error handling
- Broken documentation links in README
- Various UI contrast and alignment issues
### Changed
- Migrated testing framework to Bun and updated test configurations
- Implemented graceful shutdown and enhanced job recovery capabilities
- Replaced SiGitea icons with custom logo
- Updated various dependencies for improved stability and performance
## [2.12.0] - 2025-01-27
### Fixed

128
CLAUDE.md Normal file
View File

@@ -0,0 +1,128 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Gitea Mirror is a web application that automatically mirrors repositories from GitHub to self-hosted Gitea instances. It uses Astro for SSR, React for UI, SQLite for data storage, and Bun as the JavaScript runtime.
## Essential Commands
### Development
```bash
bun run dev # Start development server (port 3000)
bun run build # Build for production
bun run preview # Preview production build
```
### Testing
```bash
bun test # Run all tests
bun test:watch # Run tests in watch mode
bun test:coverage # Run tests with coverage
```
### Database Management
```bash
bun run init-db # Initialize database
bun run reset-users # Reset user accounts (development)
bun run cleanup-db # Remove database files
```
### Production
```bash
bun run start # Start production server
```
## Architecture & Key Concepts
### Technology Stack
- **Frontend**: Astro (SSR) + React + Tailwind CSS v4 + Shadcn UI
- **Backend**: Bun runtime + SQLite + Drizzle ORM
- **APIs**: GitHub (Octokit) and Gitea APIs
- **Auth**: JWT tokens with bcryptjs password hashing
### Project Structure
- `/src/pages/api/` - API endpoints (Astro API routes)
- `/src/components/` - React components organized by feature
- `/src/lib/db/` - Database queries and schema (Drizzle ORM)
- `/src/hooks/` - Custom React hooks for data fetching
- `/data/` - SQLite database storage location
### Key Architectural Patterns
1. **API Routes**: All API endpoints follow the pattern `/api/[resource]/[action]` and use `createSecureErrorResponse` for consistent error handling:
```typescript
import { createSecureErrorResponse } from '@/lib/utils/error-handler';
export async function POST({ request }: APIContext) {
try {
// Implementation
} catch (error) {
return createSecureErrorResponse(error);
}
}
```
2. **Database Queries**: Located in `/src/lib/db/queries/` organized by domain (users, repositories, etc.)
3. **Real-time Updates**: Server-Sent Events (SSE) endpoint at `/api/events` for live dashboard updates
4. **Authentication Flow**:
- First user signup creates admin account
- JWT tokens stored in cookies
- Protected routes check auth via `getUserFromCookie()`
5. **Mirror Process**:
- Discovers repos from GitHub (user/org)
- Creates/updates mirror in Gitea
- Tracks status in database
- Supports scheduled automatic mirroring
6. **Mirror Strategies**: Three ways to organize repositories in Gitea:
- **preserve**: Maintains GitHub structure (default)
- **single-org**: All repos go to one organization
- **flat-user**: All repos go under user account
- Starred repos always go to separate organization (starredReposOrg)
- Routing logic in `getGiteaRepoOwner()` function
### Database Schema (SQLite)
- `users` - User accounts and authentication
- `configs` - GitHub/Gitea connection settings
- `repositories` - Repository mirror status and metadata
- `organizations` - Organization structure preservation
- `mirror_jobs` - Scheduled mirror operations
- `events` - Activity log and notifications
### Testing Approach
- Uses Bun's native test runner (`bun:test`)
- Test files use `.test.ts` or `.test.tsx` extension
- Setup file at `/src/tests/setup.bun.ts`
- Mock utilities available for API testing
### Development Tips
- Environment variables in `.env` (copy from `.env.example`)
- JWT_SECRET auto-generated if not provided
- Database auto-initializes on first run
- Use `bun run dev:clean` for fresh database start
- Tailwind CSS v4 configured with Vite plugin
### Common Tasks
**Adding a new API endpoint:**
1. Create file in `/src/pages/api/[resource]/[action].ts`
2. Use `createSecureErrorResponse` for error handling
3. Add corresponding database query in `/src/lib/db/queries/`
4. Update types in `/src/types/` if needed
**Adding a new component:**
1. Create in appropriate `/src/components/[feature]/` directory
2. Use Shadcn UI components from `/src/components/ui/`
3. Follow existing naming patterns (e.g., `RepositoryCard`, `ConfigTabs`)
**Modifying database schema:**
1. Update schema in `/src/lib/db/schema.ts`
2. Run `bun run init-db` to recreate database
3. Update related queries in `/src/lib/db/queries/`

View File

@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.4
FROM oven/bun:1.2.14-alpine AS base
FROM oven/bun:1.2.16-alpine AS base
WORKDIR /app
RUN apk add --no-cache libc6-compat python3 make g++ gcc wget sqlite openssl

100
README.md
View File

@@ -1,5 +1,5 @@
<p align="center">
<img src=".github/assets/logo.png" alt="Gitea Mirror Logo" width="120" />
<img src=".github/assets/logo-no-bg.png" alt="Gitea Mirror Logo" width="120" />
<h1>Gitea Mirror</h1>
<p><i>A modern web app for automatically mirroring repositories from GitHub to your self-hosted Gitea.</i></p>
<p align="center">
@@ -21,7 +21,7 @@ bun run setup && bun run dev
# Using LXC Containers
# For Proxmox VE (online) - Community script by Tobias ([CrazyWolf13](https://github.com/CrazyWolf13))
curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVED/main/install/gitea-mirror-install.sh | bash
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/gitea-mirror.sh)"
# For local testing (offline-friendly)
sudo LOCAL_REPO_DIR=~/Development/gitea-mirror ./scripts/gitea-mirror-lxc-local.sh
@@ -36,9 +36,10 @@ See the [LXC Container Deployment Guide](scripts/README-lxc.md).
## ✨ Features
- 🔁 Sync public, private, or starred GitHub repos to Gitea
- 🏢 Mirror entire organizations with structure preservation
- 🏢 Mirror entire organizations with flexible organization strategies
- 🎯 Custom destination control for both organizations and individual repositories
- 🐞 Optional mirroring of issues and labels
- 🌟 Mirror your starred repositories
- 🌟 Mirror your starred repositories to a dedicated organization
- 🕹️ Modern user interface with toast notifications and smooth experience
- 🧠 Smart filtering and job queue with detailed logs
- 🛠️ Works with personal access tokens (GitHub + Gitea)
@@ -167,36 +168,27 @@ docker compose up -d
See [Docker build documentation](./scripts/README-docker.md) for more details.
##### Using LXC Containers
##### Using LXC Containers (Proxmox VE)
Gitea Mirror offers two deployment options for LXC containers:
**1. Proxmox VE (online, recommended for production)**
For Proxmox VE users, Gitea Mirror can be deployed using the community-maintained script:
```bash
# One-command installation on Proxmox VE
# Uses the community-maintained script by Tobias ([CrazyWolf13](https://github.com/CrazyWolf13))
# at [community-scripts/ProxmoxVED](https://github.com/community-scripts/ProxmoxVED)
curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVED/main/install/gitea-mirror-install.sh | bash
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/gitea-mirror.sh)"
```
**2. Local testing (offline-friendly, works on developer laptops)**
This community script:
- Creates a privileged Alpine Linux LXC container
- Installs Bun runtime environment
- Clones and builds Gitea Mirror
- Configures a systemd service for automatic startup
- Sets up the application to run on port 4321
```bash
# Download the script
curl -fsSL https://raw.githubusercontent.com/arunavo4/gitea-mirror/main/scripts/gitea-mirror-lxc-local.sh -o gitea-mirror-lxc-local.sh
chmod +x gitea-mirror-lxc-local.sh
> [!NOTE]
> The script is maintained by the [Community Scripts for Proxmox VE](https://community-scripts.github.io/ProxmoxVE/) project.
> For more information, visit the [Gitea Mirror script documentation](https://community-scripts.github.io/ProxmoxVE/scripts?id=gitea-mirror).
# Run with your local repo directory
sudo LOCAL_REPO_DIR=~/Development/gitea-mirror ./gitea-mirror-lxc-local.sh
```
Both scripts:
- Set up a privileged Ubuntu 22.04 LXC container
- Install Bun runtime environment
- Build the application
- Configure a systemd service
- Start the service automatically
After installation, access Gitea Mirror at `http://<container-ip>:4321`
The application includes a health check endpoint at `/api/health` for monitoring.
@@ -326,6 +318,57 @@ Key configuration options include:
> [!IMPORTANT]
> **SQLite is the only database required for Gitea Mirror**, handling both data storage and real-time event notifications.
### Mirror Strategies & Destination Customization
Gitea Mirror offers three flexible strategies for organizing your repositories in Gitea, with fine-grained control over destinations:
#### 1. **Preserve GitHub Structure** (Default)
- Personal repositories → Your Gitea username (or custom organization)
- Organization repositories → Same organization name in Gitea (with individual overrides)
- Maintains the exact structure from GitHub with optional customization
#### 2. **Single Organization**
- All repositories → One designated organization
- Simplifies management by consolidating everything
- Requires specifying a destination organization name
#### 3. **Flat User Structure**
- All repositories → Your Gitea user account
- No organizations needed
- Simplest approach for personal use
#### Destination Customization
**Organization-Level Overrides:**
- Click the edit button on any organization card to set a custom destination
- All repositories from that GitHub organization will be mirrored to your specified Gitea organization
- Visual indicators show when custom destinations are active
**Repository-Level Overrides:**
- Fine-tune individual repository destinations in the repository table
- Click the edit button in the "Destination" column to customize where a specific repo is mirrored
- Overrides organization-level settings for maximum flexibility
- Starred repositories display a ⭐ icon and always go to the configured starred repos organization
**Priority Hierarchy:**
1. Starred repositories → Always go to `starredReposOrg` (not editable)
2. Repository-level custom destination (highest priority for non-starred)
3. Organization-level custom destination
4. Personal repos override (for non-organization repos)
5. Default strategy rules (lowest priority)
> [!NOTE]
> **Starred Repositories**: Repositories you've starred on GitHub are automatically organized into a separate organization (default: "starred") and cannot have custom destinations. They're marked with a ⭐ icon for easy identification.
> [!TIP]
> **Example Use Cases**:
> - Mirror personal repos to `personal-archive` organization
> - Redirect `work-org` repos to `company-mirror` in Gitea
> - Override a single important repo to go to a special organization
> - Keep `company-org` repos in their own `company-org` organization
> - Override `community-scripts` to go to `community-mirrors` organization
> - This gives you complete control while maintaining GitHub's structure as the default
## 🚀 Development
### Local Development Setup
@@ -363,13 +406,13 @@ docker run -d \
-e USER_UID=1000 \
-e USER_GID=1000 \
-e GITEA__database__DB_TYPE=sqlite3 \
-e GITEA__database__PATH=/data/gitea.db \
-e GITEA__database__PATH=/data/gitea/gitea.db \
-e GITEA__server__DOMAIN=localhost \
-e GITEA__server__ROOT_URL=http://localhost:3001/ \
-e GITEA__server__SSH_DOMAIN=localhost \
-e GITEA__server__SSH_PORT=2222 \
-e GITEA__server__START_SSH_SERVER=true \
-e GITEA__security__INSTALL_LOCK=true \
-e GITEA__security__INSTALL_LOCK=false \
-e GITEA__service__DISABLE_REGISTRATION=false \
gitea/gitea:latest
```
@@ -591,3 +634,4 @@ sudo chmod -R 755 /host/path/to/data
- [Octokit](https://github.com/octokit/rest.js/) - GitHub REST API client for JavaScript
- [Shadcn UI](https://ui.shadcn.com/) - For the beautiful UI components
- [Astro](https://astro.build/) - For the excellent web framework
- [Community Scripts](https://community-scripts.github.io/ProxmoxVE/) - For the Proxmox VE installation script maintained by [CrazyWolf13](https://github.com/CrazyWolf13)

425
bun.lock
View File

@@ -4,47 +4,54 @@
"": {
"name": "gitea-mirror",
"dependencies": {
"@astrojs/mdx": "^4.2.6",
"@astrojs/node": "^9.2.1",
"@astrojs/react": "^4.2.7",
"@octokit/rest": "^21.1.1",
"@radix-ui/react-avatar": "^1.1.9",
"@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-radio-group": "^1.3.6",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-slot": "^1.2.2",
"@astrojs/check": "^0.9.4",
"@astrojs/mdx": "^4.3.0",
"@astrojs/node": "^9.2.2",
"@astrojs/react": "^4.3.0",
"@octokit/rest": "^22.0.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.6",
"@tailwindcss/vite": "^4.1.7",
"@tanstack/react-virtual": "^3.13.8",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.10",
"@tanstack/react-virtual": "^3.13.10",
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"astro": "^5.7.13",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"astro": "^5.9.3",
"bcryptjs": "^3.0.2",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"drizzle-orm": "^0.43.1",
"drizzle-orm": "^0.44.2",
"fuse.js": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.511.0",
"lucide-react": "^0.515.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"sonner": "^2.0.3",
"tailwind-merge": "^3.3.0",
"tailwindcss": "^4.1.7",
"tw-animate-css": "^1.3.0",
"sonner": "^2.0.5",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.10",
"tw-animate-css": "^1.3.4",
"typescript": "^5.8.3",
"uuid": "^11.1.0",
"zod": "^3.25.7",
"zod": "^3.25.64",
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
@@ -52,10 +59,10 @@
"@types/bcryptjs": "^3.0.0",
"@types/jsonwebtoken": "^9.0.9",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.4.1",
"@vitejs/plugin-react": "^4.5.2",
"jsdom": "^26.1.0",
"tsx": "^4.19.4",
"vitest": "^3.1.4",
"tsx": "^4.20.3",
"vitest": "^3.2.3",
},
},
},
@@ -66,10 +73,14 @@
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="],
"@astrojs/compiler": ["@astrojs/compiler@2.12.0", "", {}, "sha512-7bCjW6tVDpUurQLeKBUN9tZ5kSv5qYrGmcn0sG0IwacL7isR2ZbyyA3AdZ4uxsuUFOS2SlgReTH7wkxO6zpqWA=="],
"@astrojs/check": ["@astrojs/check@0.9.4", "", { "dependencies": { "@astrojs/language-server": "^2.15.0", "chokidar": "^4.0.1", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "dist/bin.js" } }, "sha512-IOheHwCtpUfvogHHsvu0AbeRZEnjJg3MopdLddkJE70mULItS/Vh37BHcI00mcOJcH1vhD3odbpvWokpxam7xA=="],
"@astrojs/compiler": ["@astrojs/compiler@2.12.2", "", {}, "sha512-w2zfvhjNCkNMmMMOn5b0J8+OmUaBL1o40ipMvqcG6NRpdC+lKxmTi48DT8Xw0SzJ3AfmeFLB45zXZXtmbsjcgw=="],
"@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
"@astrojs/language-server": ["@astrojs/language-server@2.15.4", "", { "dependencies": { "@astrojs/compiler": "^2.10.3", "@astrojs/yaml2ts": "^0.2.2", "@jridgewell/sourcemap-codec": "^1.4.15", "@volar/kit": "~2.4.7", "@volar/language-core": "~2.4.7", "@volar/language-server": "~2.4.7", "@volar/language-service": "~2.4.7", "fast-glob": "^3.2.12", "muggle-string": "^0.4.1", "volar-service-css": "0.0.62", "volar-service-emmet": "0.0.62", "volar-service-html": "0.0.62", "volar-service-prettier": "0.0.62", "volar-service-typescript": "0.0.62", "volar-service-typescript-twoslash-queries": "0.0.62", "volar-service-yaml": "0.0.62", "vscode-html-languageservice": "^5.2.0", "vscode-uri": "^3.0.8" }, "peerDependencies": { "prettier": "^3.0.0", "prettier-plugin-astro": ">=0.11.0" }, "optionalPeers": ["prettier", "prettier-plugin-astro"], "bin": { "astro-ls": "bin/nodeServer.js" } }, "sha512-JivzASqTPR2bao9BWsSc/woPHH7OGSGc9aMxXL4U6egVTqBycB3ZHdBJPuOCVtcGLrzdWTosAqVPz1BVoxE0+A=="],
"@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.2", "", { "dependencies": { "@astrojs/internal-helpers": "0.6.1", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.1.0", "js-yaml": "^4.1.0", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.2.1", "smol-toml": "^1.3.1", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-bO35JbWpVvyKRl7cmSJD822e8YA8ThR/YbUsciWNA7yTcqpIAL2hJDToWP5KcZBWxGT6IOdOkHSXARSNZc4l/Q=="],
"@astrojs/mdx": ["@astrojs/mdx@4.3.0", "", { "dependencies": { "@astrojs/markdown-remark": "6.3.2", "@mdx-js/mdx": "^3.1.0", "acorn": "^8.14.1", "es-module-lexer": "^1.6.0", "estree-util-visit": "^2.0.0", "hast-util-to-html": "^9.0.5", "kleur": "^4.1.5", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-smartypants": "^3.0.2", "source-map": "^0.7.4", "unist-util-visit": "^5.0.0", "vfile": "^6.0.3" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-OGX2KvPeBzjSSKhkCqrUoDMyzFcjKt5nTE5SFw3RdoLf0nrhyCXBQcCyclzWy1+P+XpOamn+p+hm1EhpCRyPxw=="],
@@ -82,11 +93,13 @@
"@astrojs/telemetry": ["@astrojs/telemetry@3.3.0", "", { "dependencies": { "ci-info": "^4.2.0", "debug": "^4.4.0", "dlv": "^1.1.3", "dset": "^3.1.4", "is-docker": "^3.0.0", "is-wsl": "^3.1.0", "which-pm-runs": "^1.1.0" } }, "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ=="],
"@astrojs/yaml2ts": ["@astrojs/yaml2ts@0.2.2", "", { "dependencies": { "yaml": "^2.5.0" } }, "sha512-GOfvSr5Nqy2z5XiwqTouBBpy5FyI6DEe+/g/Mk5am9SjILN1S5fOEvYK0GuWHg98yS/dobP4m8qyqw/URW35fQ=="],
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.27.3", "", {}, "sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw=="],
"@babel/core": ["@babel/core@7.27.3", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.3", "@babel/parser": "^7.27.3", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.3", "@babel/types": "^7.27.3", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-hyrN8ivxfvJ4i0fIJuV4EOlV0WDMz5Ui4StRTgVaAvWeiRCilXgwVvxJKtFQ3TKtHgJscB2YiXKGNJuVwhQMtA=="],
"@babel/core": ["@babel/core@7.27.4", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.4", "@babel/parser": "^7.27.4", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.4", "@babel/types": "^7.27.3", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g=="],
"@babel/generator": ["@babel/generator@7.27.3", "", { "dependencies": { "@babel/parser": "^7.27.3", "@babel/types": "^7.27.3", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-xnlJYj5zepml8NXtjkG0WquFUv8RskFqyFcVgTBp5k+NaA/8uw/K+OSVf8AMGw5e9HKP2ETd5xpK5MLZQD6b4Q=="],
@@ -104,9 +117,9 @@
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.27.3", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.27.3" } }, "sha512-h/eKy9agOya1IGuLaZ9tEUgz+uIRXcbtOhRtUyyMf8JFmn1iT13vnl/IGVWSkdOCG/pC57U4S1jnAabAavTMwg=="],
"@babel/helpers": ["@babel/helpers@7.27.6", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.27.6" } }, "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug=="],
"@babel/parser": ["@babel/parser@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw=="],
"@babel/parser": ["@babel/parser@7.27.5", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
@@ -116,7 +129,7 @@
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
"@babel/traverse": ["@babel/traverse@7.27.3", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.3", "@babel/parser": "^7.27.3", "@babel/template": "^7.27.2", "@babel/types": "^7.27.3", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-lId/IfN/Ye1CIu8xG7oKBHXd2iNb2aW1ilPszzGcJug6M8RCKfVNcYhpI5+bMvFYjK7lXIM0R+a+6r8xhHp2FQ=="],
"@babel/traverse": ["@babel/traverse@7.27.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.3", "@babel/parser": "^7.27.4", "@babel/template": "^7.27.2", "@babel/types": "^7.27.3", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA=="],
"@babel/types": ["@babel/types@7.27.3", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw=="],
@@ -132,6 +145,20 @@
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
"@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="],
"@emmetio/css-abbreviation": ["@emmetio/css-abbreviation@2.1.8", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw=="],
"@emmetio/css-parser": ["@emmetio/css-parser@0.4.0", "", { "dependencies": { "@emmetio/stream-reader": "^2.2.0", "@emmetio/stream-reader-utils": "^0.1.0" } }, "sha512-z7wkxRSZgrQHXVzObGkXG+Vmj3uRlpM11oCZ9pbaz0nFejvCDmAiNDpY75+wgXOcffKpj4rzGtwGaZxfJKsJxw=="],
"@emmetio/html-matcher": ["@emmetio/html-matcher@1.3.0", "", { "dependencies": { "@emmetio/scanner": "^1.0.0" } }, "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ=="],
"@emmetio/scanner": ["@emmetio/scanner@1.0.4", "", {}, "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA=="],
"@emmetio/stream-reader": ["@emmetio/stream-reader@2.2.0", "", {}, "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw=="],
"@emmetio/stream-reader-utils": ["@emmetio/stream-reader-utils@0.1.0", "", {}, "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A=="],
"@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA=="],
@@ -244,27 +271,33 @@
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw=="],
"@octokit/auth-token": ["@octokit/auth-token@5.1.2", "", {}, "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@octokit/core": ["@octokit/core@6.1.5", "", { "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", "@octokit/request": "^9.2.3", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "before-after-hook": "^3.0.2", "universal-user-agent": "^7.0.0" } }, "sha512-vvmsN0r7rguA+FySiCsbaTTobSftpIDIpPW81trAmsv9TGxg3YCujAxRYp/Uy8xmDgYCzzgulG62H7KYUFmeIg=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@octokit/endpoint": ["@octokit/endpoint@10.1.4", "", { "dependencies": { "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@octokit/graphql": ["@octokit/graphql@8.2.2", "", { "dependencies": { "@octokit/request": "^9.2.3", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA=="],
"@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
"@octokit/core": ["@octokit/core@7.0.2", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.1", "@octokit/request": "^10.0.2", "@octokit/request-error": "^7.0.0", "@octokit/types": "^14.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-ODsoD39Lq6vR6aBgvjTnA3nZGliknKboc9Gtxr7E4WDNqY24MxANKcuDQSF0jzapvGb3KWOEDrKfve4HoWGK+g=="],
"@octokit/endpoint": ["@octokit/endpoint@11.0.0", "", { "dependencies": { "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-hoYicJZaqISMAI3JfaDr1qMNi48OctWuOih1m80bkYow/ayPw6Jj52tqWJ6GEoFTk1gBqfanSoI1iY99Z5+ekQ=="],
"@octokit/graphql": ["@octokit/graphql@9.0.1", "", { "dependencies": { "@octokit/request": "^10.0.2", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-j1nQNU1ZxNFx2ZtKmL4sMrs4egy5h65OMDmSbVyuCzjOcwsHq6EaYjOTGXPQxgfiN8dJ4CriYHk6zF050WEULg=="],
"@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="],
"@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@11.6.0", "", { "dependencies": { "@octokit/types": "^13.10.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw=="],
"@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@13.0.1", "", { "dependencies": { "@octokit/types": "^14.1.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-m1KvHlueScy4mQJWvFDCxFBTIdXS0K1SgFGLmqHyX90mZdCIv6gWBbKRhatxRjhGlONuTK/hztYdaqrTXcFZdQ=="],
"@octokit/plugin-request-log": ["@octokit/plugin-request-log@5.3.1", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw=="],
"@octokit/plugin-request-log": ["@octokit/plugin-request-log@6.0.0", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q=="],
"@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@13.5.0", "", { "dependencies": { "@octokit/types": "^13.10.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-9Pas60Iv9ejO3WlAX3maE1+38c5nqbJXV5GrncEfkndIpZrJ/WPMRd2xYDcPPEt5yzpxcjw9fWNoPhsSGzqKqw=="],
"@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@16.0.0", "", { "dependencies": { "@octokit/types": "^14.1.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-kJVUQk6/dx/gRNLWUnAWKFs1kVPn5O5CYZyssyEoNYaFedqZxsfYs7DwI3d67hGz4qOwaJ1dpm07hOAD1BXx6g=="],
"@octokit/request": ["@octokit/request@9.2.3", "", { "dependencies": { "@octokit/endpoint": "^10.1.4", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "fast-content-type-parse": "^2.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-Ma+pZU8PXLOEYzsWf0cn/gY+ME57Wq8f49WTXA8FMHp2Ps9djKw//xYJ1je8Hm0pR2lU9FUGeJRWOtxq6olt4w=="],
"@octokit/request": ["@octokit/request@10.0.2", "", { "dependencies": { "@octokit/endpoint": "^11.0.0", "@octokit/request-error": "^7.0.0", "@octokit/types": "^14.0.0", "fast-content-type-parse": "^3.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-iYj4SJG/2bbhh+iIpFmG5u49DtJ4lipQ+aPakjL9OKpsGY93wM8w06gvFbEQxcMsZcCvk5th5KkIm2m8o14aWA=="],
"@octokit/request-error": ["@octokit/request-error@6.1.8", "", { "dependencies": { "@octokit/types": "^14.0.0" } }, "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ=="],
"@octokit/request-error": ["@octokit/request-error@7.0.0", "", { "dependencies": { "@octokit/types": "^14.0.0" } }, "sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg=="],
"@octokit/rest": ["@octokit/rest@21.1.1", "", { "dependencies": { "@octokit/core": "^6.1.4", "@octokit/plugin-paginate-rest": "^11.4.2", "@octokit/plugin-request-log": "^5.3.1", "@octokit/plugin-rest-endpoint-methods": "^13.3.0" } }, "sha512-sTQV7va0IUVZcntzy1q3QqPm/r8rWtDCqpRAmb8eXXnKkjoQEtFe3Nt5GTVsHft+R6jJoHeSiVLcgcvhtue/rg=="],
"@octokit/rest": ["@octokit/rest@22.0.0", "", { "dependencies": { "@octokit/core": "^7.0.2", "@octokit/plugin-paginate-rest": "^13.0.1", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^16.0.0" } }, "sha512-z6tmTu9BTnw51jYGulxrlernpsQYXpui1RK21vmXn8yF5bp6iX16yfTtJYGK5Mh1qDkvDOmp2n8sRMcQmR8jiA=="],
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
@@ -274,12 +307,16 @@
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collapsible": "1.1.11", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA=="],
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
@@ -298,6 +335,8 @@
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-CPYZ24Mhirm+g6D8jArmLzjYu4Eyg3TTUHswR26QgzXBHBe64BO/RHOJKzmF/Dxb4y4f9PKyJdwm/O/AhNkb+Q=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
@@ -318,8 +357,12 @@
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="],
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.9", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ=="],
@@ -350,7 +393,7 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.9", "", {}, "sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.11", "", {}, "sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag=="],
"@rollup/pluginutils": ["@rollup/pluginutils@5.1.4", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ=="],
@@ -410,39 +453,39 @@
"@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.7", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.7" } }, "sha512-9rsOpdY9idRI2NH6CL4wORFY0+Q6fnx9XP9Ju+iq/0wJwGD5IByIgFmwVbyy4ymuyprj8Qh4ErxMKTUL4uNh3g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.10", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.10" } }, "sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.7", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.7", "@tailwindcss/oxide-darwin-arm64": "4.1.7", "@tailwindcss/oxide-darwin-x64": "4.1.7", "@tailwindcss/oxide-freebsd-x64": "4.1.7", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.7", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.7", "@tailwindcss/oxide-linux-arm64-musl": "4.1.7", "@tailwindcss/oxide-linux-x64-gnu": "4.1.7", "@tailwindcss/oxide-linux-x64-musl": "4.1.7", "@tailwindcss/oxide-wasm32-wasi": "4.1.7", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.7", "@tailwindcss/oxide-win32-x64-msvc": "4.1.7" } }, "sha512-5SF95Ctm9DFiUyjUPnDGkoKItPX/k+xifcQhcqX5RA85m50jw1pT/KzjdvlqxRja45Y52nR4MR9fD1JYd7f8NQ=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.10", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.10", "@tailwindcss/oxide-darwin-arm64": "4.1.10", "@tailwindcss/oxide-darwin-x64": "4.1.10", "@tailwindcss/oxide-freebsd-x64": "4.1.10", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.10", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.10", "@tailwindcss/oxide-linux-arm64-musl": "4.1.10", "@tailwindcss/oxide-linux-x64-gnu": "4.1.10", "@tailwindcss/oxide-linux-x64-musl": "4.1.10", "@tailwindcss/oxide-wasm32-wasi": "4.1.10", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.10", "@tailwindcss/oxide-win32-x64-msvc": "4.1.10" } }, "sha512-v0C43s7Pjw+B9w21htrQwuFObSkio2aV/qPx/mhrRldbqxbWJK6KizM+q7BF1/1CmuLqZqX3CeYF7s7P9fbA8Q=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.7", "", { "os": "android", "cpu": "arm64" }, "sha512-IWA410JZ8fF7kACus6BrUwY2Z1t1hm0+ZWNEzykKmMNM09wQooOcN/VXr0p/WJdtHZ90PvJf2AIBS/Ceqx1emg=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.10", "", { "os": "android", "cpu": "arm64" }, "sha512-VGLazCoRQ7rtsCzThaI1UyDu/XRYVyH4/EWiaSX6tFglE+xZB5cvtC5Omt0OQ+FfiIVP98su16jDVHDEIuH4iQ=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-81jUw9To7fimGGkuJ2W5h3/oGonTOZKZ8C2ghm/TTxbwvfSiFSDPd6/A/KE2N7Jp4mv3Ps9OFqg2fEKgZFfsvg=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZIFqvR1irX2yNjWJzKCqTCcHZbgkSkSkZKbRM3BPzhDL/18idA8uWCoopYA2CSDdSGFlDAxYdU2yBHwAwx8euQ=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-q77rWjEyGHV4PdDBtrzO0tgBBPlQWKY7wZK0cUok/HaGgbNKecegNxCGikuPJn5wFAlIywC3v+WMBt0PEBtwGw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-eCA4zbIhWUFDXoamNztmS0MjXHSEJYlvATzWnRiTqJkcUteSjO94PoRHJy1Xbwp9bptjeIxxBHh+zBWFhttbrQ=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-RfmdbbK6G6ptgF4qqbzoxmH+PKfP4KSVs7SRlTwcbRgBwezJkAO3Qta/7gDy10Q2DcUVkKxFLXUQO6J3CRvBGw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-8/392Xu12R0cc93DpiJvNpJ4wYVSiciUlkiOHOSOQNH3adq9Gi/dtySK7dVQjXIOzlpSHjeCL89RUUI8/GTI6g=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.7", "", { "os": "linux", "cpu": "arm" }, "sha512-OZqsGvpwOa13lVd1z6JVwQXadEobmesxQ4AxhrwRiPuE04quvZHWn/LnihMg7/XkN+dTioXp/VMu/p6A5eZP3g=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.10", "", { "os": "linux", "cpu": "arm" }, "sha512-t9rhmLT6EqeuPT+MXhWhlRYIMSfh5LZ6kBrC4FS6/+M1yXwfCtp24UumgCWOAJVyjQwG+lYva6wWZxrfvB+NhQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-voMvBTnJSfKecJxGkoeAyW/2XRToLZ227LxswLAwKY7YslG/Xkw9/tJNH+3IVh5bdYzYE7DfiaPbRkSHFxY1xA=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-3oWrlNlxLRxXejQ8zImzrVLuZ/9Z2SeKoLhtCu0hpo38hTO2iL86eFOu4sVR8cZc6n3z7eRXXqtHJECa6mFOvA=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-PjGuNNmJeKHnP58M7XyjJyla8LPo+RmwHQpBI+W/OxqrwojyuCQ+GUtygu7jUqTEexejZHr/z3nBc/gTiXBj4A=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-saScU0cmWvg/Ez4gUmQWr9pvY9Kssxt+Xenfx1LG7LmqjcrvBnw4r9VjkFcqmbBb7GCBwYNcZi9X3/oMda9sqQ=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.7", "", { "os": "linux", "cpu": "x64" }, "sha512-HMs+Va+ZR3gC3mLZE00gXxtBo3JoSQxtu9lobbZd+DmfkIxR54NO7Z+UQNPsa0P/ITn1TevtFxXTpsRU7qEvWg=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-/G3ao/ybV9YEEgAXeEg28dyH6gs1QG8tvdN9c2MNZdUXYBaIY/Gx0N6RlJzfLy/7Nkdok4kaxKPHKJUlAaoTdA=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.7", "", { "os": "linux", "cpu": "x64" }, "sha512-MHZ6jyNlutdHH8rd+YTdr3QbXrHXqwIhHw9e7yXEBcQdluGwhpQY2Eku8UZK6ReLaWtQ4gijIv5QoM5eE+qlsA=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-LNr7X8fTiKGRtQGOerSayc2pWJp/9ptRYAa4G+U+cjw9kJZvkopav1AQc5HHD+U364f71tZv6XamaHKgrIoVzA=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.7", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", "@napi-rs/wasm-runtime": "^0.2.9", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-ANaSKt74ZRzE2TvJmUcbFQ8zS201cIPxUDm5qez5rLEwWkie2SkGtA4P+GPTj+u8N6JbPrC8MtY8RmJA35Oo+A=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.10", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", "@napi-rs/wasm-runtime": "^0.2.10", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-d6ekQpopFQJAcIK2i7ZzWOYGZ+A6NzzvQ3ozBvWFdeyqfOZdYHU66g5yr+/HC4ipP1ZgWsqa80+ISNILk+ae/Q=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-HUiSiXQ9gLJBAPCMVRk2RT1ZrBjto7WvqsPBwUrNK2BcdSxMnk19h4pjZjI7zgPhDxlAbJSumTC4ljeA9y0tEw=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1Iwg9gRbwNVOCYmnigWCCgow8nDWSFmeTUU5nbNx3rqbe4p0kRbEqLwLJbYZKmSSp23g4N6rCDmm7OuPBXhDA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.7", "", { "os": "win32", "cpu": "x64" }, "sha512-rYHGmvoHiLJ8hWucSfSOEmdCBIGZIq7SpkPRSqLsH2Ab2YUNgKeAPT1Fi2cx3+hnYOrAb0jp9cRyode3bBW4mQ=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.10", "", { "os": "win32", "cpu": "x64" }, "sha512-sGiJTjcBSfGq2DVRtaSljq5ZgZS2SDHSIfhOylkBvHVjwOsodBhnb3HdmiKkVuUGKD0I7G63abMOVaskj1KpOA=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.7", "", { "dependencies": { "@tailwindcss/node": "4.1.7", "@tailwindcss/oxide": "4.1.7", "tailwindcss": "4.1.7" }, "peerDependencies": { "vite": "^5.2.0 || ^6" } }, "sha512-tYa2fO3zDe41I7WqijyVbRd8oWT0aEID1Eokz5hMT6wShLIHj3yvwj9XbfuloHP9glZ6H+aG2AN/+ZrxJ1Y5RQ=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.10", "", { "dependencies": { "@tailwindcss/node": "4.1.10", "@tailwindcss/oxide": "4.1.10", "tailwindcss": "4.1.10" }, "peerDependencies": { "vite": "^5.2.0 || ^6" } }, "sha512-QWnD5HDY2IADv+vYR82lOhqOlS1jSCUUAmfem52cXAhRTKxpDh3ARX8TTXJTCCO7Rv7cD2Nlekabv02bwP3a2A=="],
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.9", "", { "dependencies": { "@tanstack/virtual-core": "3.13.9" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-SPWC8kwG/dWBf7Py7cfheAPOxuvIv4fFQ54PdmYbg7CpXfsKxkucak43Q0qKsxVthhUJQ1A7CIMAIplq4BjVwA=="],
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.10", "", { "dependencies": { "@tanstack/virtual-core": "3.13.10" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-nvrzk4E9mWB4124YdJ7/yzwou7IfHxlSef6ugCFcBfRmsnsma3heciiiV97sBNxyc3VuwtZvmwXd0aB5BpucVw=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.9", "", {}, "sha512-3jztt0jpaoJO5TARe2WIHC1UQC3VMLAFUW5mmMo0yrkwtDB2AQP0+sh10BVUpWrnvHjSLvzFizydtEGLCJKFoQ=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.10", "", {}, "sha512-sPEDhXREou5HyZYqSWIqdU580rsF6FGeN7vpzijmP3KTiOGjOMZASz4Y6+QKjiFQwhWrR58OP8izYaNGVxvViA=="],
"@testing-library/dom": ["@testing-library/dom@10.4.0", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "pretty-format": "^27.0.2" } }, "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ=="],
@@ -464,8 +507,12 @@
"@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="],
"@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
@@ -486,9 +533,9 @@
"@types/node": ["@types/node@22.15.23", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-7Ec1zaFPF4RJ0eXu1YT/xgiebqwqoJz8rYPDi/O2BcZ++Wpt0Kq9cl0eg6NN6bYbPnR67ZLo7St5Q3UK0SnARw=="],
"@types/react": ["@types/react@19.1.6", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q=="],
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
"@types/react-dom": ["@types/react-dom@19.1.5", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg=="],
"@types/react-dom": ["@types/react-dom@19.1.6", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
@@ -496,21 +543,37 @@
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.5.0", "", { "dependencies": { "@babel/core": "^7.26.10", "@babel/plugin-transform-react-jsx-self": "^7.25.9", "@babel/plugin-transform-react-jsx-source": "^7.25.9", "@rolldown/pluginutils": "1.0.0-beta.9", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, "sha512-JuLWaEqypaJmOJPLWwO335Ig6jSgC1FTONCWAxnqcQthLTK/Yc9aH6hr9z/87xciejbQcnP3GnA1FWUSWeXaeg=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.5.2", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.11", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" } }, "sha512-QNVT3/Lxx99nMQWJWF7K4N6apUEuT0KlZA3mx/mVaoGj3smm/8rc8ezz15J1pcbcjDK0V15rpHetVfya08r76Q=="],
"@vitest/expect": ["@vitest/expect@3.1.4", "", { "dependencies": { "@vitest/spy": "3.1.4", "@vitest/utils": "3.1.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-xkD/ljeliyaClDYqHPNCiJ0plY5YIcM0OlRiZizLhlPmpXWpxnGMyTZXOHFhFeG7w9P5PBeL4IdtJ/HeQwTbQA=="],
"@vitest/expect": ["@vitest/expect@3.2.3", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.3", "@vitest/utils": "3.2.3", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-W2RH2TPWVHA1o7UmaFKISPvdicFJH+mjykctJFoAkUw+SPTJTGjUNdKscFBrqM7IPnCVu6zihtKYa7TkZS1dkQ=="],
"@vitest/mocker": ["@vitest/mocker@3.1.4", "", { "dependencies": { "@vitest/spy": "3.1.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-8IJ3CvwtSw/EFXqWFL8aCMu+YyYXG2WUSrQbViOZkWTKTVicVwZ/YiEZDSqD00kX+v/+W+OnxhNWoeVKorHygA=="],
"@vitest/mocker": ["@vitest/mocker@3.2.3", "", { "dependencies": { "@vitest/spy": "3.2.3", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-cP6fIun+Zx8he4rbWvi+Oya6goKQDZK+Yq4hhlggwQBbrlOQ4qtZ+G4nxB6ZnzI9lyIb+JnvyiJnPC2AGbKSPA=="],
"@vitest/pretty-format": ["@vitest/pretty-format@3.1.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-cqv9H9GvAEoTaoq+cYqUTCGscUjKqlJZC7PRwY5FMySVj5J+xOm1KQcCiYHJOEzOKRUhLH4R2pTwvFlWCEScsg=="],
"@vitest/pretty-format": ["@vitest/pretty-format@3.2.3", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-yFglXGkr9hW/yEXngO+IKMhP0jxyFw2/qys/CK4fFUZnSltD+MU7dVYGrH8rvPcK/O6feXQA+EU33gjaBBbAng=="],
"@vitest/runner": ["@vitest/runner@3.1.4", "", { "dependencies": { "@vitest/utils": "3.1.4", "pathe": "^2.0.3" } }, "sha512-djTeF1/vt985I/wpKVFBMWUlk/I7mb5hmD5oP8K9ACRmVXgKTae3TUOtXAEBfslNKPzUQvnKhNd34nnRSYgLNQ=="],
"@vitest/runner": ["@vitest/runner@3.2.3", "", { "dependencies": { "@vitest/utils": "3.2.3", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-83HWYisT3IpMaU9LN+VN+/nLHVBCSIUKJzGxC5RWUOsK1h3USg7ojL+UXQR3b4o4UBIWCYdD2fxuzM7PQQ1u8w=="],
"@vitest/snapshot": ["@vitest/snapshot@3.1.4", "", { "dependencies": { "@vitest/pretty-format": "3.1.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-JPHf68DvuO7vilmvwdPr9TS0SuuIzHvxeaCkxYcCD4jTk67XwL45ZhEHFKIuCm8CYstgI6LZ4XbwD6ANrwMpFg=="],
"@vitest/snapshot": ["@vitest/snapshot@3.2.3", "", { "dependencies": { "@vitest/pretty-format": "3.2.3", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-9gIVWx2+tysDqUmmM1L0hwadyumqssOL1r8KJipwLx5JVYyxvVRfxvMq7DaWbZZsCqZnu/dZedaZQh4iYTtneA=="],
"@vitest/spy": ["@vitest/spy@3.1.4", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-Xg1bXhu+vtPXIodYN369M86K8shGLouNjoVI78g8iAq2rFoHFdajNvJJ5A/9bPMFcfQqdaCpOgWKEoMQg/s0Yg=="],
"@vitest/spy": ["@vitest/spy@3.2.3", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-JHu9Wl+7bf6FEejTCREy+DmgWe+rQKbK+y32C/k5f4TBIAlijhJbRBIRIOCEpVevgRsCQR2iHRUH2/qKVM/plw=="],
"@vitest/utils": ["@vitest/utils@3.1.4", "", { "dependencies": { "@vitest/pretty-format": "3.1.4", "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" } }, "sha512-yriMuO1cfFhmiGc8ataN51+9ooHRuURdfAZfwFd3usWynjzpLslZdYnRegTv32qdgtJTsj15FoeZe2g15fY1gg=="],
"@vitest/utils": ["@vitest/utils@3.2.3", "", { "dependencies": { "@vitest/pretty-format": "3.2.3", "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" } }, "sha512-4zFBCU5Pf+4Z6v+rwnZ1HU1yzOKKvDkMXZrymE2PBlbjKJRlrOxbvpfPSvJTGRIwGoahaOGvp+kbCoxifhzJ1Q=="],
"@volar/kit": ["@volar/kit@2.4.14", "", { "dependencies": { "@volar/language-service": "2.4.14", "@volar/typescript": "2.4.14", "typesafe-path": "^0.2.2", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "typescript": "*" } }, "sha512-kBcmHjEodtmYGJELHePZd2JdeYm4ZGOd9F/pQ1YETYIzAwy4Z491EkJ1nRSo/GTxwKt0XYwYA/dHSEgXecVHRA=="],
"@volar/language-core": ["@volar/language-core@2.4.14", "", { "dependencies": { "@volar/source-map": "2.4.14" } }, "sha512-X6beusV0DvuVseaOEy7GoagS4rYHgDHnTrdOj5jeUb49fW5ceQyP9Ej5rBhqgz2wJggl+2fDbbojq1XKaxDi6w=="],
"@volar/language-server": ["@volar/language-server@2.4.14", "", { "dependencies": { "@volar/language-core": "2.4.14", "@volar/language-service": "2.4.14", "@volar/typescript": "2.4.14", "path-browserify": "^1.0.1", "request-light": "^0.7.0", "vscode-languageserver": "^9.0.1", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" } }, "sha512-P3mGbQbW0v40UYBnb3DAaNtRYx6/MGOVKzdOWmBCGwjUkCR2xBkGrCFt05XnPDwFS/cTWDh2U6Mc9lpZ8Aecfw=="],
"@volar/language-service": ["@volar/language-service@2.4.14", "", { "dependencies": { "@volar/language-core": "2.4.14", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" } }, "sha512-vNC3823EJohdzLTyjZoCMPwoWCfINB5emusniCkW5CGoGHQov4VVmT6yI5ncgP/NpgAIUv2NEkJooXvLHA4VeQ=="],
"@volar/source-map": ["@volar/source-map@2.4.14", "", {}, "sha512-5TeKKMh7Sfxo8021cJfmBzcjfY1SsXsPMMjMvjY7ivesdnybqqS+GxGAoXHAOUawQTwtdUxgP65Im+dEmvWtYQ=="],
"@volar/typescript": ["@volar/typescript@2.4.14", "", { "dependencies": { "@volar/language-core": "2.4.14", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-p8Z6f/bZM3/HyCdRNFZOEEzts51uV8WHeN8Tnfnm2EBv6FDB2TQLzfVx7aJvnl8ofKAOnS64B2O8bImBFaauRw=="],
"@vscode/emmet-helper": ["@vscode/emmet-helper@2.11.0", "", { "dependencies": { "emmet": "^2.4.3", "jsonc-parser": "^2.3.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.15.1", "vscode-uri": "^3.0.8" } }, "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw=="],
"@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="],
"acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="],
@@ -518,6 +581,8 @@
"agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@@ -538,7 +603,7 @@
"astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="],
"astro": ["astro@5.8.0", "", { "dependencies": { "@astrojs/compiler": "^2.11.0", "@astrojs/internal-helpers": "0.6.1", "@astrojs/markdown-remark": "6.3.2", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^2.4.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.1.4", "acorn": "^8.14.1", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", "ci-info": "^4.2.0", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^1.0.2", "cssesc": "^3.0.0", "debug": "^4.4.0", "deterministic-object-hash": "^2.0.2", "devalue": "^5.1.1", "diff": "^5.2.0", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.6.0", "esbuild": "^0.25.0", "estree-walker": "^3.0.3", "flattie": "^1.1.1", "fontace": "~0.3.0", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.1.1", "import-meta-resolve": "^4.1.0", "js-yaml": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.17", "magicast": "^0.3.5", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "p-limit": "^6.2.0", "p-queue": "^8.1.0", "package-manager-detector": "^1.1.0", "picomatch": "^4.0.2", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.7.1", "shiki": "^3.2.1", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.12", "tsconfck": "^3.1.5", "ultrahtml": "^1.6.0", "unifont": "~0.5.0", "unist-util-visit": "^5.0.0", "unstorage": "^1.15.0", "vfile": "^6.0.3", "vite": "^6.3.4", "vitefu": "^1.0.6", "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.2.1", "zod": "^3.24.2", "zod-to-json-schema": "^3.24.5", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.33.3" }, "bin": { "astro": "astro.js" } }, "sha512-G57ELkdIntDiSrucA5lQaRtBOjquaZ9b9NIwoz2f471ZuuJcynLjWgItgBzlrz5UMY4WqnFbVWUCKlJb7nt9bA=="],
"astro": ["astro@5.9.3", "", { "dependencies": { "@astrojs/compiler": "^2.12.2", "@astrojs/internal-helpers": "0.6.1", "@astrojs/markdown-remark": "6.3.2", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^2.4.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.1.4", "acorn": "^8.14.1", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", "ci-info": "^4.2.0", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^1.0.2", "cssesc": "^3.0.0", "debug": "^4.4.0", "deterministic-object-hash": "^2.0.2", "devalue": "^5.1.1", "diff": "^5.2.0", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.6.0", "esbuild": "^0.25.0", "estree-walker": "^3.0.3", "flattie": "^1.1.1", "fontace": "~0.3.0", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.1.1", "import-meta-resolve": "^4.1.0", "js-yaml": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.17", "magicast": "^0.3.5", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "p-limit": "^6.2.0", "p-queue": "^8.1.0", "package-manager-detector": "^1.1.0", "picomatch": "^4.0.2", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.7.1", "shiki": "^3.2.1", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.12", "tsconfck": "^3.1.5", "ultrahtml": "^1.6.0", "unifont": "~0.5.0", "unist-util-visit": "^5.0.0", "unstorage": "^1.15.0", "vfile": "^6.0.3", "vite": "^6.3.4", "vitefu": "^1.0.6", "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.2.1", "zod": "^3.24.2", "zod-to-json-schema": "^3.24.5", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.33.3" }, "bin": { "astro": "astro.js" } }, "sha512-VReZrpUa/3rfeiVvsQ1A2M3ujDPI+pDGIYOMtXPEZwut8tZoEyealXXLjitgCsJ+3dunKGZbg4Eak6i+r0vniw=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
@@ -550,12 +615,14 @@
"bcryptjs": ["bcryptjs@3.0.2", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog=="],
"before-after-hook": ["before-after-hook@3.0.2", "", {}, "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A=="],
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
"blob-to-buffer": ["blob-to-buffer@1.2.9", "", {}, "sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA=="],
"boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="],
"browserslist": ["browserslist@4.24.5", "", { "dependencies": { "caniuse-lite": "^1.0.30001716", "electron-to-chromium": "^1.5.149", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw=="],
@@ -596,6 +663,8 @@
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -672,7 +741,7 @@
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"drizzle-orm": ["drizzle-orm@0.43.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-dUcDaZtE/zN4RV/xqGrVSMpnEczxd5cIaoDeor7Zst9wOe/HzC/7eAaulywWGYXdDEc9oBPMjayVEDg0ziTLJA=="],
"drizzle-orm": ["drizzle-orm@0.44.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-zGAqBzWWkVSFjZpwPOrmCrgO++1kZ5H/rZ4qTGeGOe18iXGVJWf3WPfHOVwFIbmi8kHjfJstC6rJomzGx8g/dQ=="],
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
@@ -682,7 +751,9 @@
"electron-to-chromium": ["electron-to-chromium@1.5.159", "", {}, "sha512-CEvHptWAMV5p6GJ0Lq8aheyvVbfzVrv5mmidu1D3pidoVNkB3tTBsTMVtPJ+rzRK5oV229mCLz9Zj/hNvU8GBA=="],
"emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
"emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
@@ -726,12 +797,20 @@
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-content-type-parse": ["fast-content-type-parse@2.0.1", "", {}, "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q=="],
"fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-uri": ["fast-uri@3.0.6", "", {}, "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw=="],
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
"fdir": ["fdir@6.4.4", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="],
"fontace": ["fontace@0.3.0", "", { "dependencies": { "@types/fontkit": "^2.0.8", "fontkit": "^2.0.4" } }, "sha512-czoqATrcnxgWb/nAkfyIrRp6Q8biYj7nGnL6zfhTcX+JKKpWHFBnb8uNMw/kZr7u++3Y3wYSYoZgHkCcsuBpBg=="],
@@ -746,6 +825,8 @@
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
@@ -754,6 +835,8 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
@@ -822,12 +905,18 @@
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
@@ -844,8 +933,12 @@
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="],
"jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="],
"jwa": ["jwa@1.4.2", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw=="],
@@ -898,7 +991,7 @@
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"lucide-react": ["lucide-react@0.511.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w=="],
"lucide-react": ["lucide-react@0.515.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Sy7bY0MeicRm2pzrnoHm2h6C1iVoeHyBU2fjdQDsXGP51fhkhau1/ZV/dzrcxEmAKsxYb6bGaIsMnGHuQ5s0dw=="],
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
@@ -946,6 +1039,8 @@
"mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
@@ -1016,6 +1111,8 @@
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="],
@@ -1032,6 +1129,8 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="],
@@ -1078,6 +1177,8 @@
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pathval": ["pathval@2.0.0", "", {}, "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA=="],
@@ -1088,6 +1189,8 @@
"postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="],
"prettier": ["prettier@2.8.7", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw=="],
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
@@ -1098,6 +1201,8 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
@@ -1158,6 +1263,12 @@
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
"request-light": ["request-light@0.7.0", "", {}, "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
@@ -1170,10 +1281,14 @@
"retext-stringify": ["retext-stringify@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", "unified": "^11.0.0" } }, "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rollup": ["rollup@4.41.1", "", { "dependencies": { "@types/estree": "1.0.7" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.41.1", "@rollup/rollup-android-arm64": "4.41.1", "@rollup/rollup-darwin-arm64": "4.41.1", "@rollup/rollup-darwin-x64": "4.41.1", "@rollup/rollup-freebsd-arm64": "4.41.1", "@rollup/rollup-freebsd-x64": "4.41.1", "@rollup/rollup-linux-arm-gnueabihf": "4.41.1", "@rollup/rollup-linux-arm-musleabihf": "4.41.1", "@rollup/rollup-linux-arm64-gnu": "4.41.1", "@rollup/rollup-linux-arm64-musl": "4.41.1", "@rollup/rollup-linux-loongarch64-gnu": "4.41.1", "@rollup/rollup-linux-powerpc64le-gnu": "4.41.1", "@rollup/rollup-linux-riscv64-gnu": "4.41.1", "@rollup/rollup-linux-riscv64-musl": "4.41.1", "@rollup/rollup-linux-s390x-gnu": "4.41.1", "@rollup/rollup-linux-x64-gnu": "4.41.1", "@rollup/rollup-linux-x64-musl": "4.41.1", "@rollup/rollup-win32-arm64-msvc": "4.41.1", "@rollup/rollup-win32-ia32-msvc": "4.41.1", "@rollup/rollup-win32-x64-msvc": "4.41.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw=="],
"rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
@@ -1202,7 +1317,7 @@
"smol-toml": ["smol-toml@1.3.4", "", {}, "sha512-UOPtVuYkzYGee0Bd2Szz8d2G3RfMfJ2t3qVdZUAozZyAk+a0Sxa+QKix0YCwjL/A1RR0ar44nCxaoN9FxdJGwA=="],
"sonner": ["sonner@2.0.3", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-njQ4Hht92m0sMqqHVDL32V2Oun9W1+PHO9NDv9FHfJjT3JT22IG4Jpo3FPQy+mouRKCXFWO+r67v6MrHX2zeIA=="],
"sonner": ["sonner@2.0.5", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-YwbHQO6cSso3HBXlbCkgrgzDNIhws14r4MO87Ofy+cV2X7ES4pOoAK3+veSmVTvqNx1BWUxlhPmZzP00Crk2aQ=="],
"source-map": ["source-map@0.7.4", "", {}, "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA=="],
@@ -1216,14 +1331,16 @@
"std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"strip-literal": ["strip-literal@3.0.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA=="],
"style-to-js": ["style-to-js@1.1.16", "", { "dependencies": { "style-to-object": "1.0.8" } }, "sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw=="],
"style-to-object": ["style-to-object@1.0.8", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g=="],
@@ -1232,9 +1349,9 @@
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"tailwind-merge": ["tailwind-merge@3.3.0", "", {}, "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ=="],
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
"tailwindcss": ["tailwindcss@4.1.7", "", {}, "sha512-kr1o/ErIdNhTz8uzAYL7TpaUuzKIE6QPQ4qmSdxnoX/lo+5wmUHQA6h3L5yIqEImSRnAAURDirLu/BgiXGPAhg=="],
"tailwindcss": ["tailwindcss@4.1.10", "", {}, "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA=="],
"tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="],
@@ -1248,16 +1365,18 @@
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
"tinypool": ["tinypool@1.0.2", "", {}, "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA=="],
"tinypool": ["tinypool@1.1.0", "", {}, "sha512-7CotroY9a8DKsKprEy/a14aCCm8jYVmR7aFy4fpkZM8sdpNJbKkixuNjgM50yCmip2ezc8z4N7k3oe2+rfRJCQ=="],
"tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="],
"tinyspy": ["tinyspy@4.0.3", "", {}, "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A=="],
"tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="],
"tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="],
@@ -1272,14 +1391,18 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.19.4", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-gK5GVzDkJK1SI1zwHf32Mqxf2tSJkNx+eYcNly5+nHvWqXUJYUkWBQtKauoESz3ymezAI++ZwT855x5p5eop+Q=="],
"tsx": ["tsx@4.20.3", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ=="],
"tw-animate-css": ["tw-animate-css@1.3.0", "", {}, "sha512-jrJ0XenzS9KVuDThJDvnhalbl4IYiMQ/XvpA0a2FL8KmlK+6CSMviO7ROY/I7z1NnUs5NnDhlM6fXmF40xPxzw=="],
"tw-animate-css": ["tw-animate-css@1.3.4", "", {}, "sha512-dd1Ht6/YQHcNbq0znIT6dG8uhO7Ce+VIIhZUhjsryXsMPJQz3bZg7Q2eNzLwipb25bRZslGb2myio5mScd1TFg=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"typesafe-path": ["typesafe-path@0.2.2", "", {}, "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"typescript-auto-import-cache": ["typescript-auto-import-cache@0.3.6", "", { "dependencies": { "semver": "^7.3.8" } }, "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ=="],
"ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="],
"ultrahtml": ["ultrahtml@1.6.0", "", {}, "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw=="],
@@ -1338,11 +1461,45 @@
"vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
"vite-node": ["vite-node@3.1.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.0", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-6enNwYnpyDo4hEgytbmc6mYWHXDHYEn0D1/rw4Q+tnHUGtKTJsn8T1YkX6Q18wI5LCrS8CTYlBaiCqxOy2kvUA=="],
"vite-node": ["vite-node@3.2.3", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-gc8aAifGuDIpZHrPjuHyP4dpQmYXqWw7D1GmDnWeNWP654UEXzVfQ5IHPSK5HaHkwB/+p1atpYpSdw/2kOv8iQ=="],
"vitefu": ["vitefu@1.0.6", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" }, "optionalPeers": ["vite"] }, "sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA=="],
"vitest": ["vitest@3.1.4", "", { "dependencies": { "@vitest/expect": "3.1.4", "@vitest/mocker": "3.1.4", "@vitest/pretty-format": "^3.1.4", "@vitest/runner": "3.1.4", "@vitest/snapshot": "3.1.4", "@vitest/spy": "3.1.4", "@vitest/utils": "3.1.4", "chai": "^5.2.0", "debug": "^4.4.0", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.13", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", "vite-node": "3.1.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.1.4", "@vitest/ui": "3.1.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-Ta56rT7uWxCSJXlBtKgIlApJnT6e6IGmTYxYcmxjJ4ujuZDI59GUQgVDObXXJujOmPDBYXHK1qmaGtneu6TNIQ=="],
"vitest": ["vitest@3.2.3", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.3", "@vitest/mocker": "3.2.3", "@vitest/pretty-format": "^3.2.3", "@vitest/runner": "3.2.3", "@vitest/snapshot": "3.2.3", "@vitest/spy": "3.2.3", "@vitest/utils": "3.2.3", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.0", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.3", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.3", "@vitest/ui": "3.2.3", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-E6U2ZFXe3N/t4f5BwUaVCKRLHqUpk1CBWeMh78UT4VaTPH/2dyvH6ALl29JTovEPu9dVKr/K/J4PkXgrMbw4Ww=="],
"volar-service-css": ["volar-service-css@0.0.62", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-JwNyKsH3F8PuzZYuqPf+2e+4CTU8YoyUHEHVnoXNlrLe7wy9U3biomZ56llN69Ris7TTy/+DEX41yVxQpM4qvg=="],
"volar-service-emmet": ["volar-service-emmet@0.0.62", "", { "dependencies": { "@emmetio/css-parser": "^0.4.0", "@emmetio/html-matcher": "^1.3.0", "@vscode/emmet-helper": "^2.9.3", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-U4dxWDBWz7Pi4plpbXf4J4Z/ss6kBO3TYrACxWNsE29abu75QzVS0paxDDhI6bhqpbDFXlpsDhZ9aXVFpnfGRQ=="],
"volar-service-html": ["volar-service-html@0.0.62", "", { "dependencies": { "vscode-html-languageservice": "^5.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-Zw01aJsZRh4GTGUjveyfEzEqpULQUdQH79KNEiKVYHZyuGtdBRYCHlrus1sueSNMxwwkuF5WnOHfvBzafs8yyQ=="],
"volar-service-prettier": ["volar-service-prettier@0.0.62", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0", "prettier": "^2.2 || ^3.0" }, "optionalPeers": ["@volar/language-service", "prettier"] }, "sha512-h2yk1RqRTE+vkYZaI9KYuwpDfOQRrTEMvoHol0yW4GFKc75wWQRrb5n/5abDrzMPrkQbSip8JH2AXbvrRtYh4w=="],
"volar-service-typescript": ["volar-service-typescript@0.0.62", "", { "dependencies": { "path-browserify": "^1.0.1", "semver": "^7.6.2", "typescript-auto-import-cache": "^0.3.3", "vscode-languageserver-textdocument": "^1.0.11", "vscode-nls": "^5.2.0", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-p7MPi71q7KOsH0eAbZwPBiKPp9B2+qrdHAd6VY5oTo9BUXatsOAdakTm9Yf0DUj6uWBAaOT01BSeVOPwucMV1g=="],
"volar-service-typescript-twoslash-queries": ["volar-service-typescript-twoslash-queries@0.0.62", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-KxFt4zydyJYYI0kFAcWPTh4u0Ha36TASPZkAnNY784GtgajerUqM80nX/W1d0wVhmcOFfAxkVsf/Ed+tiYU7ng=="],
"volar-service-yaml": ["volar-service-yaml@0.0.62", "", { "dependencies": { "vscode-uri": "^3.0.8", "yaml-language-server": "~1.15.0" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-k7gvv7sk3wa+nGll3MaSKyjwQsJjIGCHFjVkl3wjaSP2nouKyn9aokGmqjrl39mi88Oy49giog2GkZH526wjig=="],
"vscode-css-languageservice": ["vscode-css-languageservice@6.3.6", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-fU4h8mT3KlvfRcbF74v/M+Gzbligav6QMx4AD/7CbclWPYOpGb9kgIswfpZVJbIcOEJJACI9iYizkNwdiAqlHw=="],
"vscode-html-languageservice": ["vscode-html-languageservice@5.5.0", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-No6Er2P2L8IsXDnUFlp0bP4f2sdkJv+zJLZYFhtEQIp+2xNfxY8WYkhSxLJ/7bZhuV/aU55lmGSSHBVxSGer3Q=="],
"vscode-json-languageservice": ["vscode-json-languageservice@4.1.8", "", { "dependencies": { "jsonc-parser": "^3.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-nls": "^5.0.0", "vscode-uri": "^3.0.2" } }, "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg=="],
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
"vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="],
"vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="],
"vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="],
"vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="],
"vscode-nls": ["vscode-nls@5.2.0", "", {}, "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng=="],
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
@@ -1372,8 +1529,16 @@
"xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yaml": ["yaml@2.8.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ=="],
"yaml-language-server": ["yaml-language-server@1.15.0", "", { "dependencies": { "ajv": "^8.11.0", "lodash": "4.17.21", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^7.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-nls": "^5.0.0", "vscode-uri": "^3.0.2", "yaml": "2.2.2" }, "optionalDependencies": { "prettier": "2.8.7" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-N47AqBDCMQmh6mBLmI6oqxryHRzi33aPFPsJhYy3VTUGCdLHYjGh4FZzpUjRlphaADBBkDmnkM/++KNIOHi5Rw=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yocto-queue": ["yocto-queue@1.2.1", "", {}, "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg=="],
@@ -1382,7 +1547,7 @@
"yoctocolors": ["yoctocolors@2.1.1", "", {}, "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ=="],
"zod": ["zod@3.25.32", "", {}, "sha512-OSm2xTIRfW8CV5/QKgngwmQW/8aPfGdaQFlrGoErlgg/Epm7cjb6K6VEyExfe65a3VybUOnu381edLb0dfJl0g=="],
"zod": ["zod@3.25.64", "", {}, "sha512-hbP9FpSZf7pkS7hRVUrOjhwKJNyampPgtXKc3AN6DsWtoHsg2Sb4SQaS4Tcay380zSwd2VPo9G9180emBACp5g=="],
"zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
@@ -1390,15 +1555,23 @@
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@astrojs/react/@vitejs/plugin-react": ["@vitejs/plugin-react@4.5.0", "", { "dependencies": { "@babel/core": "^7.26.10", "@babel/plugin-transform-react-jsx-self": "^7.25.9", "@babel/plugin-transform-react-jsx-source": "^7.25.9", "@rolldown/pluginutils": "1.0.0-beta.9", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, "sha512-JuLWaEqypaJmOJPLWwO335Ig6jSgC1FTONCWAxnqcQthLTK/Yc9aH6hr9z/87xciejbQcnP3GnA1FWUSWeXaeg=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/generator/@babel/parser": ["@babel/parser@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="],
"@babel/helper-module-imports/@babel/traverse": ["@babel/traverse@7.27.3", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.3", "@babel/parser": "^7.27.3", "@babel/template": "^7.27.2", "@babel/types": "^7.27.3", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-lId/IfN/Ye1CIu8xG7oKBHXd2iNb2aW1ilPszzGcJug6M8RCKfVNcYhpI5+bMvFYjK7lXIM0R+a+6r8xhHp2FQ=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="],
"@babel/helper-module-transforms/@babel/traverse": ["@babel/traverse@7.27.3", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.3", "@babel/parser": "^7.27.3", "@babel/template": "^7.27.2", "@babel/types": "^7.27.3", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-lId/IfN/Ye1CIu8xG7oKBHXd2iNb2aW1ilPszzGcJug6M8RCKfVNcYhpI5+bMvFYjK7lXIM0R+a+6r8xhHp2FQ=="],
"@babel/helpers/@babel/types": ["@babel/types@7.27.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q=="],
"@babel/template/@babel/parser": ["@babel/parser@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw=="],
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
@@ -1420,14 +1593,24 @@
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"@types/babel__core/@babel/parser": ["@babel/parser@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw=="],
"@types/babel__template/@babel/parser": ["@babel/parser@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"boxen/chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="],
"boxen/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"hast-util-to-parse5/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="],
"magicast/@babel/parser": ["@babel/parser@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
@@ -1436,22 +1619,66 @@
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
"vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
"widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"wrap-ansi/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="],
"yaml-language-server/vscode-languageserver": ["vscode-languageserver@7.0.0", "", { "dependencies": { "vscode-languageserver-protocol": "3.16.0" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-60HTx5ID+fLRcgdHfmz0LDZAXYEV68fzwG0JWwEPBode9NuMYTIxuYXPg4ngO8i8+Ou0lM7y6GzaYWbiDL0drw=="],
"yaml-language-server/yaml": ["yaml@2.2.2", "", {}, "sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA=="],
"@astrojs/react/@vitejs/plugin-react/@babel/core": ["@babel/core@7.27.3", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.3", "@babel/parser": "^7.27.3", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.3", "@babel/types": "^7.27.3", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-hyrN8ivxfvJ4i0fIJuV4EOlV0WDMz5Ui4StRTgVaAvWeiRCilXgwVvxJKtFQ3TKtHgJscB2YiXKGNJuVwhQMtA=="],
"@astrojs/react/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.9", "", {}, "sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w=="],
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
"@babel/helper-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
"@babel/helper-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"boxen/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"boxen/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"widest-line/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
"widest-line/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"yaml-language-server/vscode-languageserver/vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.16.0", "", { "dependencies": { "vscode-jsonrpc": "6.0.0", "vscode-languageserver-types": "3.16.0" } }, "sha512-sdeUoAawceQdgIfTI+sdcwkiK2KU+2cbEYA0agzM2uqaUy2UpnnGHtWTHVEtS0ES4zHU0eMFRGN+oQgDxlD66A=="],
"@astrojs/react/@vitejs/plugin-react/@babel/core/@babel/helpers": ["@babel/helpers@7.27.3", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.27.3" } }, "sha512-h/eKy9agOya1IGuLaZ9tEUgz+uIRXcbtOhRtUyyMf8JFmn1iT13vnl/IGVWSkdOCG/pC57U4S1jnAabAavTMwg=="],
"@astrojs/react/@vitejs/plugin-react/@babel/core/@babel/parser": ["@babel/parser@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw=="],
"@astrojs/react/@vitejs/plugin-react/@babel/core/@babel/traverse": ["@babel/traverse@7.27.3", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.3", "@babel/parser": "^7.27.3", "@babel/template": "^7.27.2", "@babel/types": "^7.27.3", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-lId/IfN/Ye1CIu8xG7oKBHXd2iNb2aW1ilPszzGcJug6M8RCKfVNcYhpI5+bMvFYjK7lXIM0R+a+6r8xhHp2FQ=="],
"@astrojs/react/@vitejs/plugin-react/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"boxen/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"widest-line/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"yaml-language-server/vscode-languageserver/vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@6.0.0", "", {}, "sha512-wnJA4BnEjOSyFMvjZdpiOwhSq9uDoK8e/kpRJDTaMYzwlkrhG1fwDIZI94CLsLzlCK5cIbMMtFlJlfR57Lavmg=="],
"yaml-language-server/vscode-languageserver/vscode-languageserver-protocol/vscode-languageserver-types": ["vscode-languageserver-types@3.16.0", "", {}, "sha512-k8luDIWJWyenLc5ToFQQMaSrqCHiLwyKPHKPQZ5zz21vM+vIVUSvsRpcbiECH4WR88K2XZqc4ScRcZ7nk/jbeA=="],
}
}

View File

@@ -7,11 +7,12 @@ services:
image: gitea/gitea:latest
container_name: gitea
restart: unless-stopped
entrypoint: ["/tmp/gitea-dev-init.sh"]
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=sqlite3
- GITEA__database__PATH=/data/gitea.db
- GITEA__database__PATH=/data/gitea/gitea.db
- GITEA__server__DOMAIN=localhost
- GITEA__server__ROOT_URL=http://localhost:3001/
- GITEA__server__SSH_DOMAIN=localhost
@@ -19,20 +20,24 @@ services:
- GITEA__server__START_SSH_SERVER=true
- GITEA__security__INSTALL_LOCK=true
- GITEA__service__DISABLE_REGISTRATION=false
- GITEA__log__MODE=console
- GITEA__log__LEVEL=Info
ports:
- "3001:3000"
- "2222:22"
volumes:
- gitea-data:/data
- gitea-config:/etc/gitea
- ./scripts/gitea-app.ini:/tmp/app.ini:ro
- ./scripts/gitea-dev-init.sh:/tmp/gitea-dev-init.sh:ro
networks:
- gitea-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/healthz"]
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
timeout: 10s
retries: 5
start_period: 60s
# Development service connected to local Gitea
gitea-mirror-dev:
@@ -63,6 +68,7 @@ services:
- SKIP_FORKS=${SKIP_FORKS:-false}
- PRIVATE_REPOSITORIES=${PRIVATE_REPOSITORIES:-false}
- MIRROR_ISSUES=${MIRROR_ISSUES:-false}
- MIRROR_WIKI=${MIRROR_WIKI:-false}
- MIRROR_STARRED=${MIRROR_STARRED:-false}
- MIRROR_ORGANIZATIONS=${MIRROR_ORGANIZATIONS:-false}
- PRESERVE_ORG_STRUCTURE=${PRESERVE_ORG_STRUCTURE:-false}

View File

@@ -30,6 +30,7 @@ services:
- SKIP_FORKS=${SKIP_FORKS:-false}
- PRIVATE_REPOSITORIES=${PRIVATE_REPOSITORIES:-false}
- MIRROR_ISSUES=${MIRROR_ISSUES:-false}
- MIRROR_WIKI=${MIRROR_WIKI:-false}
- MIRROR_STARRED=${MIRROR_STARRED:-false}
- MIRROR_ORGANIZATIONS=${MIRROR_ORGANIZATIONS:-false}
- PRESERVE_ORG_STRUCTURE=${PRESERVE_ORG_STRUCTURE:-false}

View File

@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "2.12.0",
"version": "2.17.0",
"engines": {
"bun": ">=1.2.9"
},
@@ -16,7 +16,6 @@
"check-db": "bun scripts/manage-db.ts check",
"fix-db": "bun scripts/manage-db.ts fix",
"reset-users": "bun scripts/manage-db.ts reset-users",
"startup-recovery": "bun scripts/startup-recovery.ts",
"startup-recovery-force": "bun scripts/startup-recovery.ts --force",
"test-recovery": "bun scripts/test-recovery.ts",
@@ -32,47 +31,54 @@
"astro": "bunx --bun astro"
},
"dependencies": {
"@astrojs/mdx": "^4.2.6",
"@astrojs/node": "^9.2.1",
"@astrojs/react": "^4.2.7",
"@octokit/rest": "^21.1.1",
"@radix-ui/react-avatar": "^1.1.9",
"@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-radio-group": "^1.3.6",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-slot": "^1.2.2",
"@astrojs/check": "^0.9.4",
"@astrojs/mdx": "^4.3.0",
"@astrojs/node": "^9.2.2",
"@astrojs/react": "^4.3.0",
"@octokit/rest": "^22.0.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.6",
"@tailwindcss/vite": "^4.1.7",
"@tanstack/react-virtual": "^3.13.8",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.10",
"@tanstack/react-virtual": "^3.13.10",
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"astro": "^5.7.13",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"astro": "^5.9.3",
"bcryptjs": "^3.0.2",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"drizzle-orm": "^0.43.1",
"drizzle-orm": "^0.44.2",
"fuse.js": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.511.0",
"lucide-react": "^0.515.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"sonner": "^2.0.3",
"tailwind-merge": "^3.3.0",
"tailwindcss": "^4.1.7",
"tw-animate-css": "^1.3.0",
"sonner": "^2.0.5",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.10",
"tw-animate-css": "^1.3.4",
"typescript": "^5.8.3",
"uuid": "^11.1.0",
"zod": "^3.25.7"
"zod": "^3.25.64"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
@@ -80,10 +86,10 @@
"@types/bcryptjs": "^3.0.0",
"@types/jsonwebtoken": "^9.0.9",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.4.1",
"@vitejs/plugin-react": "^4.5.2",
"jsdom": "^26.1.0",
"tsx": "^4.19.4",
"vitest": "^3.1.4"
"tsx": "^4.20.3",
"vitest": "^3.2.3"
},
"packageManager": "bun@1.2.9"
"packageManager": "bun@1.2.16"
}

16
public/logo-dark.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

16
public/logo-light.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 13 KiB

56
scripts/README-dev.md Normal file
View File

@@ -0,0 +1,56 @@
# Development Environment Setup
This directory contains scripts to help set up a development environment with a pre-configured Gitea instance.
## Default Credentials
For development convenience, the Gitea instance is pre-configured with:
- **Admin Username**: `admin`
- **Admin Password**: `admin123`
- **Gitea URL**: http://localhost:3001
## Files
- `gitea-app.ini` - Pre-configured Gitea settings for development
- `gitea-dev-init.sh` - Initialization script that copies the config on first run
- `gitea-init.sql` - SQL script to create default admin user (not currently used)
## Usage
1. Start the development environment:
```bash
docker compose -f docker-compose.dev.yml down
docker volume rm gitea-mirror_gitea-data gitea-mirror_gitea-config
docker compose -f docker-compose.dev.yml up -d
```
2. Wait for Gitea to start (check logs):
```bash
docker logs -f gitea
```
3. Access Gitea at http://localhost:3001 and login with:
- Username: `admin`
- Password: `admin123`
4. Generate an API token:
- Go to Settings → Applications
- Generate New Token
- Give it a name like "gitea-mirror"
- Select all permissions (for development)
- Copy the token
5. Configure gitea-mirror with the token in your `.env` file or through the web UI.
## Troubleshooting
If Gitea doesn't start properly:
1. Check logs: `docker logs gitea`
2. Ensure volumes are clean: `docker volume rm gitea-mirror_gitea-data gitea-mirror_gitea-config`
3. Restart: `docker compose -f docker-compose.dev.yml up -d`
## Security Note
⚠️ **These credentials are for development only!** Never use these settings in production.

View File

@@ -1,42 +1,47 @@
# LXC Container Deployment Guide
## Overview
Run **Gitea Mirror** in an isolated LXC container, either:
Run **Gitea Mirror** in an isolated LXC container:
1. **Online, on a Proxmox VE host** script pulls everything from GitHub
2. **Offline / LAN-only, on a developer laptop** script pushes your local checkout + Bun ZIP
1. **Proxmox VE (Recommended)** Using the community-maintained script
2. **Local Development** Using the local LXC script for testing
---
## 1. Proxmox VE (online, recommended for prod)
## 1. Proxmox VE Installation (Recommended)
### Prerequisites
* Proxmox VE node with the default `vmbr0` bridge
* Root shell on the node
* Ubuntu 22.04 LXC template present (`pveam update && pveam download ...`)
* Proxmox VE host with internet access
* Root shell access on the Proxmox node
### One-command install
```bash
# Community-maintained script for Proxmox VE by Tobias ([CrazyWolf13](https://github.com/CrazyWolf13))
# at [community-scripts/ProxmoxVED](https://github.com/community-scripts/ProxmoxVED)
sudo bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVED/main/install/gitea-mirror-install.sh)"
# Community-maintained script from the Proxmox VE Community Scripts project
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/gitea-mirror.sh)"
```
What it does:
### What the script does:
* Uses the community-maintained script from ProxmoxVED
* Installs dependencies and Bun runtime
* Clones & builds `arunavo4/gitea-mirror`
* Creates a systemd service and starts it
* Sets up a random `JWT_SECRET` for security
* Creates a privileged Alpine Linux LXC container
* Installs Bun runtime environment
* Clones the Gitea Mirror repository
* Builds the application
* Configures a systemd service for automatic startup
* Sets up the application to run on port 4321
* Generates a secure `JWT_SECRET` automatically
Browse to:
### Accessing Gitea Mirror:
```
http://<container-ip>:4321
```
### Additional Information:
* **Script Source**: [Community Scripts for Proxmox VE](https://github.com/community-scripts/ProxmoxVE)
* **Documentation**: [Gitea Mirror Script Documentation](https://community-scripts.github.io/ProxmoxVE/scripts?id=gitea-mirror)
* **Support**: [Community Scripts Discord](https://discord.gg/fiXVvSHnBU)
---
## 2. Local testing (LXD on a workstation, works offline)

68
scripts/gitea-app.ini Normal file
View File

@@ -0,0 +1,68 @@
APP_NAME = Gitea: Git with a cup of tea
RUN_MODE = prod
[database]
DB_TYPE = sqlite3
PATH = /data/gitea/gitea.db
[repository]
ROOT = /data/git/repositories
[server]
SSH_DOMAIN = localhost
DOMAIN = localhost
HTTP_PORT = 3000
ROOT_URL = http://localhost:3001/
DISABLE_SSH = false
SSH_PORT = 22
LFS_START_SERVER = true
LFS_JWT_SECRET = _oaWNP5sCH5cSECa-K_HvCXeXhg-zN5H0cU5vVQAZr4
OFFLINE_MODE = false
[security]
INSTALL_LOCK = true
SECRET_KEY = vLu5OuX0EweZjDNxKPQ5V9DXXXX8cJiKpJyQylKkMVTrNdFAzlUlNdYLYfiCybu
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3MjgzMTk1MDB9.Lz0cJB_DCLmJFh8FqDX0z9IUcxfY9jPftHEGvz_WeHo
PASSWORD_HASH_ALGO = pbkdf2
[service]
DISABLE_REGISTRATION = false
REQUIRE_SIGNIN_VIEW = false
REGISTER_EMAIL_CONFIRM = false
ENABLE_NOTIFY_MAIL = false
ALLOW_ONLY_EXTERNAL_REGISTRATION = false
ENABLE_CAPTCHA = false
DEFAULT_KEEP_EMAIL_PRIVATE = false
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
DEFAULT_ENABLE_TIMETRACKING = true
NO_REPLY_ADDRESS = noreply.localhost
[oauth2]
JWT_SECRET = gQXt_D8B-VJGCvFfJ9xEj5yp8mOd6fAza8TKc9rJJYw
[lfs]
PATH = /data/git/lfs
[mailer]
ENABLED = false
[openid]
ENABLE_OPENID_SIGNIN = true
ENABLE_OPENID_SIGNUP = true
[session]
PROVIDER = file
[log]
MODE = console
LEVEL = Info
ROOT_PATH = /data/gitea/log
[repository.pull-request]
DEFAULT_MERGE_STYLE = merge
[repository.signing]
DEFAULT_TRUST_MODEL = committer
[actions]
ENABLED = false

14
scripts/gitea-create-admin.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# Create admin user for Gitea development instance
echo "Creating admin user for Gitea..."
docker exec -u git gitea gitea admin user create \
--username admin \
--password admin123 \
--email admin@localhost \
--admin \
--must-change-password=false
echo "Admin user created!"
echo "Username: admin"
echo "Password: admin123"

32
scripts/gitea-dev-init.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/bin/sh
# Initialize Gitea for development with pre-configured settings
# Create necessary directories
mkdir -p /data/gitea/conf
# Copy pre-configured app.ini if it doesn't exist
if [ ! -f /data/gitea/conf/app.ini ]; then
echo "Initializing Gitea with development configuration..."
cp /tmp/app.ini /data/gitea/conf/app.ini
chown 1000:1000 /data/gitea/conf/app.ini
fi
# Start Gitea in background
/usr/bin/entrypoint "$@" &
GITEA_PID=$!
# Wait for Gitea to be ready
echo "Waiting for Gitea to start..."
until wget --no-verbose --tries=1 --spider http://localhost:3000/ 2>/dev/null; do
sleep 2
done
# Create admin user if it doesn't exist
if [ ! -f /data/.admin_created ]; then
echo "Creating default admin user..."
su git -c "gitea admin user create --username admin --password admin123 --email admin@localhost --admin --must-change-password=false" && \
touch /data/.admin_created
fi
# Keep Gitea running in foreground
wait $GITEA_PID

View File

@@ -0,0 +1,81 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Home, ArrowLeft, GitBranch, BookOpen, Settings, FileQuestion } from "lucide-react";
export function NotFound() {
return (
<div className="h-dvh bg-muted/30 flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center pb-4">
<div className="mx-auto mb-4 h-16 w-16 rounded-full bg-muted flex items-center justify-center">
<FileQuestion className="h-8 w-8 text-muted-foreground" />
</div>
<h1 className="text-3xl font-bold">404</h1>
<h2 className="text-xl font-semibold mt-2">Page Not Found</h2>
<p className="text-muted-foreground mt-2">
The page you're looking for doesn't exist or has been moved.
</p>
</CardHeader>
<CardContent className="space-y-6">
{/* Action Buttons */}
<div className="flex flex-col gap-3">
<Button asChild className="w-full">
<a href="/">
<Home className="mr-2 h-4 w-4" />
Go to Dashboard
</a>
</Button>
<Button variant="outline" className="w-full" onClick={() => window.history.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Go Back
</Button>
</div>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">or visit</span>
</div>
</div>
{/* Quick Links */}
<div className="grid grid-cols-3 gap-3">
<a
href="/repositories"
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
>
<GitBranch className="h-5 w-5 text-muted-foreground" />
<span className="text-xs">Repositories</span>
</a>
<a
href="/config"
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
>
<Settings className="h-5 w-5 text-muted-foreground" />
<span className="text-xs">Config</span>
</a>
<a
href="/docs"
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
>
<BookOpen className="h-5 w-5 text-muted-foreground" />
<span className="text-xs">Docs</span>
</a>
</div>
{/* Error Code */}
<div className="text-center pt-2">
<p className="text-xs text-muted-foreground">
Error Code: <code className="font-mono">404_NOT_FOUND</code>
</p>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -60,7 +60,16 @@ export function LoginForm() {
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<img src="/logo.svg" alt="Gitea Mirror" className="h-10 w-10" />
<img
src="/logo-light.svg"
alt="Gitea Mirror Logo"
className="h-10 w-10 dark:hidden"
/>
<img
src="/logo-dark.svg"
alt="Gitea Mirror Logo"
className="h-10 w-10 hidden dark:block"
/>
</div>
<CardTitle className="text-2xl">Gitea Mirror</CardTitle>
<CardDescription>

View File

@@ -3,7 +3,6 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { GitMerge } from 'lucide-react';
import { toast, Toaster } from 'sonner';
import { showErrorToast } from '@/lib/utils';
@@ -66,7 +65,16 @@ export function SignupForm() {
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<GitMerge className="h-10 w-10" />
<img
src="/logo-light.svg"
alt="Gitea Mirror Logo"
className="h-10 w-10 dark:hidden"
/>
<img
src="/logo-dark.svg"
alt="Gitea Mirror Logo"
className="h-10 w-10 hidden dark:block"
/>
</div>
<CardTitle className="text-2xl">Create Admin Account</CardTitle>
<CardDescription>

View File

@@ -0,0 +1,90 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "../ui/checkbox";
import type { AdvancedOptions } from "@/types/config";
import { RefreshCw } from "lucide-react";
interface AdvancedOptionsFormProps {
config: AdvancedOptions;
setConfig: React.Dispatch<React.SetStateAction<AdvancedOptions>>;
onAutoSave?: (config: AdvancedOptions) => Promise<void>;
isAutoSaving?: boolean;
}
export function AdvancedOptionsForm({
config,
setConfig,
onAutoSave,
isAutoSaving = false,
}: AdvancedOptionsFormProps) {
const handleChange = (name: string, checked: boolean) => {
const newConfig = {
...config,
[name]: checked,
};
setConfig(newConfig);
// Auto-save
if (onAutoSave) {
onAutoSave(newConfig);
}
};
return (
<Card className="self-start">
<CardHeader>
<CardTitle className="text-lg font-semibold flex items-center justify-between">
Advanced Options
{isAutoSaving && (
<div className="flex items-center text-sm text-muted-foreground">
<RefreshCw className="h-3 w-3 animate-spin mr-1" />
<span className="text-xs">Auto-saving...</span>
</div>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="flex items-center">
<Checkbox
id="skip-forks"
checked={config.skipForks}
onCheckedChange={(checked) =>
handleChange("skipForks", Boolean(checked))
}
/>
<label
htmlFor="skip-forks"
className="ml-2 text-sm select-none"
>
Skip forks
</label>
</div>
<p className="text-xs text-muted-foreground ml-6">
Don't mirror repositories that are forks of other repositories
</p>
<div className="flex items-center">
<Checkbox
id="skip-starred-issues"
checked={config.skipStarredIssues}
onCheckedChange={(checked) =>
handleChange("skipStarredIssues", Boolean(checked))
}
/>
<label
htmlFor="skip-starred-issues"
className="ml-2 text-sm select-none"
>
Don't fetch issues for starred repos
</label>
</div>
<p className="text-xs text-muted-foreground ml-6">
Skip mirroring issues and pull requests for starred repositories
</p>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,332 @@
import { useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Clock,
Database,
RefreshCw,
Calendar,
Activity,
Zap,
Info
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ScheduleConfig, DatabaseCleanupConfig } from "@/types/config";
import { formatDate } from "@/lib/utils";
interface AutomationSettingsProps {
scheduleConfig: ScheduleConfig;
cleanupConfig: DatabaseCleanupConfig;
onScheduleChange: (config: ScheduleConfig) => void;
onCleanupChange: (config: DatabaseCleanupConfig) => void;
isAutoSavingSchedule?: boolean;
isAutoSavingCleanup?: boolean;
}
const scheduleIntervals = [
{ label: "Every hour", value: 3600 },
{ label: "Every 2 hours", value: 7200 },
{ label: "Every 4 hours", value: 14400 },
{ label: "Every 8 hours", value: 28800 },
{ label: "Every 12 hours", value: 43200 },
{ label: "Daily", value: 86400 },
{ label: "Every 2 days", value: 172800 },
{ label: "Weekly", value: 604800 },
];
const retentionPeriods = [
{ label: "1 day", value: 86400 },
{ label: "3 days", value: 259200 },
{ label: "1 week", value: 604800 },
{ label: "2 weeks", value: 1209600 },
{ label: "1 month", value: 2592000 },
{ label: "2 months", value: 5184000 },
{ label: "3 months", value: 7776000 },
];
function getCleanupInterval(retentionSeconds: number): number {
const days = retentionSeconds / 86400;
if (days <= 1) return 21600; // 6 hours
if (days <= 3) return 43200; // 12 hours
if (days <= 7) return 86400; // 24 hours
if (days <= 30) return 172800; // 48 hours
return 604800; // 1 week
}
function getCleanupFrequencyText(retentionSeconds: number): string {
const days = retentionSeconds / 86400;
if (days <= 1) return "every 6 hours";
if (days <= 3) return "every 12 hours";
if (days <= 7) return "daily";
if (days <= 30) return "every 2 days";
return "weekly";
}
export function AutomationSettings({
scheduleConfig,
cleanupConfig,
onScheduleChange,
onCleanupChange,
isAutoSavingSchedule,
isAutoSavingCleanup,
}: AutomationSettingsProps) {
// Update nextRun for cleanup when settings change
useEffect(() => {
if (cleanupConfig.enabled && !cleanupConfig.nextRun) {
const cleanupInterval = getCleanupInterval(cleanupConfig.retentionDays);
const nextRun = new Date(Date.now() + cleanupInterval * 1000);
onCleanupChange({ ...cleanupConfig, nextRun });
}
}, [cleanupConfig.enabled, cleanupConfig.retentionDays]);
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="text-lg font-semibold flex items-center gap-2">
<Zap className="h-5 w-5" />
Automation & Maintenance
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Automatic Mirroring Section */}
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium flex items-center gap-2">
<RefreshCw className="h-4 w-4 text-primary" />
Automatic Mirroring
</h3>
{isAutoSavingSchedule && (
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
<div className="space-y-4">
<div className="flex items-start space-x-3">
<Checkbox
id="enable-auto-mirror"
checked={scheduleConfig.enabled}
onCheckedChange={(checked) =>
onScheduleChange({ ...scheduleConfig, enabled: !!checked })
}
/>
<div className="space-y-0.5 flex-1">
<Label
htmlFor="enable-auto-mirror"
className="text-sm font-normal cursor-pointer"
>
Enable automatic repository syncing
</Label>
<p className="text-xs text-muted-foreground">
Periodically check GitHub for changes and mirror them to Gitea
</p>
</div>
</div>
{scheduleConfig.enabled && (
<div className="ml-6 space-y-3">
<div>
<Label htmlFor="mirror-interval" className="text-sm">
Sync frequency
</Label>
<Select
value={scheduleConfig.interval.toString()}
onValueChange={(value) =>
onScheduleChange({
...scheduleConfig,
interval: parseInt(value, 10),
})
}
>
<SelectTrigger id="mirror-interval" className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
{scheduleIntervals.map((option) => (
<SelectItem
key={option.value}
value={option.value.toString()}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
<div className="space-y-2 p-3 bg-muted/30 dark:bg-muted/10 rounded-md border border-border/50">
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
Last sync
</span>
<span className="font-medium">
{scheduleConfig.lastRun
? formatDate(scheduleConfig.lastRun)
: "Never"}
</span>
</div>
{scheduleConfig.enabled && scheduleConfig.nextRun && (
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5" />
Next sync
</span>
<span className="font-medium">
{formatDate(scheduleConfig.nextRun)}
</span>
</div>
)}
</div>
</div>
</div>
{/* Database Cleanup Section */}
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium flex items-center gap-2">
<Database className="h-4 w-4 text-primary" />
Database Maintenance
</h3>
{isAutoSavingCleanup && (
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
<div className="space-y-4">
<div className="flex items-start space-x-3">
<Checkbox
id="enable-auto-cleanup"
checked={cleanupConfig.enabled}
onCheckedChange={(checked) =>
onCleanupChange({ ...cleanupConfig, enabled: !!checked })
}
/>
<div className="space-y-0.5 flex-1">
<Label
htmlFor="enable-auto-cleanup"
className="text-sm font-normal cursor-pointer"
>
Enable automatic database cleanup
</Label>
<p className="text-xs text-muted-foreground">
Remove old activity logs and events to optimize storage
</p>
</div>
</div>
{cleanupConfig.enabled && (
<div className="ml-6 space-y-3">
<div>
<Label htmlFor="retention-period" className="text-sm flex items-center gap-2">
Data retention period
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Info className="h-3 w-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs">
Activity logs and events older than this will be removed.
Cleanup frequency is automatically optimized based on your retention period.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<Select
value={cleanupConfig.retentionDays.toString()}
onValueChange={(value) =>
onCleanupChange({
...cleanupConfig,
retentionDays: parseInt(value, 10),
})
}
>
<SelectTrigger id="retention-period" className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
{retentionPeriods.map((option) => (
<SelectItem
key={option.value}
value={option.value.toString()}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{cleanupConfig.enabled && (
<p className="text-xs text-muted-foreground mt-1">
Cleanup runs {getCleanupFrequencyText(cleanupConfig.retentionDays)}
</p>
)}
</div>
</div>
)}
<div className="space-y-2 p-3 bg-muted/30 dark:bg-muted/10 rounded-md border border-border/50">
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
Last cleanup
</span>
<span className="font-medium">
{cleanupConfig.lastRun
? formatDate(cleanupConfig.lastRun)
: "Never"}
</span>
</div>
{cleanupConfig.enabled && cleanupConfig.nextRun && (
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5" />
Next cleanup
</span>
<span className="font-medium">
{cleanupConfig.nextRun
? formatDate(cleanupConfig.nextRun)
: "Calculating..."}
</span>
</div>
)}
</div>
</div>
</div>
</div>
<div className="mt-6 p-4 bg-blue-50/50 dark:bg-blue-950/20 rounded-lg border border-blue-200 dark:border-blue-900">
<div className="flex gap-3">
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
Background Operations
</p>
<p className="text-xs text-blue-800 dark:text-blue-200/80">
These automated tasks run in the background to keep your mirrors up-to-date and maintain optimal database performance.
Choose intervals that match your workflow and repository update frequency.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,8 +1,7 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { GitHubConfigForm } from './GitHubConfigForm';
import { GiteaConfigForm } from './GiteaConfigForm';
import { ScheduleConfigForm } from './ScheduleConfigForm';
import { DatabaseCleanupConfigForm } from './DatabaseCleanupConfigForm';
import { AutomationSettings } from './AutomationSettings';
import type {
ConfigApiResponse,
GiteaConfig,
@@ -11,6 +10,8 @@ import type {
SaveConfigApiResponse,
ScheduleConfig,
DatabaseCleanupConfig,
MirrorOptions,
AdvancedOptions,
} from '@/types/config';
import { Button } from '../ui/button';
import { useAuth } from '@/hooks/useAuth';
@@ -25,6 +26,8 @@ type ConfigState = {
giteaConfig: GiteaConfig;
scheduleConfig: ScheduleConfig;
cleanupConfig: DatabaseCleanupConfig;
mirrorOptions: MirrorOptions;
advancedOptions: AdvancedOptions;
};
export function ConfigTabs() {
@@ -32,12 +35,8 @@ export function ConfigTabs() {
githubConfig: {
username: '',
token: '',
skipForks: false,
privateRepositories: false,
mirrorIssues: false,
mirrorStarred: false,
preserveOrgStructure: false,
skipStarredIssues: false,
},
giteaConfig: {
url: '',
@@ -46,6 +45,7 @@ export function ConfigTabs() {
organization: 'github-mirrors',
visibility: 'public',
starredReposOrg: 'github',
preserveOrgStructure: false,
},
scheduleConfig: {
enabled: false,
@@ -55,6 +55,21 @@ export function ConfigTabs() {
enabled: false,
retentionDays: 604800, // 7 days in seconds
},
mirrorOptions: {
mirrorReleases: false,
mirrorMetadata: false,
metadataComponents: {
issues: false,
pullRequests: false,
labels: false,
milestones: false,
wiki: false,
},
},
advancedOptions: {
skipForks: false,
skipStarredIssues: false,
},
});
const { user } = useAuth();
const [isLoading, setIsLoading] = useState(true);
@@ -82,6 +97,11 @@ export function ConfigTabs() {
return isGitHubValid && isGiteaValid;
};
const isGitHubConfigValid = (): boolean => {
const { githubConfig } = config;
return !!(githubConfig.username.trim() && githubConfig.token.trim());
};
// Removed the problematic useEffect that was causing circular dependencies
// The lastRun and nextRun should be managed by the backend and fetched via API
@@ -132,6 +152,8 @@ export function ConfigTabs() {
giteaConfig: config.giteaConfig,
scheduleConfig: scheduleConfig,
cleanupConfig: config.cleanupConfig,
mirrorOptions: config.mirrorOptions,
advancedOptions: config.advancedOptions,
};
try {
@@ -196,6 +218,8 @@ export function ConfigTabs() {
giteaConfig: config.giteaConfig,
scheduleConfig: config.scheduleConfig,
cleanupConfig: cleanupConfig,
mirrorOptions: config.mirrorOptions,
advancedOptions: config.advancedOptions,
};
try {
@@ -259,6 +283,8 @@ export function ConfigTabs() {
giteaConfig: config.giteaConfig,
scheduleConfig: config.scheduleConfig,
cleanupConfig: config.cleanupConfig,
mirrorOptions: config.mirrorOptions,
advancedOptions: config.advancedOptions,
};
try {
@@ -306,6 +332,8 @@ export function ConfigTabs() {
giteaConfig: giteaConfig,
scheduleConfig: config.scheduleConfig,
cleanupConfig: config.cleanupConfig,
mirrorOptions: config.mirrorOptions,
advancedOptions: config.advancedOptions,
};
try {
@@ -334,6 +362,76 @@ export function ConfigTabs() {
}, 500); // 500ms debounce
}, [user?.id, config.githubConfig, config.scheduleConfig, config.cleanupConfig]);
// Auto-save function for mirror options (handled within GitHub config)
const autoSaveMirrorOptions = useCallback(async (mirrorOptions: MirrorOptions) => {
if (!user?.id) return;
const reqPayload: SaveConfigApiRequest = {
userId: user.id!,
githubConfig: config.githubConfig,
giteaConfig: config.giteaConfig,
scheduleConfig: config.scheduleConfig,
cleanupConfig: config.cleanupConfig,
mirrorOptions: mirrorOptions,
advancedOptions: config.advancedOptions,
};
try {
const response = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload),
});
const result: SaveConfigApiResponse = await response.json();
if (result.success) {
invalidateConfigCache();
} else {
showErrorToast(
`Auto-save failed: ${result.message || 'Unknown error'}`,
toast
);
}
} catch (error) {
showErrorToast(error, toast);
}
}, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig, config.cleanupConfig, config.advancedOptions]);
// Auto-save function for advanced options (handled within GitHub config)
const autoSaveAdvancedOptions = useCallback(async (advancedOptions: AdvancedOptions) => {
if (!user?.id) return;
const reqPayload: SaveConfigApiRequest = {
userId: user.id!,
githubConfig: config.githubConfig,
giteaConfig: config.giteaConfig,
scheduleConfig: config.scheduleConfig,
cleanupConfig: config.cleanupConfig,
mirrorOptions: config.mirrorOptions,
advancedOptions: advancedOptions,
};
try {
const response = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqPayload),
});
const result: SaveConfigApiResponse = await response.json();
if (result.success) {
invalidateConfigCache();
} else {
showErrorToast(
`Auto-save failed: ${result.message || 'Unknown error'}`,
toast
);
}
} catch (error) {
showErrorToast(error, toast);
}
}, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig, config.cleanupConfig, config.mirrorOptions]);
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
@@ -372,6 +470,10 @@ export function ConfigTabs() {
response.scheduleConfig || config.scheduleConfig,
cleanupConfig:
response.cleanupConfig || config.cleanupConfig,
mirrorOptions:
response.mirrorOptions || config.mirrorOptions,
advancedOptions:
response.advancedOptions || config.advancedOptions,
});
}
@@ -398,14 +500,14 @@ export function ConfigTabs() {
</div>
<div className="flex gap-x-4">
<Skeleton className="h-10 w-36" />
<Skeleton className="h-10 w-36" />
</div>
</div>
{/* Content section */}
<div className="flex flex-col gap-y-4">
<div className="flex gap-x-4">
<div className="w-1/2 border rounded-lg p-4">
{/* Content section - Grid layout */}
<div className="space-y-4">
{/* GitHub & Gitea connections - Side by side */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="border rounded-lg p-4">
<div className="flex justify-between items-center mb-4">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-9 w-32" />
@@ -413,10 +515,13 @@ export function ConfigTabs() {
<div className="space-y-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-1 w-full" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-32 w-full" />
</div>
</div>
<div className="w-1/2 border rounded-lg p-4">
<div className="border rounded-lg p-4">
<div className="flex justify-between items-center mb-4">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-9 w-32" />
@@ -425,23 +530,24 @@ export function ConfigTabs() {
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-64 w-full" />
</div>
</div>
</div>
<div className="flex gap-x-4">
<div className="w-1/2 border rounded-lg p-4">
{/* Automation & Maintenance - Full width */}
<div className="border rounded-lg p-4">
<Skeleton className="h-8 w-48 mb-4" />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-6 w-40" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-8 w-32" />
<Skeleton className="h-24 w-full" />
</div>
</div>
<div className="w-1/2 border rounded-lg p-4">
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-6 w-40" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-8 w-32" />
<Skeleton className="h-24 w-full" />
</div>
</div>
</div>
@@ -470,10 +576,10 @@ export function ConfigTabs() {
<div className="flex gap-x-4">
<Button
onClick={handleImportGitHubData}
disabled={isSyncing || !isConfigFormValid()}
disabled={isSyncing || !isGitHubConfigValid()}
title={
!isConfigFormValid()
? 'Please fill all required GitHub and Gitea fields'
!isGitHubConfigValid()
? 'Please fill GitHub username and token fields'
: isSyncing
? 'Import in progress'
: 'Import GitHub Data'
@@ -494,9 +600,10 @@ export function ConfigTabs() {
</div>
</div>
{/* Content section */}
<div className="flex flex-col gap-y-4">
<div className="flex gap-x-4">
{/* Content section - Grid layout */}
<div className="space-y-6">
{/* GitHub & Gitea connections - Side by side */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:items-stretch">
<GitHubConfigForm
config={config.githubConfig}
setConfig={update =>
@@ -508,7 +615,29 @@ export function ConfigTabs() {
: update,
}))
}
mirrorOptions={config.mirrorOptions}
setMirrorOptions={update =>
setConfig(prev => ({
...prev,
mirrorOptions:
typeof update === 'function'
? update(prev.mirrorOptions)
: update,
}))
}
advancedOptions={config.advancedOptions}
setAdvancedOptions={update =>
setConfig(prev => ({
...prev,
advancedOptions:
typeof update === 'function'
? update(prev.advancedOptions)
: update,
}))
}
onAutoSave={autoSaveGitHubConfig}
onMirrorOptionsAutoSave={autoSaveMirrorOptions}
onAdvancedOptionsAutoSave={autoSaveAdvancedOptions}
isAutoSaving={isAutoSavingGitHub}
/>
<GiteaConfigForm
@@ -524,41 +653,26 @@ export function ConfigTabs() {
}
onAutoSave={autoSaveGiteaConfig}
isAutoSaving={isAutoSavingGitea}
githubUsername={config.githubConfig.username}
/>
</div>
<div className="flex gap-x-4">
<div className="w-1/2">
<ScheduleConfigForm
config={config.scheduleConfig}
setConfig={update =>
setConfig(prev => ({
...prev,
scheduleConfig:
typeof update === 'function'
? update(prev.scheduleConfig)
: update,
}))
}
onAutoSave={autoSaveScheduleConfig}
isAutoSaving={isAutoSavingSchedule}
/>
</div>
<div className="w-1/2">
<DatabaseCleanupConfigForm
config={config.cleanupConfig}
setConfig={update =>
setConfig(prev => ({
...prev,
cleanupConfig:
typeof update === 'function'
? update(prev.cleanupConfig)
: update,
}))
}
onAutoSave={autoSaveCleanupConfig}
isAutoSaving={isAutoSavingCleanup}
/>
</div>
{/* Automation & Maintenance - Full width */}
<div>
<AutomationSettings
scheduleConfig={config.scheduleConfig}
cleanupConfig={config.cleanupConfig}
onScheduleChange={(newConfig) => {
setConfig(prev => ({ ...prev, scheduleConfig: newConfig }));
autoSaveScheduleConfig(newConfig);
}}
onCleanupChange={(newConfig) => {
setConfig(prev => ({ ...prev, cleanupConfig: newConfig }));
autoSaveCleanupConfig(newConfig);
}}
isAutoSavingSchedule={isAutoSavingSchedule}
isAutoSavingCleanup={isAutoSavingCleanup}
/>
</div>
</div>
</div>

View File

@@ -94,7 +94,7 @@ export function DatabaseCleanupConfigForm({
];
return (
<Card>
<Card className="self-start">
<CardContent className="pt-6 relative">
{isAutoSaving && (
<div className="absolute top-4 right-4 flex items-center text-sm text-muted-foreground">

View File

@@ -1,50 +1,54 @@
import React, { useEffect, useState } from "react";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { githubApi } from "@/lib/api";
import type { GitHubConfig } from "@/types/config";
import type { GitHubConfig, MirrorOptions, AdvancedOptions } from "@/types/config";
import { Input } from "../ui/input";
import { Checkbox } from "../ui/checkbox";
import { toast } from "sonner";
import { AlertTriangle } from "lucide-react";
import { Alert, AlertDescription } from "../ui/alert";
import { Info } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { GitHubMirrorSettings } from "./GitHubMirrorSettings";
import { Separator } from "../ui/separator";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
interface GitHubConfigFormProps {
config: GitHubConfig;
setConfig: React.Dispatch<React.SetStateAction<GitHubConfig>>;
mirrorOptions: MirrorOptions;
setMirrorOptions: React.Dispatch<React.SetStateAction<MirrorOptions>>;
advancedOptions: AdvancedOptions;
setAdvancedOptions: React.Dispatch<React.SetStateAction<AdvancedOptions>>;
onAutoSave?: (githubConfig: GitHubConfig) => Promise<void>;
onMirrorOptionsAutoSave?: (mirrorOptions: MirrorOptions) => Promise<void>;
onAdvancedOptionsAutoSave?: (advancedOptions: AdvancedOptions) => Promise<void>;
isAutoSaving?: boolean;
}
export function GitHubConfigForm({ config, setConfig, onAutoSave, isAutoSaving }: GitHubConfigFormProps) {
export function GitHubConfigForm({
config,
setConfig,
mirrorOptions,
setMirrorOptions,
advancedOptions,
setAdvancedOptions,
onAutoSave,
onMirrorOptionsAutoSave,
onAdvancedOptionsAutoSave,
isAutoSaving
}: GitHubConfigFormProps) {
const [isLoading, setIsLoading] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
// Special handling for preserveOrgStructure changes
if (
name === "preserveOrgStructure" &&
config.preserveOrgStructure !== checked
) {
toast.info(
"Changing this setting may affect how repositories are accessed in Gitea. " +
"Existing mirrored repositories will still be accessible during sync operations.",
{
duration: 6000,
position: "top-center",
}
);
}
const newConfig = {
...config,
[name]: type === "checkbox" ? checked : value,
@@ -83,7 +87,7 @@ export function GitHubConfigForm({ config, setConfig, onAutoSave, isAutoSaving }
};
return (
<Card className="w-full">
<Card className="w-full h-full flex flex-col">
<CardHeader className="flex flex-row items-center justify-between gap-4">
<CardTitle className="text-lg font-semibold">
GitHub Configuration
@@ -98,7 +102,7 @@ export function GitHubConfigForm({ config, setConfig, onAutoSave, isAutoSaving }
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-y-6">
<CardContent className="flex flex-col gap-y-6 flex-1">
<div>
<label
htmlFor="github-username"
@@ -119,12 +123,49 @@ export function GitHubConfigForm({ config, setConfig, onAutoSave, isAutoSaving }
</div>
<div>
<label
htmlFor="github-token"
className="block text-sm font-medium mb-1.5"
>
GitHub Token
</label>
<div className="flex items-center gap-2 mb-1.5">
<label
htmlFor="github-token"
className="block text-sm font-medium"
>
GitHub Token
</label>
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<span className="inline-flex p-0.5 hover:bg-muted rounded-sm transition-colors cursor-help">
<Info className="h-3.5 w-3.5 text-muted-foreground" />
</span>
</HoverCardTrigger>
<HoverCardContent side="right" align="start" className="w-80">
<div className="space-y-2">
<h4 className="font-medium text-sm">GitHub Token Requirements</h4>
<div className="text-sm space-y-2">
<p>
You need to create a <span className="font-medium">Classic GitHub PAT Token</span> with the following scopes:
</p>
<ul className="ml-4 space-y-1 list-disc">
<li><code className="text-xs bg-muted px-1 py-0.5 rounded">repo</code></li>
<li><code className="text-xs bg-muted px-1 py-0.5 rounded">admin:org</code></li>
</ul>
<p className="text-muted-foreground">
The organization access is required for mirroring organization repositories.
</p>
<p>
Generate tokens at{" "}
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline font-medium"
>
github.com/settings/tokens
</a>
</p>
</div>
</div>
</HoverCardContent>
</HoverCard>
</div>
<Input
id="github-token"
name="token"
@@ -132,7 +173,7 @@ export function GitHubConfigForm({ config, setConfig, onAutoSave, isAutoSaving }
value={config.token}
onChange={handleChange}
className="bg-background"
placeholder="Your GitHub personal access token"
placeholder="Your GitHub token (classic) with repo and admin:org scopes"
/>
<p className="text-xs text-muted-foreground mt-1">
Required for private repositories, organizations, and starred
@@ -140,210 +181,27 @@ export function GitHubConfigForm({ config, setConfig, onAutoSave, isAutoSaving }
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-3">
<div className="flex items-center">
<Checkbox
id="skip-forks"
name="skipForks"
checked={config.skipForks}
onCheckedChange={(checked) =>
handleChange({
target: {
name: "skipForks",
type: "checkbox",
checked: Boolean(checked),
value: "",
},
} as React.ChangeEvent<HTMLInputElement>)
}
/>
<label
htmlFor="skip-forks"
className="ml-2 block text-sm select-none"
>
Skip Forks
</label>
</div>
<Separator />
<div className="flex items-center">
<Checkbox
id="private-repositories"
name="privateRepositories"
checked={config.privateRepositories}
onCheckedChange={(checked) =>
handleChange({
target: {
name: "privateRepositories",
type: "checkbox",
checked: Boolean(checked),
value: "",
},
} as React.ChangeEvent<HTMLInputElement>)
}
/>
<label
htmlFor="private-repositories"
className="ml-2 block text-sm select-none"
>
Mirror Private Repos
</label>
</div>
<GitHubMirrorSettings
githubConfig={config}
mirrorOptions={mirrorOptions}
advancedOptions={advancedOptions}
onGitHubConfigChange={(newConfig) => {
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onMirrorOptionsChange={(newOptions) => {
setMirrorOptions(newOptions);
if (onMirrorOptionsAutoSave) onMirrorOptionsAutoSave(newOptions);
}}
onAdvancedOptionsChange={(newOptions) => {
setAdvancedOptions(newOptions);
if (onAdvancedOptionsAutoSave) onAdvancedOptionsAutoSave(newOptions);
}}
/>
</CardContent>
<div className="flex items-center">
<Checkbox
id="mirror-starred"
name="mirrorStarred"
checked={config.mirrorStarred}
onCheckedChange={(checked) =>
handleChange({
target: {
name: "mirrorStarred",
type: "checkbox",
checked: Boolean(checked),
value: "",
},
} as React.ChangeEvent<HTMLInputElement>)
}
/>
<label
htmlFor="mirror-starred"
className="ml-2 block text-sm select-none"
>
Mirror Starred Repos
</label>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center">
<Checkbox
id="mirror-issues"
name="mirrorIssues"
checked={config.mirrorIssues}
onCheckedChange={(checked) =>
handleChange({
target: {
name: "mirrorIssues",
type: "checkbox",
checked: Boolean(checked),
value: "",
},
} as React.ChangeEvent<HTMLInputElement>)
}
/>
<label
htmlFor="mirror-issues"
className="ml-2 block text-sm select-none"
>
Mirror Issues
</label>
</div>
<div className="flex items-center">
<Checkbox
id="preserve-org-structure"
name="preserveOrgStructure"
checked={config.preserveOrgStructure}
onCheckedChange={(checked) =>
handleChange({
target: {
name: "preserveOrgStructure",
type: "checkbox",
checked: Boolean(checked),
value: "",
},
} as React.ChangeEvent<HTMLInputElement>)
}
/>
<label
htmlFor="preserve-org-structure"
className="ml-2 text-sm select-none flex items-center"
>
Preserve Org Structure
<Tooltip>
<TooltipTrigger asChild>
<span
className="ml-1 cursor-pointer align-middle text-muted-foreground"
role="button"
tabIndex={0}
>
<Info size={16} />
</span>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs text-xs">
When enabled, organization repositories will be mirrored to
the same organization structure in Gitea. When disabled, all
repositories will be mirrored under your Gitea username.
</TooltipContent>
</Tooltip>
</label>
</div>
<div className="flex items-center">
<Checkbox
id="skip-starred-issues"
name="skipStarredIssues"
checked={config.skipStarredIssues}
onCheckedChange={(checked) =>
handleChange({
target: {
name: "skipStarredIssues",
type: "checkbox",
checked: Boolean(checked),
value: "",
},
} as React.ChangeEvent<HTMLInputElement>)
}
/>
<label
htmlFor="skip-starred-issues"
className="ml-2 block text-sm select-none"
>
Skip Issues for Starred Repos
</label>
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex-col items-start">
<Alert variant="note" className="w-full">
<AlertTriangle className="h-4 w-4 text-blue-600 dark:text-blue-400 mr-2" />
<AlertDescription className="text-sm">
<div className="font-semibold mb-1">Note:</div>
<div className="mb-1">
You need to create a{" "}
<span className="font-semibold">Classic GitHub PAT Token</span>{" "}
with following scopes:
</div>
<ul className="ml-4 mb-1 list-disc">
<li>
<code>repo</code>
</li>
<li>
<code>admin:org</code>
</li>
</ul>
<div className="mb-1">
The organization access is required for mirroring organization
repositories.
</div>
<div>
You can generate tokens at{" "}
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="underline font-medium hover:text-blue-900 dark:hover:text-blue-200"
>
github.com/settings/tokens
</a>
.
</div>
</AlertDescription>
</Alert>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,527 @@
import React from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Info,
GitBranch,
Star,
Lock,
Archive,
GitPullRequest,
Tag,
FileText,
MessageSquare,
Target,
BookOpen,
GitFork,
ChevronDown,
Funnel
} from "lucide-react";
import type { GitHubConfig, MirrorOptions, AdvancedOptions } from "@/types/config";
import { cn } from "@/lib/utils";
interface GitHubMirrorSettingsProps {
githubConfig: GitHubConfig;
mirrorOptions: MirrorOptions;
advancedOptions: AdvancedOptions;
onGitHubConfigChange: (config: GitHubConfig) => void;
onMirrorOptionsChange: (options: MirrorOptions) => void;
onAdvancedOptionsChange: (options: AdvancedOptions) => void;
}
export function GitHubMirrorSettings({
githubConfig,
mirrorOptions,
advancedOptions,
onGitHubConfigChange,
onMirrorOptionsChange,
onAdvancedOptionsChange,
}: GitHubMirrorSettingsProps) {
const handleGitHubChange = (field: keyof GitHubConfig, value: boolean) => {
onGitHubConfigChange({ ...githubConfig, [field]: value });
};
const handleMirrorChange = (field: keyof MirrorOptions, value: boolean) => {
onMirrorOptionsChange({ ...mirrorOptions, [field]: value });
};
const handleMetadataComponentChange = (component: keyof MirrorOptions['metadataComponents'], value: boolean) => {
onMirrorOptionsChange({
...mirrorOptions,
metadataComponents: {
...mirrorOptions.metadataComponents,
[component]: value,
},
});
};
const handleAdvancedChange = (field: keyof AdvancedOptions, value: boolean) => {
onAdvancedOptionsChange({ ...advancedOptions, [field]: value });
};
// When metadata is disabled, all components should be disabled
const isMetadataEnabled = mirrorOptions.mirrorMetadata;
// Calculate what content is included for starred repos
const starredRepoContent = {
code: true, // Always included
releases: !advancedOptions.skipStarredIssues && mirrorOptions.mirrorReleases,
issues: !advancedOptions.skipStarredIssues && mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.issues,
pullRequests: !advancedOptions.skipStarredIssues && mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.pullRequests,
wiki: !advancedOptions.skipStarredIssues && mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.wiki,
};
const starredContentCount = Object.entries(starredRepoContent).filter(([key, value]) => key !== 'code' && value).length;
const totalStarredOptions = 4; // releases, issues, PRs, wiki
return (
<div className="space-y-6">
{/* Repository Selection Section */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
<GitBranch className="h-4 w-4" />
Repository Selection
</h4>
<p className="text-xs text-muted-foreground mb-4">
Choose which repositories to include in mirroring
</p>
</div>
<div className="space-y-3">
<div className="flex items-start space-x-3">
<Checkbox
id="private-repos"
checked={githubConfig.privateRepositories}
onCheckedChange={(checked) => handleGitHubChange('privateRepositories', !!checked)}
/>
<div className="space-y-0.5 flex-1">
<Label
htmlFor="private-repos"
className="text-sm font-normal cursor-pointer flex items-center gap-2"
>
<Lock className="h-3.5 w-3.5" />
Include private repositories
</Label>
<p className="text-xs text-muted-foreground">
Mirror your private repositories
</p>
</div>
</div>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start space-x-3">
<Checkbox
id="starred-repos"
checked={githubConfig.mirrorStarred}
onCheckedChange={(checked) => handleGitHubChange('mirrorStarred', !!checked)}
/>
<div className="space-y-0.5 flex-1">
<Label
htmlFor="starred-repos"
className="text-sm font-normal cursor-pointer flex items-center gap-2"
>
<Star className="h-3.5 w-3.5" />
Mirror starred repositories
</Label>
<p className="text-xs text-muted-foreground">
Include repositories you've starred on GitHub
</p>
</div>
</div>
{/* Starred repos content selection - inline to prevent layout shift */}
<div className={cn(
"flex items-center justify-end transition-opacity duration-200",
githubConfig.mirrorStarred ? "opacity-100" : "opacity-0 pointer-events-none"
)}>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!githubConfig.mirrorStarred}
className="h-8 text-xs font-normal min-w-[140px] justify-between"
>
<span>
{advancedOptions.skipStarredIssues ? (
"Code only"
) : starredContentCount === 0 ? (
"Code only"
) : starredContentCount === totalStarredOptions ? (
"Full content"
) : (
`${starredContentCount + 1} of ${totalStarredOptions + 1} selected`
)}
</span>
<ChevronDown className="ml-2 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Starred repos content</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Info className="h-3.5 w-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent side="left" className="max-w-xs">
<p className="text-xs">
Choose what content to mirror from starred repositories.
Selecting "Lightweight mode" will only mirror code for better performance.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Separator className="my-2" />
<div className="space-y-3">
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
<Checkbox
id="starred-lightweight"
checked={advancedOptions.skipStarredIssues}
onCheckedChange={(checked) => handleAdvancedChange('skipStarredIssues', !!checked)}
/>
<Label
htmlFor="starred-lightweight"
className="text-sm font-normal cursor-pointer flex-1"
>
<div className="space-y-0.5">
<div className="font-medium">Lightweight mode</div>
<div className="text-xs text-muted-foreground">
Only mirror code, skip all metadata
</div>
</div>
</Label>
</div>
{!advancedOptions.skipStarredIssues && (
<>
<Separator className="my-2" />
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Content included for starred repos:
</p>
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-xs pl-2">
<GitBranch className="h-3 w-3 text-muted-foreground" />
<span>Source code</span>
<Badge variant="secondary" className="ml-auto text-[10px] px-2 h-4">Always</Badge>
</div>
<div className={cn(
"flex items-center gap-2 text-xs pl-2",
starredRepoContent.releases ? "" : "opacity-50"
)}>
<Tag className="h-3 w-3 text-muted-foreground" />
<span>Releases & Tags</span>
{starredRepoContent.releases && <Badge variant="outline" className="ml-auto text-[10px] px-2 h-4">Included</Badge>}
</div>
<div className={cn(
"flex items-center gap-2 text-xs pl-2",
starredRepoContent.issues ? "" : "opacity-50"
)}>
<MessageSquare className="h-3 w-3 text-muted-foreground" />
<span>Issues</span>
{starredRepoContent.issues && <Badge variant="outline" className="ml-auto text-[10px] px-2 h-4">Included</Badge>}
</div>
<div className={cn(
"flex items-center gap-2 text-xs pl-2",
starredRepoContent.pullRequests ? "" : "opacity-50"
)}>
<GitPullRequest className="h-3 w-3 text-muted-foreground" />
<span>Pull Requests</span>
{starredRepoContent.pullRequests && <Badge variant="outline" className="ml-auto text-[10px] px-2 h-4">Included</Badge>}
</div>
<div className={cn(
"flex items-center gap-2 text-xs pl-2",
starredRepoContent.wiki ? "" : "opacity-50"
)}>
<BookOpen className="h-3 w-3 text-muted-foreground" />
<span>Wiki</span>
{starredRepoContent.wiki && <Badge variant="outline" className="ml-auto text-[10px] px-2 h-4">Included</Badge>}
</div>
</div>
<p className="text-[10px] text-muted-foreground mt-2">
To include more content, enable them in the Content & Data section below
</p>
</div>
</>
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</div>
<Separator />
{/* Content & Data Section */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
<Archive className="h-4 w-4" />
Content & Data
</h4>
<p className="text-xs text-muted-foreground mb-4">
Select what content to mirror from each repository
</p>
</div>
<div className="space-y-3">
{/* Code is always mirrored - shown as info */}
<div className="flex items-center gap-3 p-3 bg-muted/50 dark:bg-muted/20 rounded-md">
<GitBranch className="h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<p className="text-sm">Source code & branches</p>
<p className="text-xs text-muted-foreground">Always included</p>
</div>
<Badge variant="secondary" className="text-xs">Default</Badge>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="mirror-releases"
checked={mirrorOptions.mirrorReleases}
onCheckedChange={(checked) => handleMirrorChange('mirrorReleases', !!checked)}
/>
<div className="space-y-0.5 flex-1">
<Label
htmlFor="mirror-releases"
className="text-sm font-normal cursor-pointer flex items-center gap-2"
>
<Tag className="h-3.5 w-3.5" />
Releases & Tags
</Label>
<p className="text-xs text-muted-foreground">
Include GitHub releases, tags, and associated assets
</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="flex items-start space-x-3">
<Checkbox
id="mirror-metadata"
checked={mirrorOptions.mirrorMetadata}
onCheckedChange={(checked) => handleMirrorChange('mirrorMetadata', !!checked)}
/>
<div className="space-y-0.5 flex-1">
<Label
htmlFor="mirror-metadata"
className="text-sm font-normal cursor-pointer flex items-center gap-2"
>
<FileText className="h-3.5 w-3.5" />
Repository Metadata
</Label>
<p className="text-xs text-muted-foreground">
Mirror issues, pull requests, and other repository data
</p>
</div>
</div>
{/* Metadata multi-select - inline to prevent layout shift */}
<div className={cn(
"flex items-center justify-end transition-opacity duration-200",
mirrorOptions.mirrorMetadata ? "opacity-100" : "opacity-0 pointer-events-none"
)}>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!mirrorOptions.mirrorMetadata}
className="h-8 text-xs font-normal min-w-[140px] justify-between"
>
<span>
{(() => {
const selectedCount = Object.values(mirrorOptions.metadataComponents).filter(Boolean).length;
const totalCount = Object.keys(mirrorOptions.metadataComponents).length;
if (selectedCount === 0) return "No items selected";
if (selectedCount === totalCount) return "All items selected";
return `${selectedCount} of ${totalCount} selected`;
})()}
</span>
<ChevronDown className="ml-2 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-64">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Metadata to mirror</div>
<Button
variant="ghost"
size="sm"
className="h-auto px-2 py-1 text-xs font-normal text-primary hover:text-primary/80"
onClick={() => {
const allSelected = Object.values(mirrorOptions.metadataComponents).every(Boolean);
const newValue = !allSelected;
// Update all metadata components at once
onMirrorOptionsChange({
...mirrorOptions,
metadataComponents: {
issues: newValue,
pullRequests: newValue,
labels: newValue,
milestones: newValue,
wiki: newValue,
},
});
}}
>
{Object.values(mirrorOptions.metadataComponents).every(Boolean) ? 'Deselect all' : 'Select all'}
</Button>
</div>
<Separator className="my-2" />
<div className="space-y-2">
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
<Checkbox
id="metadata-issues-popup"
checked={mirrorOptions.metadataComponents.issues}
onCheckedChange={(checked) => handleMetadataComponentChange('issues', !!checked)}
/>
<Label
htmlFor="metadata-issues-popup"
className="text-sm font-normal cursor-pointer flex items-center gap-2 flex-1"
>
<MessageSquare className="h-3.5 w-3.5 text-muted-foreground" />
Issues
</Label>
</div>
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
<Checkbox
id="metadata-prs-popup"
checked={mirrorOptions.metadataComponents.pullRequests}
onCheckedChange={(checked) => handleMetadataComponentChange('pullRequests', !!checked)}
/>
<Label
htmlFor="metadata-prs-popup"
className="text-sm font-normal cursor-pointer flex items-center gap-2 flex-1"
>
<GitPullRequest className="h-3.5 w-3.5 text-muted-foreground" />
Pull Requests
</Label>
</div>
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
<Checkbox
id="metadata-labels-popup"
checked={mirrorOptions.metadataComponents.labels}
onCheckedChange={(checked) => handleMetadataComponentChange('labels', !!checked)}
/>
<Label
htmlFor="metadata-labels-popup"
className="text-sm font-normal cursor-pointer flex items-center gap-2 flex-1"
>
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
Labels
</Label>
</div>
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
<Checkbox
id="metadata-milestones-popup"
checked={mirrorOptions.metadataComponents.milestones}
onCheckedChange={(checked) => handleMetadataComponentChange('milestones', !!checked)}
/>
<Label
htmlFor="metadata-milestones-popup"
className="text-sm font-normal cursor-pointer flex items-center gap-2 flex-1"
>
<Target className="h-3.5 w-3.5 text-muted-foreground" />
Milestones
</Label>
</div>
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
<Checkbox
id="metadata-wiki-popup"
checked={mirrorOptions.metadataComponents.wiki}
onCheckedChange={(checked) => handleMetadataComponentChange('wiki', !!checked)}
/>
<Label
htmlFor="metadata-wiki-popup"
className="text-sm font-normal cursor-pointer flex items-center gap-2 flex-1"
>
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
Wiki
</Label>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</div>
<Separator />
{/* Filtering & Behavior Section */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
<Funnel className="h-4 w-4" />
Filtering & Behavior
</h4>
<p className="text-xs text-muted-foreground mb-4">
Fine-tune what gets excluded from mirroring
</p>
</div>
<div className="space-y-3">
<div className="flex items-start space-x-3">
<Checkbox
id="skip-forks"
checked={advancedOptions.skipForks}
onCheckedChange={(checked) => handleAdvancedChange('skipForks', !!checked)}
/>
<div className="space-y-0.5 flex-1">
<Label
htmlFor="skip-forks"
className="text-sm font-normal cursor-pointer flex items-center gap-2"
>
<GitFork className="h-3.5 w-3.5" />
Skip forked repositories
</Label>
<p className="text-xs text-muted-foreground">
Exclude repositories that are forks of other projects
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,40 +1,92 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { giteaApi } from "@/lib/api";
import type { GiteaConfig, GiteaOrgVisibility } from "@/types/config";
import type { GiteaConfig, MirrorStrategy } from "@/types/config";
import { toast } from "sonner";
import { OrganizationStrategy } from "./OrganizationStrategy";
import { OrganizationConfiguration } from "./OrganizationConfiguration";
import { Separator } from "../ui/separator";
interface GiteaConfigFormProps {
config: GiteaConfig;
setConfig: React.Dispatch<React.SetStateAction<GiteaConfig>>;
onAutoSave?: (giteaConfig: GiteaConfig) => Promise<void>;
isAutoSaving?: boolean;
githubUsername?: string;
}
export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving }: GiteaConfigFormProps) {
export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, githubUsername }: GiteaConfigFormProps) {
const [isLoading, setIsLoading] = useState(false);
// Derive the mirror strategy from existing config for backward compatibility
const getMirrorStrategy = (): MirrorStrategy => {
if (config.mirrorStrategy) return config.mirrorStrategy;
if (config.preserveOrgStructure) return "preserve";
if (config.organization && config.organization !== config.username) return "single-org";
return "flat-user";
};
const [mirrorStrategy, setMirrorStrategy] = useState<MirrorStrategy>(getMirrorStrategy());
// Update config when strategy changes
useEffect(() => {
const newConfig = { ...config };
switch (mirrorStrategy) {
case "preserve":
newConfig.preserveOrgStructure = true;
newConfig.mirrorStrategy = "preserve";
break;
case "single-org":
newConfig.preserveOrgStructure = false;
newConfig.mirrorStrategy = "single-org";
if (!newConfig.organization) {
newConfig.organization = "github-mirrors";
}
break;
case "flat-user":
newConfig.preserveOrgStructure = false;
newConfig.mirrorStrategy = "flat-user";
newConfig.organization = "";
break;
}
setConfig(newConfig);
if (onAutoSave) {
onAutoSave(newConfig);
}
}, [mirrorStrategy]);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value } = e.target;
const { name, value, type } = e.target;
const checked = type === "checkbox" ? (e.target as HTMLInputElement).checked : undefined;
// Special handling for preserveOrgStructure changes
if (
name === "preserveOrgStructure" &&
config.preserveOrgStructure !== checked
) {
toast.info(
"Changing this setting may affect how repositories are accessed in Gitea. " +
"Existing mirrored repositories will still be accessible during sync operations.",
{
duration: 6000,
position: "top-center",
}
);
}
const newConfig = {
...config,
[name]: value,
[name]: type === "checkbox" ? checked : value,
};
setConfig(newConfig);
@@ -71,7 +123,7 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving }:
};
return (
<Card className="w-full">
<Card className="w-full h-full flex flex-col">
<CardHeader className="flex flex-row items-center justify-between gap-4">
<CardTitle className="text-lg font-semibold">
Gitea Configuration
@@ -86,7 +138,7 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving }:
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-y-6">
<CardContent className="flex flex-col gap-y-6 flex-1">
<div>
<label
htmlFor="gitea-username"
@@ -148,89 +200,47 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving }:
</p>
</div>
<div>
<label
htmlFor="organization"
className="block text-sm font-medium mb-1.5"
>
Default Organization (Optional)
</label>
<input
id="organization"
name="organization"
type="text"
value={config.organization}
onChange={handleChange}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Organization name"
/>
<p className="text-xs text-muted-foreground mt-1">
If specified, repositories will be mirrored to this organization.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
htmlFor="visibility"
className="block text-sm font-medium mb-1.5"
>
Organization Visibility
</label>
<Select
name="visibility"
value={config.visibility}
onValueChange={(value) =>
handleChange({
target: { name: "visibility", value },
} as React.ChangeEvent<HTMLInputElement>)
}
>
<SelectTrigger className="w-full border border-input dark:bg-background dark:hover:bg-background">
<SelectValue placeholder="Select visibility" />
</SelectTrigger>
<SelectContent className="bg-background text-foreground border border-input shadow-sm">
{(["public", "private", "limited"] as GiteaOrgVisibility[]).map(
(option) => (
<SelectItem
key={option}
value={option}
className="cursor-pointer text-sm px-3 py-2 hover:bg-accent focus:bg-accent focus:text-accent-foreground"
>
{option.charAt(0).toUpperCase() + option.slice(1)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
<div>
<label
htmlFor="starred-repos-org"
className="block text-sm font-medium mb-1.5"
>
Starred Repositories Organization
</label>
<input
id="starred-repos-org"
name="starredReposOrg"
type="text"
value={config.starredReposOrg}
onChange={handleChange}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="github"
/>
<p className="text-xs text-muted-foreground mt-1">
Organization for starred repositories (default: github)
</p>
</div>
</div>
<Separator />
<OrganizationStrategy
strategy={mirrorStrategy}
destinationOrg={config.organization}
starredReposOrg={config.starredReposOrg}
onStrategyChange={setMirrorStrategy}
githubUsername={githubUsername}
giteaUsername={config.username}
/>
<Separator />
<OrganizationConfiguration
strategy={mirrorStrategy}
destinationOrg={config.organization}
starredReposOrg={config.starredReposOrg}
personalReposOrg={config.personalReposOrg}
visibility={config.visibility}
onDestinationOrgChange={(org) => {
const newConfig = { ...config, organization: org };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onStarredReposOrgChange={(org) => {
const newConfig = { ...config, starredReposOrg: org };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onPersonalReposOrgChange={(org) => {
const newConfig = { ...config, personalReposOrg: org };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onVisibilityChange={(visibility) => {
const newConfig = { ...config, visibility };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
/>
</CardContent>
<CardFooter className="">
{/* Footer content can be added here if needed */}
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,226 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "../ui/checkbox";
import type { MirrorOptions } from "@/types/config";
import { RefreshCw, Info } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
interface MirrorOptionsFormProps {
config: MirrorOptions;
setConfig: React.Dispatch<React.SetStateAction<MirrorOptions>>;
onAutoSave?: (config: MirrorOptions) => Promise<void>;
isAutoSaving?: boolean;
}
export function MirrorOptionsForm({
config,
setConfig,
onAutoSave,
isAutoSaving = false,
}: MirrorOptionsFormProps) {
const handleChange = (name: string, checked: boolean) => {
let newConfig = { ...config };
if (name === "mirrorMetadata") {
newConfig.mirrorMetadata = checked;
// If disabling metadata, also disable all components
if (!checked) {
newConfig.metadataComponents = {
issues: false,
pullRequests: false,
labels: false,
milestones: false,
wiki: false,
};
}
} else if (name.startsWith("metadataComponents.")) {
const componentName = name.split(".")[1] as keyof typeof config.metadataComponents;
newConfig.metadataComponents = {
...config.metadataComponents,
[componentName]: checked,
};
} else {
newConfig = {
...config,
[name]: checked,
};
}
setConfig(newConfig);
// Auto-save
if (onAutoSave) {
onAutoSave(newConfig);
}
};
return (
<Card className="self-start">
<CardHeader>
<CardTitle className="text-lg font-semibold flex items-center justify-between">
Mirror Options
{isAutoSaving && (
<div className="flex items-center text-sm text-muted-foreground">
<RefreshCw className="h-3 w-3 animate-spin mr-1" />
<span className="text-xs">Auto-saving...</span>
</div>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Repository Content */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-foreground">Repository Content</h4>
<div className="flex items-center">
<Checkbox
id="mirror-releases"
checked={config.mirrorReleases}
onCheckedChange={(checked) =>
handleChange("mirrorReleases", Boolean(checked))
}
/>
<label
htmlFor="mirror-releases"
className="ml-2 text-sm select-none flex items-center"
>
Mirror releases
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-1 cursor-pointer text-muted-foreground">
<Info size={14} />
</span>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs text-xs">
Include GitHub releases and tags in the mirror
</TooltipContent>
</Tooltip>
</label>
</div>
<div className="flex items-center">
<Checkbox
id="mirror-metadata"
checked={config.mirrorMetadata}
onCheckedChange={(checked) =>
handleChange("mirrorMetadata", Boolean(checked))
}
/>
<label
htmlFor="mirror-metadata"
className="ml-2 text-sm select-none flex items-center"
>
Mirror metadata
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-1 cursor-pointer text-muted-foreground">
<Info size={14} />
</span>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs text-xs">
Include issues, pull requests, labels, milestones, and wiki
</TooltipContent>
</Tooltip>
</label>
</div>
{/* Metadata Components */}
{config.mirrorMetadata && (
<div className="ml-6 space-y-3 border-l-2 border-muted pl-4">
<h5 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Metadata Components
</h5>
<div className="grid grid-cols-1 gap-2">
<div className="flex items-center">
<Checkbox
id="metadata-issues"
checked={config.metadataComponents.issues}
onCheckedChange={(checked) =>
handleChange("metadataComponents.issues", Boolean(checked))
}
disabled={!config.mirrorMetadata}
/>
<label
htmlFor="metadata-issues"
className="ml-2 text-sm select-none"
>
Issues
</label>
</div>
<div className="flex items-center">
<Checkbox
id="metadata-pullRequests"
checked={config.metadataComponents.pullRequests}
onCheckedChange={(checked) =>
handleChange("metadataComponents.pullRequests", Boolean(checked))
}
disabled={!config.mirrorMetadata}
/>
<label
htmlFor="metadata-pullRequests"
className="ml-2 text-sm select-none"
>
Pull requests
</label>
</div>
<div className="flex items-center">
<Checkbox
id="metadata-labels"
checked={config.metadataComponents.labels}
onCheckedChange={(checked) =>
handleChange("metadataComponents.labels", Boolean(checked))
}
disabled={!config.mirrorMetadata}
/>
<label
htmlFor="metadata-labels"
className="ml-2 text-sm select-none"
>
Labels
</label>
</div>
<div className="flex items-center">
<Checkbox
id="metadata-milestones"
checked={config.metadataComponents.milestones}
onCheckedChange={(checked) =>
handleChange("metadataComponents.milestones", Boolean(checked))
}
disabled={!config.mirrorMetadata}
/>
<label
htmlFor="metadata-milestones"
className="ml-2 text-sm select-none"
>
Milestones
</label>
</div>
<div className="flex items-center">
<Checkbox
id="metadata-wiki"
checked={config.metadataComponents.wiki}
onCheckedChange={(checked) =>
handleChange("metadataComponents.wiki", Boolean(checked))
}
disabled={!config.mirrorMetadata}
/>
<label
htmlFor="metadata-wiki"
className="ml-2 text-sm select-none"
>
Wiki
</label>
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,193 @@
import React from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Star, Globe, Lock, Shield, Info, MonitorCog } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { MirrorStrategy, GiteaOrgVisibility } from "@/types/config";
interface OrganizationConfigurationProps {
strategy: MirrorStrategy;
destinationOrg?: string;
starredReposOrg?: string;
personalReposOrg?: string;
visibility: GiteaOrgVisibility;
onDestinationOrgChange: (org: string) => void;
onStarredReposOrgChange: (org: string) => void;
onPersonalReposOrgChange: (org: string) => void;
onVisibilityChange: (visibility: GiteaOrgVisibility) => void;
}
const visibilityOptions = [
{ value: "public" as GiteaOrgVisibility, label: "Public", icon: Globe, description: "Visible to everyone" },
{ value: "private" as GiteaOrgVisibility, label: "Private", icon: Lock, description: "Visible to members only" },
{ value: "limited" as GiteaOrgVisibility, label: "Limited", icon: Shield, description: "Visible to logged-in users" },
];
export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps> = ({
strategy,
destinationOrg,
starredReposOrg,
personalReposOrg,
visibility,
onDestinationOrgChange,
onStarredReposOrgChange,
onPersonalReposOrgChange,
onVisibilityChange,
}) => {
return (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
<MonitorCog className="h-4 w-4" />
Organization Configuration
</h4>
</div>
{/* First row - Organization inputs with consistent layout */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Left column - always shows starred repos org */}
<div className="space-y-1">
<Label htmlFor="starredReposOrg" className="text-sm font-normal flex items-center gap-2">
<Star className="h-3.5 w-3.5" />
Starred Repos Organization
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Info className="h-3.5 w-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Starred repositories will be organized separately in this organization</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<Input
id="starredReposOrg"
value={starredReposOrg || ""}
onChange={(e) => onStarredReposOrgChange(e.target.value)}
placeholder="starred"
className=""
/>
<p className="text-xs text-muted-foreground mt-1">
Keep starred repos organized separately
</p>
</div>
{/* Right column - shows destination org for single-org, personal repos org for preserve, empty div for others */}
{strategy === "single-org" ? (
<div className="space-y-1">
<Label htmlFor="destinationOrg" className="text-sm font-normal flex items-center gap-2">
Destination Organization
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Info className="h-3.5 w-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>All repositories will be mirrored to this organization</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<Input
id="destinationOrg"
value={destinationOrg || ""}
onChange={(e) => onDestinationOrgChange(e.target.value)}
placeholder="github-mirrors"
className=""
/>
<p className="text-xs text-muted-foreground mt-1">
Organization for consolidated repositories
</p>
</div>
) : strategy === "preserve" ? (
<div className="space-y-1">
<Label htmlFor="personalReposOrg" className="text-sm font-normal flex items-center gap-2">
Personal Repos Organization
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Info className="h-3.5 w-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Override where your personal repositories are mirrored (leave empty to use your username)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<Input
id="personalReposOrg"
value={personalReposOrg || ""}
onChange={(e) => onPersonalReposOrgChange(e.target.value)}
placeholder="my-personal-mirrors"
className=""
/>
<p className="text-xs text-muted-foreground mt-1">
Override destination for your personal repos
</p>
</div>
) : (
<div className="hidden md:block" />
)}
</div>
{/* Second row - Organization Visibility (always shown) */}
<div className="space-y-2">
<Label className="text-sm font-normal flex items-center gap-2">
Organization Visibility
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Info className="h-3.5 w-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Default visibility for newly created organizations</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<div className="grid grid-cols-3 gap-2">
{visibilityOptions.map((option) => {
const Icon = option.icon;
const isSelected = visibility === option.value;
return (
<TooltipProvider key={option.value}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => onVisibilityChange(option.value)}
className={cn(
"flex items-center justify-between px-3 py-2 rounded-md text-sm transition-all",
"border group",
isSelected
? "bg-accent border-accent-foreground/20"
: "bg-background hover:bg-accent/50 border-input"
)}
>
<div className="flex items-center gap-2">
<Icon className="h-3.5 w-3.5" />
<span>{option.label}</span>
</div>
<Info className="h-3 w-3 text-muted-foreground opacity-50 group-hover:opacity-100 transition-opacity" />
</button>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">{option.description}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
})}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,312 @@
import React from "react";
import { Card } from "@/components/ui/card";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Info, GitBranch, FolderTree, Star, Building2, User, Building } from "lucide-react";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
export type MirrorStrategy = "preserve" | "single-org" | "flat-user";
interface OrganizationStrategyProps {
strategy: MirrorStrategy;
destinationOrg?: string;
starredReposOrg?: string;
onStrategyChange: (strategy: MirrorStrategy) => void;
githubUsername?: string;
giteaUsername?: string;
}
const strategyConfig = {
preserve: {
title: "Preserve Structure",
icon: FolderTree,
description: "Keep the exact same organization structure as GitHub",
color: "text-blue-600 dark:text-blue-400",
bgColor: "bg-blue-50 dark:bg-blue-950/20",
borderColor: "border-blue-200 dark:border-blue-900",
repoColors: {
bg: "bg-blue-50 dark:bg-blue-950/30",
icon: "text-blue-600 dark:text-blue-400"
}
},
"single-org": {
title: "Single Organization",
icon: Building2,
description: "Consolidate all repositories into one Gitea organization",
color: "text-purple-600 dark:text-purple-400",
bgColor: "bg-purple-50 dark:bg-purple-950/20",
borderColor: "border-purple-200 dark:border-purple-900",
repoColors: {
bg: "bg-purple-50 dark:bg-purple-950/30",
icon: "text-purple-600 dark:text-purple-400"
}
},
"flat-user": {
title: "User Repositories",
icon: User,
description: "Place all repositories directly under your user account",
color: "text-green-600 dark:text-green-400",
bgColor: "bg-green-50 dark:bg-green-950/20",
borderColor: "border-green-200 dark:border-green-900",
repoColors: {
bg: "bg-green-50 dark:bg-green-950/30",
icon: "text-green-600 dark:text-green-400"
}
}
};
const MappingPreview: React.FC<{
strategy: MirrorStrategy;
config: typeof strategyConfig.preserve;
destinationOrg?: string;
starredReposOrg?: string;
githubUsername?: string;
giteaUsername?: string;
}> = ({ strategy, config, destinationOrg, starredReposOrg, githubUsername, giteaUsername }) => {
const displayGithubUsername = githubUsername || "<username>";
const displayGiteaUsername = giteaUsername || "<username>";
const isGithubPlaceholder = !githubUsername;
const isGiteaPlaceholder = !giteaUsername;
if (strategy === "preserve") {
return (
<div className="flex items-center justify-between gap-6">
<div className="flex-1">
<div className="text-xs font-medium text-muted-foreground mb-2">GitHub</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
<User className="h-3 w-3" />
<span className={cn(isGithubPlaceholder && "text-muted-foreground italic")}>{displayGithubUsername}/my-repo</span>
</div>
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
<Building2 className="h-3 w-3" />
<span>my-org/team-repo</span>
</div>
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
<Star className="h-3 w-3" />
<span>awesome/starred-repo</span>
</div>
</div>
</div>
<div className="flex items-center">
<GitBranch className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="text-xs font-medium text-muted-foreground mb-2">Gitea</div>
<div className="space-y-1.5">
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
<User className={cn("h-3 w-3", config.repoColors.icon)} />
<span className={cn(isGiteaPlaceholder && "text-muted-foreground italic")}>{displayGiteaUsername}/my-repo</span>
</div>
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
<span>my-org/team-repo</span>
</div>
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
<span>{starredReposOrg || "starred"}/starred-repo</span>
</div>
</div>
</div>
</div>
);
}
if (strategy === "single-org") {
return (
<div className="flex items-center justify-between gap-6">
<div className="flex-1">
<div className="text-xs font-medium text-muted-foreground mb-2">GitHub</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
<User className="h-3 w-3" />
<span className={cn(isGithubPlaceholder && "text-muted-foreground italic")}>{displayGithubUsername}/my-repo</span>
</div>
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
<Building2 className="h-3 w-3" />
<span>my-org/team-repo</span>
</div>
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
<Star className="h-3 w-3" />
<span>awesome/starred-repo</span>
</div>
</div>
</div>
<div className="flex items-center">
<GitBranch className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="text-xs font-medium text-muted-foreground mb-2">Gitea</div>
<div className="space-y-1.5">
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
<span>{destinationOrg || "github-mirrors"}/my-repo</span>
</div>
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
<span>{destinationOrg || "github-mirrors"}/team-repo</span>
</div>
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
<span>{starredReposOrg || "starred"}/starred-repo</span>
</div>
</div>
</div>
</div>
);
}
if (strategy === "flat-user") {
return (
<div className="flex items-center justify-between gap-6">
<div className="flex-1">
<div className="text-xs font-medium text-muted-foreground mb-2">GitHub</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
<User className="h-3 w-3" />
<span className={cn(isGithubPlaceholder && "text-muted-foreground italic")}>{displayGithubUsername}/my-repo</span>
</div>
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
<Building2 className="h-3 w-3" />
<span>my-org/team-repo</span>
</div>
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
<Star className="h-3 w-3" />
<span>awesome/starred-repo</span>
</div>
</div>
</div>
<div className="flex items-center">
<GitBranch className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="text-xs font-medium text-muted-foreground mb-2">Gitea</div>
<div className="space-y-1.5">
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
<User className={cn("h-3 w-3", config.repoColors.icon)} />
<span className={cn(isGiteaPlaceholder && "text-muted-foreground italic")}>{displayGiteaUsername}/my-repo</span>
</div>
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
<User className={cn("h-3 w-3", config.repoColors.icon)} />
<span className={cn(isGiteaPlaceholder && "text-muted-foreground italic")}>{displayGiteaUsername}/team-repo</span>
</div>
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
<span>{starredReposOrg || "starred"}/starred-repo</span>
</div>
</div>
</div>
</div>
);
}
return null;
};
export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
strategy,
destinationOrg,
starredReposOrg,
onStrategyChange,
githubUsername,
giteaUsername,
}) => {
return (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
<Building className="h-4 w-4" />
Organization Strategy
</h4>
<p className="text-xs text-muted-foreground mb-4">
Choose how your repositories will be organized in Gitea
</p>
</div>
<RadioGroup value={strategy} onValueChange={onStrategyChange}>
<div className="grid grid-cols-1 2xl:grid-cols-2 gap-4">
{(Object.entries(strategyConfig) as [MirrorStrategy, typeof strategyConfig.preserve][]).map(([key, config]) => {
const isSelected = strategy === key;
const Icon = config.icon;
return (
<div key={key}>
<label htmlFor={key} className="cursor-pointer">
<Card
className={cn(
"relative",
isSelected && `${config.borderColor} border-2`,
!isSelected && "border-muted"
)}
>
<div className="p-3">
<div className="flex items-center gap-3">
<RadioGroupItem
value={key}
id={key}
/>
<div className={cn(
"rounded-lg p-2",
isSelected ? config.bgColor : "bg-muted dark:bg-muted/50"
)}>
<Icon className={cn(
"h-4 w-4",
isSelected ? config.color : "text-muted-foreground dark:text-muted-foreground/70"
)} />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-medium text-sm">{config.title}</h4>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{config.description}
</p>
</div>
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<span
className="inline-flex p-1.5 hover:bg-muted rounded-md transition-colors cursor-help"
onClick={(e) => e.stopPropagation()}
>
<Info className="h-4 w-4 text-muted-foreground" />
</span>
</HoverCardTrigger>
<HoverCardContent side="left" align="center" className="w-[500px]">
<div className="space-y-3">
<h4 className="font-medium text-sm">Repository Mapping Preview</h4>
<MappingPreview
strategy={key}
config={config}
destinationOrg={destinationOrg}
starredReposOrg={starredReposOrg}
githubUsername={githubUsername}
giteaUsername={giteaUsername}
/>
</div>
</HoverCardContent>
</HoverCard>
</div>
</div>
</Card>
</label>
</div>
);
})}
</div>
</RadioGroup>
</div>
);
};

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { ScheduleConfigForm } from './ScheduleConfigForm';
import { DatabaseCleanupConfigForm } from './DatabaseCleanupConfigForm';
import { Separator } from '../ui/separator';
import type { ScheduleConfig, DatabaseCleanupConfig } from '@/types/config';
interface ScheduleAndCleanupFormProps {
scheduleConfig: ScheduleConfig;
cleanupConfig: DatabaseCleanupConfig;
setScheduleConfig: (update: ScheduleConfig | ((prev: ScheduleConfig) => ScheduleConfig)) => void;
setCleanupConfig: (update: DatabaseCleanupConfig | ((prev: DatabaseCleanupConfig) => DatabaseCleanupConfig)) => void;
onAutoSaveSchedule?: (config: ScheduleConfig) => Promise<void>;
onAutoSaveCleanup?: (config: DatabaseCleanupConfig) => Promise<void>;
isAutoSavingSchedule?: boolean;
isAutoSavingCleanup?: boolean;
}
export function ScheduleAndCleanupForm({
scheduleConfig,
cleanupConfig,
setScheduleConfig,
setCleanupConfig,
onAutoSaveSchedule,
onAutoSaveCleanup,
isAutoSavingSchedule,
isAutoSavingCleanup,
}: ScheduleAndCleanupFormProps) {
return (
<div className="space-y-6">
<ScheduleConfigForm
config={scheduleConfig}
setConfig={setScheduleConfig}
onAutoSave={onAutoSaveSchedule}
isAutoSaving={isAutoSavingSchedule}
/>
<Separator />
<DatabaseCleanupConfigForm
config={cleanupConfig}
setConfig={setCleanupConfig}
onAutoSave={onAutoSaveCleanup}
isAutoSaving={isAutoSavingCleanup}
/>
</div>
);
}

View File

@@ -54,7 +54,7 @@ export function ScheduleConfigForm({
];
return (
<Card>
<Card className="self-start">
<CardContent className="pt-6 relative">
{isAutoSaving && (
<div className="absolute top-4 right-4 flex items-center text-sm text-muted-foreground">

View File

@@ -64,7 +64,16 @@ export function Header({ currentPage, onNavigate }: HeaderProps) {
}}
className="flex items-center gap-2 py-1 hover:opacity-80 transition-opacity"
>
<img src="/logo.svg" alt="Gitea Mirror" className="h-6 w-6" />
<img
src="/logo-light.svg"
alt="Gitea Mirror Logo"
className="h-6 w-6 dark:hidden"
/>
<img
src="/logo-dark.svg"
alt="Gitea Mirror Logo"
className="h-6 w-6 hidden dark:block"
/>
<span className="text-xl font-bold">Gitea Mirror</span>
</button>

View File

@@ -0,0 +1,193 @@
import { useState } from "react";
import { ArrowRight, Edit3, RotateCcw, CheckCircle2, XCircle, Building2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
interface MirrorDestinationEditorProps {
organizationId: string;
organizationName: string;
currentDestination?: string;
onUpdate: (newDestination: string | null) => Promise<void>;
isUpdating?: boolean;
className?: string;
}
export function MirrorDestinationEditor({
organizationId,
organizationName,
currentDestination,
onUpdate,
isUpdating = false,
className,
}: MirrorDestinationEditorProps) {
const [isOpen, setIsOpen] = useState(false);
const [editValue, setEditValue] = useState(currentDestination || "");
const [isLoading, setIsLoading] = useState(false);
const hasOverride = currentDestination && currentDestination !== organizationName;
const effectiveDestination = currentDestination || organizationName;
const handleSave = async () => {
const trimmedValue = editValue.trim();
const newDestination = trimmedValue === "" || trimmedValue === organizationName
? null
: trimmedValue;
setIsLoading(true);
try {
await onUpdate(newDestination);
setIsOpen(false);
toast.success(
newDestination
? `Destination updated to: ${newDestination}`
: "Destination reset to default"
);
} catch (error) {
toast.error("Failed to update destination");
} finally {
setIsLoading(false);
}
};
const handleReset = async () => {
setEditValue("");
await handleSave();
};
const handleCancel = () => {
setEditValue(currentDestination || "");
setIsOpen(false);
};
return (
<div className={cn("flex items-center gap-2", className)}>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Building2 className="h-3 w-3" />
<span className="font-medium">{organizationName}</span>
<ArrowRight className="h-3 w-3" />
<span className={cn(
"font-medium",
hasOverride && "text-orange-600 dark:text-orange-400"
)}>
{effectiveDestination}
</span>
{hasOverride && (
<Badge variant="outline" className="h-4 px-1 text-[10px] border-orange-600 text-orange-600 dark:border-orange-400 dark:text-orange-400">
custom
</Badge>
)}
</div>
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 opacity-60 hover:opacity-100"
title="Edit mirror destination"
disabled={isUpdating || isLoading}
>
<Edit3 className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end">
<div className="space-y-4">
<div>
<h4 className="font-medium text-sm mb-1">Mirror Destination</h4>
<p className="text-xs text-muted-foreground">
Customize where this organization's repositories are mirrored to in Gitea.
</p>
</div>
<div className="space-y-3">
{/* Visual Preview */}
<div className="rounded-md bg-muted/50 p-3 space-y-2">
<div className="text-xs font-medium text-muted-foreground">Preview</div>
<div className="flex items-center gap-2 text-sm">
<div className="flex items-center gap-1.5">
<Building2 className="h-4 w-4 text-muted-foreground" />
<span>{organizationName}</span>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<div className="flex items-center gap-1.5">
<Building2 className="h-4 w-4 text-primary" />
<span className="font-medium text-primary">
{editValue.trim() || organizationName}
</span>
</div>
</div>
</div>
{/* Input Field */}
<div className="space-y-2">
<Label htmlFor="destination" className="text-xs">
Destination Organization
</Label>
<Input
id="destination"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
placeholder={organizationName}
className="h-8"
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground">
Leave empty to use the default GitHub organization name
</p>
</div>
{/* Quick Actions */}
{hasOverride && (
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={isLoading}
className="w-full h-8 text-xs"
>
<RotateCcw className="h-3 w-3 mr-2" />
Reset to Default ({organizationName})
</Button>
)}
</div>
{/* Action Buttons */}
<div className="flex gap-2 justify-end">
<Button
variant="outline"
size="sm"
onClick={handleCancel}
disabled={isLoading}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={isLoading || (editValue.trim() === (currentDestination || ""))}
>
{isLoading ? (
<>
<CheckCircle2 className="h-3 w-3 mr-2 animate-spin" />
Saving...
</>
) : (
"Save"
)}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -404,6 +404,7 @@ export function Organization() {
loadingOrgIds={loadingOrgIds}
onMirror={handleMirrorOrg}
onAddOrganization={() => setIsDialogOpen(true)}
onRefresh={() => fetchOrganizations(false)}
/>
<AddOrganizationDialog

View File

@@ -1,14 +1,16 @@
import { useMemo } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Plus, RefreshCw, Building2 } from "lucide-react";
import { SiGithub } from "react-icons/si";
import { Badge } from "@/components/ui/badge";
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock } from "lucide-react";
import { SiGithub, SiGitea } from "react-icons/si";
import type { Organization } from "@/lib/db/schema";
import type { FilterParams } from "@/types/filter";
import Fuse from "fuse.js";
import { Skeleton } from "@/components/ui/skeleton";
import { Checkbox } from "@/components/ui/checkbox";
import { getStatusColor } from "@/lib/utils";
import { cn } from "@/lib/utils";
import { MirrorDestinationEditor } from "./MirrorDestinationEditor";
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
interface OrganizationListProps {
organizations: Organization[];
@@ -18,8 +20,25 @@ interface OrganizationListProps {
onMirror: ({ orgId }: { orgId: string }) => Promise<void>;
loadingOrgIds: Set<string>;
onAddOrganization?: () => void;
onRefresh?: () => Promise<void>;
}
// Helper function to get status badge variant and icon
const getStatusBadge = (status: string | null) => {
switch (status) {
case "imported":
return { variant: "secondary" as const, label: "Not Mirrored", icon: null };
case "mirroring":
return { variant: "outline" as const, label: "Mirroring", icon: Clock };
case "mirrored":
return { variant: "default" as const, label: "Mirrored", icon: Check };
case "failed":
return { variant: "destructive" as const, label: "Failed", icon: AlertCircle };
default:
return { variant: "secondary" as const, label: "Unknown", icon: null };
}
};
export function OrganizationList({
organizations,
isLoading,
@@ -28,7 +47,59 @@ export function OrganizationList({
onMirror,
loadingOrgIds,
onAddOrganization,
onRefresh,
}: OrganizationListProps) {
const { giteaConfig } = useGiteaConfig();
// Helper function to construct Gitea organization URL
const getGiteaOrgUrl = (organization: Organization): string | null => {
if (!giteaConfig?.url) {
return null;
}
// Only provide Gitea links for organizations that have been mirrored
const validStatuses = ['mirroring', 'mirrored'];
if (!validStatuses.includes(organization.status || '')) {
return null;
}
// Use destinationOrg if available, otherwise use the organization name
const orgName = organization.destinationOrg || organization.name;
if (!orgName) {
return null;
}
// Ensure the base URL doesn't have a trailing slash
const baseUrl = giteaConfig.url.endsWith('/')
? giteaConfig.url.slice(0, -1)
: giteaConfig.url;
return `${baseUrl}/${orgName}`;
};
const handleUpdateDestination = async (orgId: string, newDestination: string | null) => {
// Call API to update organization destination
const response = await fetch(`/api/organizations/${orgId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
destinationOrg: newDestination,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to update organization");
}
// Refresh organizations data
if (onRefresh) {
await onRefresh();
}
};
const hasAnyFilter = Object.values(filter).some(
(val) => val?.toString().trim() !== ""
);
@@ -93,29 +164,56 @@ export function OrganizationList({
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredOrganizations.map((org, index) => {
const isLoading = loadingOrgIds.has(org.id ?? "");
const statusBadge = getStatusBadge(org.status);
const StatusIcon = statusBadge.icon;
return (
<Card key={index} className="overflow-hidden p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Building2 className="h-5 w-5 text-muted-foreground" />
<a
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
className="font-medium hover:underline cursor-pointer"
>
{org.name}
</a>
<Card
key={index}
className={cn(
"overflow-hidden p-4 transition-all hover:shadow-md min-h-[160px]",
isLoading && "opacity-75"
)}
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Building2 className="h-5 w-5 text-muted-foreground" />
<a
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
className="font-medium hover:underline cursor-pointer"
>
{org.name}
</a>
<span
className={`text-xs px-2 py-0.5 rounded-full capitalize ${
org.membershipRole === "member"
? "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"
}`}
>
{org.membershipRole}
</span>
</div>
{/* Destination override section */}
<div className="mt-2">
<MirrorDestinationEditor
organizationId={org.id!}
organizationName={org.name!}
currentDestination={org.destinationOrg}
onUpdate={(newDestination) => handleUpdateDestination(org.id!, newDestination)}
isUpdating={isLoading}
/>
</div>
</div>
<span
className={`text-xs px-2 py-1 rounded-full capitalize ${
org.membershipRole === "member"
? "bg-blue-100 text-blue-800"
: "bg-purple-100 text-purple-800"
}`}
>
{org.membershipRole}
{/* needs to be updated */}
</span>
<Badge variant={statusBadge.variant} className="ml-2">
{StatusIcon && <StatusIcon className={cn(
"h-3 w-3",
org.status === "mirroring" && "animate-pulse"
)} />}
{statusBadge.label}
</Badge>
</div>
<div className="text-sm text-muted-foreground mb-4">
@@ -125,78 +223,147 @@ export function OrganizationList({
{org.repositoryCount === 1 ? "repository" : "repositories"}
</span>
</div>
{(org.publicRepositoryCount !== undefined ||
org.privateRepositoryCount !== undefined ||
org.forkRepositoryCount !== undefined) && (
<div className="flex gap-4 mt-2 text-xs">
{org.publicRepositoryCount !== undefined && (
<span className="flex items-center gap-1">
<div className="h-2 w-2 rounded-full bg-green-500" />
{org.publicRepositoryCount} public
</span>
)}
{org.privateRepositoryCount !== undefined && org.privateRepositoryCount > 0 && (
<span className="flex items-center gap-1">
<div className="h-2 w-2 rounded-full bg-orange-500" />
{org.privateRepositoryCount} private
</span>
)}
{org.forkRepositoryCount !== undefined && org.forkRepositoryCount > 0 && (
<span className="flex items-center gap-1">
<div className="h-2 w-2 rounded-full bg-blue-500" />
{org.forkRepositoryCount} fork{org.forkRepositoryCount !== 1 ? 's' : ''}
</span>
)}
</div>
)}
{/* Always render this section to prevent layout shift */}
<div className="flex gap-4 mt-2 text-xs min-h-[20px]">
{isLoading || (org.status === "mirroring" && org.publicRepositoryCount === undefined) ? (
<>
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-16" />
</>
) : (
<>
{org.publicRepositoryCount !== undefined ? (
<span className="flex items-center gap-1">
<div className="h-2 w-2 rounded-full bg-green-500" />
{org.publicRepositoryCount} public
</span>
) : null}
{org.privateRepositoryCount !== undefined && org.privateRepositoryCount > 0 ? (
<span className="flex items-center gap-1">
<div className="h-2 w-2 rounded-full bg-orange-500" />
{org.privateRepositoryCount} private
</span>
) : null}
{org.forkRepositoryCount !== undefined && org.forkRepositoryCount > 0 ? (
<span className="flex items-center gap-1">
<div className="h-2 w-2 rounded-full bg-blue-500" />
{org.forkRepositoryCount} fork{org.forkRepositoryCount !== 1 ? 's' : ''}
</span>
) : null}
{/* Show a placeholder if no counts are available to maintain height */}
{org.publicRepositoryCount === undefined &&
org.privateRepositoryCount === undefined &&
org.forkRepositoryCount === undefined && (
<span className="invisible">Loading counts...</span>
)}
</>
)}
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center">
<Checkbox
id={`include-${org.id}`}
name={`include-${org.id}`}
checked={org.status === "mirrored"}
disabled={
loadingOrgIds.has(org.id ?? "") ||
org.status === "mirrored" ||
org.status === "mirroring"
}
onCheckedChange={async (checked) => {
if (checked && !org.isIncluded && org.id) {
onMirror({ orgId: org.id });
}
}}
/>
<label
htmlFor={`include-${org.id}`}
className="ml-2 text-sm select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Enable mirroring
</label>
{isLoading && (
<RefreshCw className="opacity-50 h-4 w-4 animate-spin ml-4" />
<div className="flex items-center gap-2">
{org.status === "imported" && (
<Button
size="sm"
onClick={() => org.id && onMirror({ orgId: org.id })}
disabled={isLoading}
>
{isLoading ? (
<>
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
Starting...
</>
) : (
"Mirror"
)}
</Button>
)}
{org.status === "mirroring" && (
<Button size="sm" disabled variant="outline">
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
Mirroring...
</Button>
)}
{org.status === "mirrored" && (
<Button size="sm" disabled variant="secondary">
<Check className="h-3 w-3 mr-2" />
Mirrored
</Button>
)}
{org.status === "failed" && (
<Button
size="sm"
variant="destructive"
onClick={() => org.id && onMirror({ orgId: org.id })}
disabled={isLoading}
>
{isLoading ? (
<>
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
Retrying...
</>
) : (
<>
<AlertCircle className="h-3 w-3 mr-2" />
Retry
</>
)}
</Button>
)}
</div>
<Button variant="ghost" size="icon" asChild>
<a
href={`https://github.com/${org.name}`}
target="_blank"
rel="noopener noreferrer"
>
<SiGithub className="h-4 w-4" />
</a>
</Button>
</div>
<div className="flex items-center gap-1">
{(() => {
const giteaUrl = getGiteaOrgUrl(org);
{/* dont know if this looks good. maybe revised */}
<div className="flex items-center gap-2 justify-end mt-4">
<div
className={`h-2 w-2 rounded-full ${getStatusColor(org.status)}`}
/>
<span className="text-sm capitalize">{org.status}</span>
// Determine tooltip based on status and configuration
let tooltip: string;
if (!giteaConfig?.url) {
tooltip = "Gitea not configured";
} else if (org.status === 'imported') {
tooltip = "Organization not yet mirrored to Gitea";
} else if (org.status === 'failed') {
tooltip = "Organization mirroring failed";
} else if (org.status === 'mirroring') {
tooltip = "Organization is being mirrored to Gitea";
} else if (giteaUrl) {
tooltip = "View on Gitea";
} else {
tooltip = "Gitea organization not available";
}
return giteaUrl ? (
<Button variant="ghost" size="icon" asChild>
<a
href={giteaUrl}
target="_blank"
rel="noopener noreferrer"
title={tooltip}
>
<SiGitea className="h-4 w-4" />
</a>
</Button>
) : (
<Button variant="ghost" size="icon" disabled title={tooltip}>
<SiGitea className="h-4 w-4" />
</Button>
);
})()}
<Button variant="ghost" size="icon" asChild>
<a
href={`https://github.com/${org.name}`}
target="_blank"
rel="noopener noreferrer"
title="View on GitHub"
>
<SiGithub className="h-4 w-4" />
</a>
</Button>
</div>
</div>
</Card>
);

View File

@@ -0,0 +1,187 @@
import { useState, useRef, useEffect } from "react";
import { Edit3, Check, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { Repository } from "@/lib/db/schema";
interface InlineDestinationEditorProps {
repository: Repository;
giteaConfig: any;
onUpdate: (repoId: string, newDestination: string | null) => Promise<void>;
isUpdating?: boolean;
className?: string;
}
export function InlineDestinationEditor({
repository,
giteaConfig,
onUpdate,
isUpdating = false,
className,
}: InlineDestinationEditorProps) {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// Determine the default destination based on repository properties and config
const getDefaultDestination = () => {
// Starred repos always go to the configured starredReposOrg
if (repository.isStarred && giteaConfig?.starredReposOrg) {
return giteaConfig.starredReposOrg;
}
// Check mirror strategy
const strategy = giteaConfig?.mirrorStrategy || 'preserve';
if (strategy === 'single-org' && giteaConfig?.organization) {
// All repos go to a single organization
return giteaConfig.organization;
} else if (strategy === 'flat-user') {
// All repos go under the user account
return giteaConfig?.username || repository.owner;
} else {
// 'preserve' strategy or default
// For organization repos, use the organization name
if (repository.organization) {
return repository.organization;
}
// For personal repos, check if personalReposOrg is configured
if (!repository.organization && giteaConfig?.personalReposOrg) {
return giteaConfig.personalReposOrg;
}
// Default to the gitea username or owner
return giteaConfig?.username || repository.owner;
}
};
const defaultDestination = getDefaultDestination();
const currentDestination = repository.destinationOrg || defaultDestination;
const hasOverride = repository.destinationOrg && repository.destinationOrg !== defaultDestination;
const isStarredRepo = repository.isStarred && giteaConfig?.starredReposOrg;
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
const handleStartEdit = () => {
if (isStarredRepo) return; // Don't allow editing starred repos
setEditValue(currentDestination);
setIsEditing(true);
};
const handleSave = async () => {
const trimmedValue = editValue.trim();
const newDestination = trimmedValue === defaultDestination ? null : trimmedValue;
if (trimmedValue === currentDestination) {
setIsEditing(false);
return;
}
setIsLoading(true);
try {
await onUpdate(repository.id!, newDestination);
setIsEditing(false);
} catch (error) {
// Revert on error
setEditValue(currentDestination);
} finally {
setIsLoading(false);
}
};
const handleCancel = () => {
setEditValue(currentDestination);
setIsEditing(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
} else if (e.key === "Escape") {
e.preventDefault();
handleCancel();
}
};
if (isEditing) {
return (
<div className={cn("flex items-center gap-1", className)}>
<Input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleCancel}
className="h-6 text-sm px-2 py-0 w-24"
disabled={isLoading}
/>
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0"
onClick={handleSave}
disabled={isLoading}
>
<Check className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0"
onClick={handleCancel}
disabled={isLoading}
>
<X className="h-3 w-3" />
</Button>
</div>
);
}
return (
<div className={cn("flex flex-col gap-0.5", className)}>
{/* Show GitHub org if exists */}
{repository.organization && (
<span className="text-xs text-muted-foreground">
{repository.organization}
</span>
)}
{/* Show Gitea destination */}
<div className="flex items-center gap-1 group">
<span className="text-sm">
{currentDestination || "-"}
</span>
{hasOverride && (
<Badge variant="outline" className="h-4 px-1 text-[10px] ml-1">
custom
</Badge>
)}
{isStarredRepo && (
<Badge variant="secondary" className="h-4 px-1 text-[10px] ml-1">
starred
</Badge>
)}
{!isStarredRepo && (
<Button
size="sm"
variant="ghost"
className="h-4 w-4 p-0 opacity-0 group-hover:opacity-60 hover:opacity-100 ml-1"
onClick={handleStartEdit}
disabled={isUpdating || isLoading}
title="Edit destination"
>
<Edit3 className="h-3 w-3" />
</Button>
)}
</div>
</div>
);
}

View File

@@ -18,7 +18,7 @@ import {
SelectValue,
} from "../ui/select";
import { Button } from "@/components/ui/button";
import { Search, RefreshCw, FlipHorizontal } from "lucide-react";
import { Search, RefreshCw, FlipHorizontal, RotateCcw, X } from "lucide-react";
import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror";
import { useSSE } from "@/hooks/useSEE";
import { useFilterParams } from "@/hooks/useFilterParams";
@@ -46,6 +46,7 @@ export default function Repository() {
owner: "",
});
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
const [selectedRepoIds, setSelectedRepoIds] = useState<Set<string>>(new Set());
// Read organization filter from URL when component mounts
useEffect(() => {
@@ -254,6 +255,143 @@ export default function Repository() {
}
};
// Bulk action handlers
const handleBulkMirror = async () => {
if (selectedRepoIds.size === 0) return;
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
const eligibleRepos = selectedRepos.filter(
repo => repo.status === "imported" || repo.status === "failed"
);
if (eligibleRepos.length === 0) {
toast.info("No eligible repositories to mirror in selection");
return;
}
const repoIds = eligibleRepos.map(repo => repo.id as string);
setLoadingRepoIds(prev => {
const newSet = new Set(prev);
repoIds.forEach(id => newSet.add(id));
return newSet;
});
try {
const response = await apiRequest<MirrorRepoResponse>("/job/mirror-repo", {
method: "POST",
data: { userId: user?.id, repositoryIds: repoIds }
});
if (response.success) {
toast.success(`Mirroring started for ${repoIds.length} repositories`);
setRepositories(prevRepos =>
prevRepos.map(repo => {
const updated = response.repositories.find(r => r.id === repo.id);
return updated ? updated : repo;
})
);
setSelectedRepoIds(new Set());
} else {
showErrorToast(response.error || "Error starting mirror jobs", toast);
}
} catch (error) {
showErrorToast(error, toast);
} finally {
setLoadingRepoIds(new Set());
}
};
const handleBulkSync = async () => {
if (selectedRepoIds.size === 0) return;
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
const eligibleRepos = selectedRepos.filter(
repo => repo.status === "mirrored" || repo.status === "synced"
);
if (eligibleRepos.length === 0) {
toast.info("No eligible repositories to sync in selection");
return;
}
const repoIds = eligibleRepos.map(repo => repo.id as string);
setLoadingRepoIds(prev => {
const newSet = new Set(prev);
repoIds.forEach(id => newSet.add(id));
return newSet;
});
try {
const response = await apiRequest<SyncRepoResponse>("/job/sync-repo", {
method: "POST",
data: { userId: user?.id, repositoryIds: repoIds }
});
if (response.success) {
toast.success(`Syncing started for ${repoIds.length} repositories`);
setRepositories(prevRepos =>
prevRepos.map(repo => {
const updated = response.repositories.find(r => r.id === repo.id);
return updated ? updated : repo;
})
);
setSelectedRepoIds(new Set());
} else {
showErrorToast(response.error || "Error starting sync jobs", toast);
}
} catch (error) {
showErrorToast(error, toast);
} finally {
setLoadingRepoIds(new Set());
}
};
const handleBulkRetry = async () => {
if (selectedRepoIds.size === 0) return;
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
const eligibleRepos = selectedRepos.filter(repo => repo.status === "failed");
if (eligibleRepos.length === 0) {
toast.info("No failed repositories in selection to retry");
return;
}
const repoIds = eligibleRepos.map(repo => repo.id as string);
setLoadingRepoIds(prev => {
const newSet = new Set(prev);
repoIds.forEach(id => newSet.add(id));
return newSet;
});
try {
const response = await apiRequest<RetryRepoResponse>("/job/retry-repo", {
method: "POST",
data: { userId: user?.id, repositoryIds: repoIds }
});
if (response.success) {
toast.success(`Retrying ${repoIds.length} repositories`);
setRepositories(prevRepos =>
prevRepos.map(repo => {
const updated = response.repositories.find(r => r.id === repo.id);
return updated ? updated : repo;
})
);
setSelectedRepoIds(new Set());
} else {
showErrorToast(response.error || "Error retrying jobs", toast);
}
} catch (error) {
showErrorToast(error, toast);
} finally {
setLoadingRepoIds(new Set());
}
};
const handleSyncRepo = async ({ repoId }: { repoId: string }) => {
try {
if (!user || !user.id) {
@@ -392,6 +530,35 @@ export default function Repository() {
)
).sort();
// Determine what actions are available for selected repositories
const getAvailableActions = () => {
if (selectedRepoIds.size === 0) return [];
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
const statuses = new Set(selectedRepos.map(repo => repo.status));
const actions = [];
// Check if any selected repos can be mirrored
if (selectedRepos.some(repo => repo.status === "imported" || repo.status === "failed")) {
actions.push('mirror');
}
// Check if any selected repos can be synced
if (selectedRepos.some(repo => repo.status === "mirrored" || repo.status === "synced")) {
actions.push('sync');
}
// Check if any selected repos are failed
if (selectedRepos.some(repo => repo.status === "failed")) {
actions.push('retry');
}
return actions;
};
const availableActions = getAvailableActions();
return (
<div className="flex flex-col gap-y-8">
{/* Combine search and actions into a single flex row */}
@@ -459,14 +626,69 @@ export default function Repository() {
<RefreshCw className="h-4 w-4" />
</Button>
<Button
variant="default"
onClick={handleMirrorAllRepos}
disabled={isInitialLoading || loadingRepoIds.size > 0}
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror All
</Button>
{/* Context-aware action buttons */}
{selectedRepoIds.size === 0 ? (
<Button
variant="default"
onClick={handleMirrorAllRepos}
disabled={isInitialLoading || loadingRepoIds.size > 0}
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror All
</Button>
) : (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
<span className="text-sm font-medium">
{selectedRepoIds.size} selected
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setSelectedRepoIds(new Set())}
>
<X className="h-4 w-4" />
</Button>
</div>
{availableActions.includes('mirror') && (
<Button
variant="default"
size="sm"
onClick={handleBulkMirror}
disabled={loadingRepoIds.size > 0}
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror ({selectedRepoIds.size})
</Button>
)}
{availableActions.includes('sync') && (
<Button
variant="outline"
size="sm"
onClick={handleBulkSync}
disabled={loadingRepoIds.size > 0}
>
<RefreshCw className="h-4 w-4 mr-2" />
Sync ({selectedRepoIds.size})
</Button>
)}
{availableActions.includes('retry') && (
<Button
variant="outline"
size="sm"
onClick={handleBulkRetry}
disabled={loadingRepoIds.size > 0}
>
<RotateCcw className="h-4 w-4 mr-2" />
Retry
</Button>
)}
</div>
)}
</div>
{!isGitHubConfigured ? (
@@ -497,6 +719,9 @@ export default function Repository() {
onSync={handleSyncRepo}
onRetry={handleRetryRepoAction}
loadingRepoIds={loadingRepoIds}
selectedRepoIds={selectedRepoIds}
onSelectionChange={setSelectedRepoIds}
onRefresh={() => fetchRepositories(false)}
/>
)}

View File

@@ -1,7 +1,7 @@
import { useMemo, useRef } from "react";
import Fuse from "fuse.js";
import { useVirtualizer } from "@tanstack/react-virtual";
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw } from "lucide-react";
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star } from "lucide-react";
import { SiGithub, SiGitea } from "react-icons/si";
import type { Repository } from "@/lib/db/schema";
import { Button } from "@/components/ui/button";
@@ -9,6 +9,14 @@ import { formatDate, getStatusColor } from "@/lib/utils";
import type { FilterParams } from "@/types/filter";
import { Skeleton } from "@/components/ui/skeleton";
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
import { Checkbox } from "@/components/ui/checkbox";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { InlineDestinationEditor } from "./InlineDestinationEditor";
interface RepositoryTableProps {
repositories: Repository[];
@@ -20,6 +28,9 @@ interface RepositoryTableProps {
onSync: ({ repoId }: { repoId: string }) => Promise<void>;
onRetry: ({ repoId }: { repoId: string }) => Promise<void>;
loadingRepoIds: Set<string>;
selectedRepoIds: Set<string>;
onSelectionChange: (selectedIds: Set<string>) => void;
onRefresh?: () => Promise<void>;
}
export default function RepositoryTable({
@@ -32,10 +43,36 @@ export default function RepositoryTable({
onSync,
onRetry,
loadingRepoIds,
selectedRepoIds,
onSelectionChange,
onRefresh,
}: RepositoryTableProps) {
const tableParentRef = useRef<HTMLDivElement>(null);
const { giteaConfig } = useGiteaConfig();
const handleUpdateDestination = async (repoId: string, newDestination: string | null) => {
// Call API to update repository destination
const response = await fetch(`/api/repositories/${repoId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
destinationOrg: newDestination,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to update repository");
}
// Refresh repositories data
if (onRefresh) {
await onRefresh();
}
};
// Helper function to construct Gitea repository URL
const getGiteaRepoUrl = (repository: Repository): string | null => {
if (!giteaConfig?.url) {
@@ -105,9 +142,36 @@ export default function RepositoryTable({
overscan: 5,
});
// Selection handlers
const handleSelectAll = (checked: boolean) => {
if (checked) {
const allIds = new Set(filteredRepositories.map(repo => repo.id).filter((id): id is string => !!id));
onSelectionChange(allIds);
} else {
onSelectionChange(new Set());
}
};
const handleSelectRepo = (repoId: string, checked: boolean) => {
const newSelection = new Set(selectedRepoIds);
if (checked) {
newSelection.add(repoId);
} else {
newSelection.delete(repoId);
}
onSelectionChange(newSelection);
};
const isAllSelected = filteredRepositories.length > 0 &&
filteredRepositories.every(repo => repo.id && selectedRepoIds.has(repo.id));
const isPartiallySelected = selectedRepoIds.size > 0 && !isAllSelected;
return isLoading ? (
<div className="border rounded-md">
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Skeleton className="h-4 w-4" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
Repository
</div>
@@ -132,6 +196,9 @@ export default function RepositoryTable({
key={i}
className="h-[65px] flex items-center justify-between border-b bg-transparent"
>
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Skeleton className="h-4 w-4" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
<Skeleton className="h-full w-full" />
</div>
@@ -187,6 +254,14 @@ export default function RepositoryTable({
<div className="flex flex-col border rounded-md">
{/* table header */}
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Checkbox
checked={isAllSelected}
indeterminate={isPartiallySelected}
onCheckedChange={handleSelectAll}
aria-label="Select all repositories"
/>
</div>
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
Repository
</div>
@@ -235,11 +310,25 @@ export default function RepositoryTable({
data-index={virtualRow.index}
className="h-[65px] flex items-center justify-between bg-transparent border-b hover:bg-muted/50" //the height is set according to the row content. right now the highest row is in the repo column which is arround 64.99px
>
{/* Checkbox */}
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Checkbox
checked={repo.id ? selectedRepoIds.has(repo.id) : false}
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, !!checked)}
aria-label={`Select ${repo.name}`}
/>
</div>
{/* Repository */}
<div className="h-full p-3 flex items-center gap-2 flex-[2.5]">
<GitFork className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">{repo.name}</div>
<div className="flex-1">
<div className="font-medium flex items-center gap-1">
{repo.name}
{repo.isStarred && (
<Star className="h-3 w-3 fill-yellow-500 text-yellow-500" />
)}
</div>
<div className="text-xs text-muted-foreground">
{repo.fullName}
</div>
@@ -263,7 +352,12 @@ export default function RepositoryTable({
{/* Organization */}
<div className="h-full p-3 flex items-center flex-[1]">
<p className="text-sm"> {repo.organization || "-"}</p>
<InlineDestinationEditor
repository={repo}
giteaConfig={giteaConfig}
onUpdate={handleUpdateDestination}
isUpdating={loadingRepoIds.has(repo.id ?? "")}
/>
</div>
{/* Last Mirrored */}
@@ -277,12 +371,26 @@ export default function RepositoryTable({
{/* Status */}
<div className="h-full p-3 flex items-center gap-x-2 flex-[1]">
<div
className={`h-2 w-2 rounded-full ${getStatusColor(
repo.status
)}`}
/>
<span className="text-sm capitalize">{repo.status}</span>
{repo.status === "failed" && repo.errorMessage ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-x-2 cursor-help">
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
<span className="text-sm capitalize underline decoration-dotted">{repo.status}</span>
</div>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-sm">{repo.errorMessage}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<>
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
<span className="text-sm capitalize">{repo.status}</span>
</>
)}
</div>
{/* Actions */}

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,31 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,42 @@
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,43 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -19,7 +19,11 @@ function TooltipProvider({
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
@@ -40,7 +44,7 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-[var(--radix-tooltip-content-transform-origin)] rounded-md px-3 py-1.5 text-xs text-balance",
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}

View File

@@ -1,17 +1,4 @@
import { defineCollection, z } from 'astro:content';
// Define a schema for the documentation collection
const docsCollection = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
order: z.number().optional(),
updatedDate: z.date().optional(),
}),
});
// Export the collections
export const collections = {
'docs': docsCollection,
};
// Export empty collections since docs have been moved
export const collections = {};

View File

@@ -1,123 +0,0 @@
---
title: "Architecture"
description: "Comprehensive overview of the Gitea Mirror application architecture."
order: 1
updatedDate: 2025-05-22
---
<div class="mb-6">
<h1 class="text-2xl font-bold text-foreground">Gitea Mirror Architecture</h1>
<p class="text-muted-foreground mt-2">This document provides a comprehensive overview of the Gitea Mirror application architecture, including component diagrams, project structure, and detailed explanations of each part of the system.</p>
</div>
## System Overview
<div class="mb-4">
<p class="text-muted-foreground">Gitea Mirror is a web application that automates the mirroring of GitHub repositories to Gitea instances. It provides a user-friendly interface for configuring, monitoring, and managing mirroring operations without requiring users to edit configuration files or run Docker commands.</p>
</div>
The application is built using:
- <span class="font-semibold text-foreground">Astro</span>: Web framework for the frontend
- <span class="font-semibold text-foreground">React</span>: Component library for interactive UI elements
- <span class="font-semibold text-foreground">Shadcn UI</span>: UI component library built on Tailwind CSS
- <span class="font-semibold text-foreground">SQLite</span>: Database for storing configuration, state, and events
- <span class="font-semibold text-foreground">Bun</span>: Runtime environment for the backend
- <span class="font-semibold text-foreground">Drizzle ORM</span>: Type-safe ORM for database interactions
## Architecture Diagram
```mermaid
graph TD
subgraph "Gitea Mirror"
Frontend["Frontend<br/>(Astro + React)"]
Backend["Backend<br/>(Bun)"]
Database["Database<br/>(SQLite + Drizzle)"]
Frontend <--> Backend
Backend <--> Database
end
subgraph "External APIs"
GitHub["GitHub API"]
Gitea["Gitea API"]
end
Backend --> GitHub
Backend --> Gitea
```
## Component Breakdown
### Frontend (Astro + React)
The frontend is built with Astro, a modern web framework that allows for server-side rendering and partial hydration. React components are used for interactive elements, providing a responsive and dynamic user interface.
Key frontend components:
- **Dashboard**: Overview of mirroring status and recent activity
- **Repository Management**: Interface for managing repositories to mirror
- **Organization Management**: Interface for managing GitHub organizations
- **Configuration**: Settings for GitHub and Gitea connections
- **Activity Log**: Detailed log of mirroring operations
### Backend (Bun)
The backend is built with Bun and provides API endpoints for the frontend to interact with. It handles:
- Authentication and user management
- GitHub API integration
- Gitea API integration
- Mirroring operations
- Database interactions
### Database (SQLite + Drizzle ORM)
SQLite with Bun's native SQLite driver is used for data persistence, with Drizzle ORM providing type-safe database interactions. The database stores:
- User accounts and authentication data
- GitHub and Gitea configuration
- Repository and organization information
- Mirroring job history and status
- Event notifications and their read status
## Data Flow
1. **User Authentication**: Users authenticate through the frontend, which communicates with the backend to validate credentials.
2. **Configuration**: Users configure GitHub and Gitea settings through the UI, which are stored in the SQLite database.
3. **Repository Discovery**: The backend queries the GitHub API to discover repositories based on user configuration.
4. **Mirroring Process**: When triggered, the backend fetches repository data from GitHub and pushes it to Gitea.
5. **Status Tracking**: All operations are logged in the database and displayed in the Activity Log.
## Project Structure
```
gitea-mirror/
├── src/ # Source code
│ ├── components/ # React components
│ ├── content/ # Documentation and content
│ ├── layouts/ # Astro layout components
│ ├── lib/ # Utility functions and database
│ ├── pages/ # Astro pages and API routes
│ └── styles/ # CSS and Tailwind styles
├── public/ # Static assets
├── data/ # Database and persistent data
├── docker/ # Docker configuration
└── scripts/ # Utility scripts for deployment and maintenance
├── gitea-mirror-lxc-local.sh # Local LXC deployment script
└── manage-db.ts # Database management tool
```
## Deployment Options
Gitea Mirror supports multiple deployment options:
1. **Docker**: Run as a containerized application using Docker and docker-compose
2. **LXC Containers**: Deploy in Linux Containers (LXC) on Proxmox VE (using community script by [Tobias/CrazyWolf13](https://github.com/CrazyWolf13)) or local workstations
3. **Native**: Run directly on the host system using Bun runtime
Each deployment method has its own advantages:
- **Docker**: Isolation, easy updates, consistent environment
- **LXC**: Lightweight virtualization, better performance than Docker, system-level isolation
- **Native**: Best performance, direct access to system resources

View File

@@ -1,176 +0,0 @@
---
title: "Configuration"
description: "Guide to configuring Gitea Mirror for your environment."
order: 2
updatedDate: 2025-05-22
---
<div class="mb-6">
<h1 class="text-2xl font-bold text-foreground">Gitea Mirror Configuration Guide</h1>
<p class="text-muted-foreground mt-2">This guide provides detailed information on how to configure Gitea Mirror for your environment.</p>
</div>
## Configuration Methods
Gitea Mirror can be configured using:
1. <span class="font-semibold text-foreground">Environment Variables</span>: Set configuration options through environment variables
2. <span class="font-semibold text-foreground">Web UI</span>: Configure the application through the web interface after installation
## Environment Variables
The following environment variables can be used to configure Gitea Mirror:
| Variable | Description | Default Value | Example |
|----------|-------------|---------------|---------|
| `NODE_ENV` | Runtime environment (development, production, test) | `development` | `production` |
| `DATABASE_URL` | SQLite database URL | `file:data/gitea-mirror.db` | `file:path/to/your/database.db` |
| `JWT_SECRET` | Secret key for JWT authentication | Auto-generated secure random string | `your-secure-random-string` |
| `HOST` | Server host | `localhost` | `0.0.0.0` |
| `PORT` | Server port | `4321` | `8080` |
### Important Security Note
The application will automatically generate a secure random `JWT_SECRET` on first run if one isn't provided or if the default value is used. This generated secret is stored in the data directory for persistence across container restarts.
While this auto-generation feature provides good security by default, you can still explicitly set your own `JWT_SECRET` for complete control over your deployment.
## Web UI Configuration
After installing and starting Gitea Mirror, you can configure it through the web interface:
1. Navigate to `http://your-server:port/`
2. If this is your first time, you'll be guided through creating an admin account
3. Log in with your credentials
4. Go to the Configuration page
### GitHub Configuration
The GitHub configuration section allows you to connect to GitHub and specify which repositories to mirror.
| Option | Description | Default |
|--------|-------------|---------|
| Username | Your GitHub username | - |
| Token | GitHub personal access token | - |
| Skip Forks | Skip forked repositories | `false` |
| Private Repositories | Include private repositories | `false` |
| Mirror Issues | Mirror issues from GitHub to Gitea | `false` |
| Mirror Starred | Mirror starred repositories | `false` |
| Mirror Organizations | Mirror organization repositories | `false` |
| Only Mirror Orgs | Only mirror organization repositories | `false` |
| Preserve Org Structure | Maintain organization structure in Gitea | `false` |
| Skip Starred Issues | Skip mirroring issues for starred repositories | `false` |
#### GitHub Token Permissions
Your GitHub token needs the following permissions:
- `repo` - Full control of private repositories
- `read:org` - Read organization membership
- `read:user` - Read user profile data
To create a GitHub token:
1. Go to [GitHub Settings > Developer settings > Personal access tokens](https://github.com/settings/tokens)
2. Click "Generate new token"
3. Select the required permissions
4. Copy the generated token and paste it into Gitea Mirror
### Gitea Configuration
The Gitea configuration section allows you to connect to your Gitea instance and specify how repositories should be mirrored.
| Option | Description | Default |
|--------|-------------|---------|
| URL | Gitea server URL | - |
| Token | Gitea access token | - |
| Organization | Default organization for mirrored repositories | - |
| Visibility | Default visibility for mirrored repositories | `public` |
| Starred Repos Org | Organization for starred repositories | `github` |
#### Gitea Token Creation
To create a Gitea access token:
1. Log in to your Gitea instance
2. Go to Settings > Applications
3. Under "Generate New Token", enter a name for your token
4. Click "Generate Token"
5. Copy the generated token and paste it into Gitea Mirror
### Schedule Configuration
You can configure automatic mirroring on a schedule:
| Option | Description | Default |
|--------|-------------|---------|
| Enable Scheduling | Enable automatic mirroring | `false` |
| Interval (seconds) | Time between mirroring operations | `3600` (1 hour) |
## Advanced Configuration
### Repository Filtering
You can include or exclude specific repositories using patterns:
- Include patterns: Only repositories matching these patterns will be mirrored
- Exclude patterns: Repositories matching these patterns will be skipped
Example patterns:
- `*` - All repositories
- `org-name/*` - All repositories in a specific organization
- `username/repo-name` - A specific repository
### Database Management
Gitea Mirror includes several database management tools that can be run from the command line:
```bash
# Initialize the database (only if it doesn't exist)
bun run init-db
# Check database status
bun run check-db
# Fix database location issues
bun run fix-db
# Reset all users (for testing signup flow)
bun run reset-users
# Remove database files completely
bun run cleanup-db
```
### Event Management
Events in Gitea Mirror (such as repository mirroring operations) are stored in the SQLite database. You can manage these events using the following scripts:
```bash
# View all events in the database
bun scripts/check-events.ts
# Mark all events as read
bun scripts/mark-events-read.ts
```
For cleaning up old activities and events, use the cleanup button in the Activity Log page of the web interface.
### Health Check Endpoint
Gitea Mirror includes a built-in health check endpoint at `/api/health` that provides:
- System status and uptime
- Database connectivity check
- Memory usage statistics
- Environment information
You can use this endpoint for monitoring your deployment:
```bash
# Basic check (returns 200 OK if healthy)
curl -I http://your-server:port/api/health
# Detailed health information (JSON)
curl http://your-server:port/api/health
```

View File

@@ -1,182 +0,0 @@
---
title: "Quick Start Guide"
description: "Get started with Gitea Mirror quickly."
order: 3
updatedDate: 2025-05-22
---
<div class="mb-6">
<h1 class="text-2xl font-bold text-foreground">Gitea Mirror Quick Start Guide</h1>
<p class="text-muted-foreground mt-2">This guide will help you get Gitea Mirror up and running quickly.</p>
</div>
## Prerequisites
Before you begin, make sure you have:
1. <span class="font-semibold text-foreground">A GitHub account with a personal access token</span>
2. <span class="font-semibold text-foreground">A Gitea instance with an access token</span>
3. <span class="font-semibold text-foreground">One of the following:</span>
- Docker and docker-compose (for Docker deployment)
- Bun 1.2.9+ (for native deployment)
- Proxmox VE or LXD (for LXC container deployment)
## Installation Options
Choose the installation method that works best for your environment.
### Using Docker (Recommended for most users)
Docker provides the easiest way to get started with minimal configuration.
1. Clone the repository:
```bash
git clone https://github.com/arunavo4/gitea-mirror.git
cd gitea-mirror
```
2. Start the application in production mode:
```bash
docker compose up -d
```
3. Access the application at [http://localhost:4321](http://localhost:4321)
### Using Bun (Native Installation)
If you prefer to run the application directly on your system:
1. Clone the repository:
```bash
git clone https://github.com/arunavo4/gitea-mirror.git
cd gitea-mirror
```
2. Run the quick setup script:
```bash
bun run setup
```
This installs dependencies and initializes the database.
3. Choose how to run the application:
**Development Mode:**
```bash
bun run dev
```
Note: For Bun-specific features, use:
```bash
bunx --bun astro dev
```
**Production Mode:**
```bash
bun run build
bun run start
```
4. Access the application at [http://localhost:4321](http://localhost:4321)
### Using LXC Containers (Recommended for server deployments)
#### Proxmox VE (Online Installation)
For deploying on a Proxmox VE host with internet access:
```bash
# Optional env overrides: CTID HOSTNAME STORAGE DISK_SIZE CORES MEMORY BRIDGE IP_CONF
sudo bash -c "$(curl -fsSL https://raw.githubusercontent.com/arunavo4/gitea-mirror/main/scripts/gitea-mirror-lxc-proxmox.sh)"
```
This script:
- Creates a privileged LXC container
- Installs Bun and dependencies
- Clones and builds the application
- Sets up a systemd service
#### Local LXD (Offline-friendly Installation)
For testing on a local workstation or in environments without internet access:
1. Clone the repository locally:
```bash
git clone https://github.com/arunavo4/gitea-mirror.git
```
2. Download the Bun installer once:
```bash
curl -L -o /tmp/bun-linux-x64.zip https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip
```
3. Run the local LXC installer:
```bash
sudo LOCAL_REPO_DIR=~/path/to/gitea-mirror ./gitea-mirror/scripts/gitea-mirror-lxc-local.sh
```
For more details on LXC deployment, see the [LXC Container Deployment Guide](https://github.com/arunavo4/gitea-mirror/blob/main/scripts/README-lxc.md).
## Initial Configuration
Follow these steps to configure Gitea Mirror for first use:
1. **Create Admin Account**
- Upon first access, you'll be prompted to create an admin account
- Choose a secure username and password
- This will be your administrator account
2. **Configure GitHub Connection**
- Navigate to the Configuration page
- Enter your GitHub username
- Enter your GitHub personal access token
- Select which repositories to mirror (all, starred, organizations)
- Configure repository filtering options
3. **Configure Gitea Connection**
- Enter your Gitea server URL
- Enter your Gitea access token
- Configure organization and visibility settings
4. **Set Up Scheduling (Optional)**
- Enable automatic mirroring if desired
- Set the mirroring interval (in seconds)
5. **Save Configuration**
- Click the "Save" button to store your settings
## Performing Your First Mirror
After completing the configuration, you can start mirroring repositories:
1. Click "Import GitHub Data" to fetch repositories from GitHub
2. Go to the Repositories page to view your imported repositories
3. Select the repositories you want to mirror
4. Click "Mirror Selected" to start the mirroring process
5. Monitor the progress on the Activity page
6. You'll receive toast notifications about the success or failure of operations
## Troubleshooting
If you encounter any issues:
- Check the Activity Log for detailed error messages
- Verify your GitHub and Gitea tokens have the correct permissions
- Ensure your Gitea instance is accessible from the machine running Gitea Mirror
- Check logs based on your deployment method:
- Docker: `docker logs gitea-mirror`
- Native: Check the terminal output or system logs
- LXC: `systemctl status gitea-mirror` or `journalctl -u gitea-mirror -f`
- Use the health check endpoint to verify system status: `curl http://your-server:4321/api/health`
- For database issues, try the database management tools: `bun run check-db` or `bun run fix-db`
## Next Steps
After your initial setup:
- Explore the dashboard for an overview of your mirroring status
- Set up automatic mirroring schedules for hands-off operation
- Configure organization mirroring for team repositories
- Check out the [Configuration Guide](/configuration) for advanced settings
- Review the [Architecture Documentation](/architecture) to understand the system
- For server deployments, set up monitoring using the health check endpoint
- Use the cleanup button in the Activity Log page to manage old events and activities

View File

@@ -1,83 +0,0 @@
import { useState } from 'react';
import { mirrorApi } from '@/lib/api';
import type { MirrorJob } from '@/lib/db/schema';
export function useMirror() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentJob, setCurrentJob] = useState<MirrorJob | null>(null);
const [jobs, setJobs] = useState<MirrorJob[]>([]);
const startMirror = async (configId: string, repositoryIds?: string[]) => {
setIsLoading(true);
setError(null);
try {
const job = await mirrorApi.startMirror(configId, repositoryIds);
setCurrentJob(job);
return job;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start mirroring');
throw err;
} finally {
setIsLoading(false);
}
};
const getMirrorJobs = async (configId: string) => {
setIsLoading(true);
setError(null);
try {
const fetchedJobs = await mirrorApi.getMirrorJobs(configId);
setJobs(fetchedJobs);
return fetchedJobs;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch mirror jobs');
throw err;
} finally {
setIsLoading(false);
}
};
const getMirrorJob = async (jobId: string) => {
setIsLoading(true);
setError(null);
try {
const job = await mirrorApi.getMirrorJob(jobId);
setCurrentJob(job);
return job;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch mirror job');
throw err;
} finally {
setIsLoading(false);
}
};
const cancelMirrorJob = async (jobId: string) => {
setIsLoading(true);
setError(null);
try {
const result = await mirrorApi.cancelMirrorJob(jobId);
if (result.success && currentJob?.id === jobId) {
setCurrentJob({ ...currentJob, status: 'failed' });
}
return result;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to cancel mirror job');
throw err;
} finally {
setIsLoading(false);
}
};
return {
isLoading,
error,
currentJob,
jobs,
startMirror,
getMirrorJobs,
getMirrorJob,
cancelMirrorJob,
};
}

View File

@@ -28,11 +28,44 @@ try {
// Ensure all required tables exist
ensureTablesExist(sqlite);
// Run migrations
runMigrations(sqlite);
} catch (error) {
console.error("Error opening database:", error);
throw error;
}
/**
* Run database migrations
*/
function runMigrations(db: Database) {
try {
// Migration 1: Add destination_org column to organizations table
const orgTableInfo = db.query("PRAGMA table_info(organizations)").all() as Array<{name: string}>;
const hasDestinationOrg = orgTableInfo.some(col => col.name === 'destination_org');
if (!hasDestinationOrg) {
console.log("🔄 Running migration: Adding destination_org column to organizations table");
db.exec("ALTER TABLE organizations ADD COLUMN destination_org TEXT");
console.log("✅ Migration completed: destination_org column added");
}
// Migration 2: Add destination_org column to repositories table
const repoTableInfo = db.query("PRAGMA table_info(repositories)").all() as Array<{name: string}>;
const hasRepoDestinationOrg = repoTableInfo.some(col => col.name === 'destination_org');
if (!hasRepoDestinationOrg) {
console.log("🔄 Running migration: Adding destination_org column to repositories table");
db.exec("ALTER TABLE repositories ADD COLUMN destination_org TEXT");
console.log("✅ Migration completed: destination_org column added to repositories");
}
} catch (error) {
console.error("❌ Error running migrations:", error);
// Don't throw - migrations should be non-breaking
}
}
/**
* Ensure all required tables exist in the database
*/
@@ -159,6 +192,7 @@ function createTable(db: Database, tableName: string) {
last_mirrored INTEGER,
error_message TEXT,
repository_count INTEGER NOT NULL DEFAULT 0,
destination_org TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id),
@@ -437,6 +471,9 @@ export const organizations = sqliteTable("organizations", {
.notNull()
.default(true),
// Override destination organization for this GitHub org's repos
destinationOrg: text("destination_org"),
status: text("status").notNull().default("imported"),
lastMirrored: integer("last_mirrored", { mode: "timestamp" }),
errorMessage: text("error_message"),

View File

@@ -26,6 +26,7 @@ export const configSchema = z.object({
skipForks: z.boolean().default(false),
privateRepositories: z.boolean().default(false),
mirrorIssues: z.boolean().default(false),
mirrorWiki: z.boolean().default(false),
mirrorStarred: z.boolean().default(false),
useSpecificUser: z.boolean().default(false),
singleRepo: z.string().optional(),
@@ -33,7 +34,6 @@ export const configSchema = z.object({
excludeOrgs: z.array(z.string()).default([]),
mirrorPublicOrgs: z.boolean().default(false),
publicOrgs: z.array(z.string()).default([]),
preserveOrgStructure: z.boolean().default(false),
skipStarredIssues: z.boolean().default(false),
}),
giteaConfig: z.object({
@@ -43,6 +43,9 @@ export const configSchema = z.object({
organization: z.string().optional(),
visibility: z.enum(["public", "private", "limited"]).default("public"),
starredReposOrg: z.string().default("github"),
preserveOrgStructure: z.boolean().default(false),
mirrorStrategy: z.enum(["preserve", "single-org", "flat-user"]).optional(),
personalReposOrg: z.string().optional(), // Override destination for personal repos
}),
include: z.array(z.string()).default(["*"]),
exclude: z.array(z.string()).default([]),
@@ -98,6 +101,7 @@ export const repositorySchema = z.object({
errorMessage: z.string().optional(),
mirroredLocation: z.string().default(""), // Store the full Gitea path where repo was mirrored
destinationOrg: z.string().optional(), // Custom destination organization override
createdAt: z.date().default(() => new Date()),
updatedAt: z.date().default(() => new Date()),
@@ -156,6 +160,9 @@ export const organizationSchema = z.object({
privateRepositoryCount: z.number().optional(),
forkRepositoryCount: z.number().optional(),
// Override destination organization for this GitHub org's repos
destinationOrg: z.string().optional(),
createdAt: z.date().default(() => new Date()),
updatedAt: z.date().default(() => new Date()),
});

View File

@@ -1,7 +1,8 @@
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
import { Octokit } from "@octokit/rest";
import { repoStatusEnum } from "@/types/Repository";
import { getOrCreateGiteaOrg } from "./gitea";
import { getOrCreateGiteaOrg, getGiteaRepoOwner, getGiteaRepoOwnerAsync } from "./gitea";
import type { Config, Repository, Organization } from "./db/schema";
// Mock the isRepoPresentInGitea function
const mockIsRepoPresentInGitea = mock(() => Promise.resolve(false));
@@ -291,3 +292,90 @@ describe("Gitea Repository Mirroring", () => {
expect(mockGetOrCreateGiteaOrg).toHaveBeenCalled();
});
});
describe("getGiteaRepoOwner - Organization Override Tests", () => {
const baseConfig: Partial<Config> = {
githubConfig: {
username: "testuser",
token: "token",
preserveOrgStructure: false,
skipForks: false,
privateRepositories: false,
mirrorIssues: false,
mirrorWiki: false,
mirrorStarred: false,
useSpecificUser: false,
includeOrgs: [],
excludeOrgs: [],
mirrorPublicOrgs: false,
publicOrgs: [],
skipStarredIssues: false
},
giteaConfig: {
username: "giteauser",
url: "https://gitea.example.com",
token: "gitea-token",
organization: "github-mirrors",
visibility: "public",
starredReposOrg: "starred",
preserveOrgStructure: false,
mirrorStrategy: "preserve"
}
};
const baseRepo: Repository = {
id: "repo-id",
userId: "user-id",
configId: "config-id",
name: "test-repo",
fullName: "testuser/test-repo",
url: "https://github.com/testuser/test-repo",
cloneUrl: "https://github.com/testuser/test-repo.git",
owner: "testuser",
isPrivate: false,
isForked: false,
hasIssues: true,
isStarred: false,
isArchived: false,
size: 1000,
hasLFS: false,
hasSubmodules: false,
defaultBranch: "main",
visibility: "public",
status: "imported",
mirroredLocation: "",
createdAt: new Date(),
updatedAt: new Date()
};
test("starred repos go to starredReposOrg", () => {
const repo = { ...baseRepo, isStarred: true };
const result = getGiteaRepoOwner({ config: baseConfig, repository: repo });
expect(result).toBe("starred");
});
test("preserve strategy: personal repos use personalReposOrg override", () => {
const configWithOverride = {
...baseConfig,
giteaConfig: {
...baseConfig.giteaConfig!,
personalReposOrg: "my-personal-mirrors"
}
};
const repo = { ...baseRepo, organization: undefined };
const result = getGiteaRepoOwner({ config: configWithOverride, repository: repo });
expect(result).toBe("my-personal-mirrors");
});
test("preserve strategy: personal repos fallback to username when no override", () => {
const repo = { ...baseRepo, organization: undefined };
const result = getGiteaRepoOwner({ config: baseConfig, repository: repo });
expect(result).toBe("giteauser");
});
test("preserve strategy: org repos go to same org name", () => {
const repo = { ...baseRepo, organization: "myorg" };
const result = getGiteaRepoOwner({ config: baseConfig, repository: repo });
expect(result).toBe("myorg");
});
});

View File

@@ -9,7 +9,87 @@ import type { Organization, Repository } from "./db/schema";
import { httpPost, httpGet } from "./http-client";
import { createMirrorJob } from "./helpers";
import { db, organizations, repositories } from "./db";
import { eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
/**
* Helper function to get organization configuration including destination override
*/
export const getOrganizationConfig = async ({
orgName,
userId,
}: {
orgName: string;
userId: string;
}): Promise<Organization | null> => {
try {
const [orgConfig] = await db
.select()
.from(organizations)
.where(and(eq(organizations.name, orgName), eq(organizations.userId, userId)))
.limit(1);
return orgConfig || null;
} catch (error) {
console.error(`Error fetching organization config for ${orgName}:`, error);
return null;
}
};
/**
* Enhanced async version of getGiteaRepoOwner that supports organization overrides
*/
export const getGiteaRepoOwnerAsync = async ({
config,
repository,
}: {
config: Partial<Config>;
repository: Repository;
}): Promise<string> => {
if (!config.githubConfig || !config.giteaConfig) {
throw new Error("GitHub or Gitea config is required.");
}
if (!config.giteaConfig.username) {
throw new Error("Gitea username is required.");
}
if (!config.userId) {
throw new Error("User ID is required for organization overrides.");
}
// Check if repository is starred - starred repos always go to starredReposOrg (highest priority)
if (repository.isStarred && config.giteaConfig.starredReposOrg) {
return config.giteaConfig.starredReposOrg;
}
// Check for repository-specific override (second highest priority)
if (repository.destinationOrg) {
console.log(`Using repository override: ${repository.fullName} -> ${repository.destinationOrg}`);
return repository.destinationOrg;
}
// Check for organization-specific override
if (repository.organization) {
const orgConfig = await getOrganizationConfig({
orgName: repository.organization,
userId: config.userId,
});
if (orgConfig?.destinationOrg) {
console.log(`Using organization override: ${repository.organization} -> ${orgConfig.destinationOrg}`);
return orgConfig.destinationOrg;
}
}
// Check for personal repos override (when it's user's repo, not an organization)
if (!repository.organization && config.giteaConfig.personalReposOrg) {
console.log(`Using personal repos override: ${config.giteaConfig.personalReposOrg}`);
return config.giteaConfig.personalReposOrg;
}
// Fall back to existing strategy logic
return getGiteaRepoOwner({ config, repository });
};
export const getGiteaRepoOwner = ({
config,
@@ -26,13 +106,40 @@ export const getGiteaRepoOwner = ({
throw new Error("Gitea username is required.");
}
// if the config has preserveOrgStructure set to true, then use the org name as the owner
if (config.githubConfig.preserveOrgStructure && repository.organization) {
return repository.organization;
// Check if repository is starred - starred repos always go to starredReposOrg
if (repository.isStarred && config.giteaConfig.starredReposOrg) {
return config.giteaConfig.starredReposOrg;
}
// if the config has preserveOrgStructure set to false, then use the gitea username as the owner
return config.giteaConfig.username;
// Get the mirror strategy - use preserveOrgStructure for backward compatibility
const mirrorStrategy = config.giteaConfig.mirrorStrategy ||
(config.githubConfig.preserveOrgStructure ? "preserve" : "flat-user");
switch (mirrorStrategy) {
case "preserve":
// Keep GitHub structure - org repos go to same org, personal repos to user (or override)
if (repository.organization) {
return repository.organization;
}
// Use personal repos override if configured, otherwise use username
return config.giteaConfig.personalReposOrg || config.giteaConfig.username;
case "single-org":
// All non-starred repos go to the destination organization
if (config.giteaConfig.organization) {
return config.giteaConfig.organization;
}
// Fallback to username if no organization specified
return config.giteaConfig.username;
case "flat-user":
// All non-starred repos go under the user account
return config.giteaConfig.username;
default:
// Default fallback
return config.giteaConfig.username;
}
};
export const isRepoPresentInGitea = async ({
@@ -80,8 +187,11 @@ export const checkRepoLocation = async ({
expectedOwner: string;
}): Promise<{ present: boolean; actualOwner: string }> => {
// First check if we have a recorded mirroredLocation and if the repo exists there
if (repository.mirroredLocation && repository.mirroredLocation.trim() !== "") {
const [mirroredOwner] = repository.mirroredLocation.split('/');
if (
repository.mirroredLocation &&
repository.mirroredLocation.trim() !== ""
) {
const [mirroredOwner] = repository.mirroredLocation.split("/");
if (mirroredOwner) {
const mirroredPresent = await isRepoPresentInGitea({
config,
@@ -90,7 +200,9 @@ export const checkRepoLocation = async ({
});
if (mirroredPresent) {
console.log(`Repository found at recorded mirrored location: ${repository.mirroredLocation}`);
console.log(
`Repository found at recorded mirrored location: ${repository.mirroredLocation}`
);
return { present: true, actualOwner: mirroredOwner };
}
}
@@ -129,15 +241,18 @@ export const mirrorGithubRepoToGitea = async ({
throw new Error("Gitea username is required.");
}
// Get the correct owner based on the strategy (with organization overrides)
const repoOwner = await getGiteaRepoOwnerAsync({ config, repository });
const isExisting = await isRepoPresentInGitea({
config,
owner: config.giteaConfig.username,
owner: repoOwner,
repoName: repository.name,
});
if (isExisting) {
console.log(
`Repository ${repository.name} already exists in Gitea. Updating database status.`
`Repository ${repository.name} already exists in Gitea under ${repoOwner}. Updating database status.`
);
// Update database to reflect that the repository is already mirrored
@@ -148,7 +263,7 @@ export const mirrorGithubRepoToGitea = async ({
updatedAt: new Date(),
lastMirrored: new Date(),
errorMessage: null,
mirroredLocation: `${config.giteaConfig.username}/${repository.name}`,
mirroredLocation: `${repoOwner}/${repository.name}`,
})
.where(eq(repositories.id, repository.id!));
@@ -158,11 +273,13 @@ export const mirrorGithubRepoToGitea = async ({
repositoryId: repository.id,
repositoryName: repository.name,
message: `Repository ${repository.name} already exists in Gitea`,
details: `Repository ${repository.name} was found to already exist in Gitea and database status was updated.`,
details: `Repository ${repository.name} was found to already exist in Gitea under ${repoOwner} and database status was updated.`,
status: "mirrored",
});
console.log(`Repository ${repository.name} database status updated to mirrored`);
console.log(
`Repository ${repository.name} database status updated to mirrored`
);
return;
}
@@ -205,20 +322,45 @@ export const mirrorGithubRepoToGitea = async ({
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`;
const response = await httpPost(apiUrl, {
clone_addr: cloneAddress,
repo_name: repository.name,
mirror: true,
private: repository.isPrivate,
repo_owner: config.giteaConfig.username,
description: "",
service: "git",
}, {
"Authorization": `token ${config.giteaConfig.token}`,
// Handle organization creation if needed for single-org or preserve strategies
if (repoOwner !== config.giteaConfig.username && !repository.isStarred) {
// Need to create the organization if it doesn't exist
await getOrCreateGiteaOrg({
orgName: repoOwner,
config,
});
}
const response = await httpPost(
apiUrl,
{
clone_addr: cloneAddress,
repo_name: repository.name,
mirror: true,
wiki: config.githubConfig.mirrorWiki || false, // will mirror wiki if it exists
private: repository.isPrivate,
repo_owner: repoOwner,
description: "",
service: "git",
},
{
Authorization: `token ${config.giteaConfig.token}`,
}
);
//mirror releases
await mirrorGitHubReleasesToGitea({
config,
octokit,
repository,
});
// clone issues
if (config.githubConfig.mirrorIssues) {
// Skip issues for starred repos if skipStarredIssues is enabled
const shouldMirrorIssues = config.githubConfig.mirrorIssues &&
!(repository.isStarred && config.githubConfig.skipStarredIssues);
if (shouldMirrorIssues) {
await mirrorGitRepoIssuesToGitea({
config,
octokit,
@@ -237,7 +379,7 @@ export const mirrorGithubRepoToGitea = async ({
updatedAt: new Date(),
lastMirrored: new Date(),
errorMessage: null,
mirroredLocation: `${config.giteaConfig.username}/${repository.name}`,
mirroredLocation: `${repoOwner}/${repository.name}`,
})
.where(eq(repositories.id, repository.id!));
@@ -317,16 +459,22 @@ export async function getOrCreateGiteaOrg({
}
);
console.log(`Get org response status: ${orgRes.status} for org: ${orgName}`);
console.log(
`Get org response status: ${orgRes.status} for org: ${orgName}`
);
if (orgRes.ok) {
// Check if response is actually JSON
const contentType = orgRes.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
console.warn(`Expected JSON response but got content-type: ${contentType}`);
console.warn(
`Expected JSON response but got content-type: ${contentType}`
);
const responseText = await orgRes.text();
console.warn(`Response body: ${responseText}`);
throw new Error(`Invalid response format from Gitea API. Expected JSON but got: ${contentType}`);
throw new Error(
`Invalid response format from Gitea API. Expected JSON but got: ${contentType}`
);
}
// Clone the response to handle potential JSON parsing errors
@@ -334,14 +482,22 @@ export async function getOrCreateGiteaOrg({
try {
const org = await orgRes.json();
console.log(`Successfully retrieved existing org: ${orgName} with ID: ${org.id}`);
console.log(
`Successfully retrieved existing org: ${orgName} with ID: ${org.id}`
);
// Note: Organization events are handled by the main mirroring process
// to avoid duplicate events
return org.id;
} catch (jsonError) {
const responseText = await orgResClone.text();
console.error(`Failed to parse JSON response for existing org: ${responseText}`);
throw new Error(`Failed to parse JSON response from Gitea API: ${jsonError instanceof Error ? jsonError.message : String(jsonError)}`);
console.error(
`Failed to parse JSON response for existing org: ${responseText}`
);
throw new Error(
`Failed to parse JSON response from Gitea API: ${
jsonError instanceof Error ? jsonError.message : String(jsonError)
}`
);
}
}
@@ -357,25 +513,33 @@ export async function getOrCreateGiteaOrg({
username: orgName,
full_name: `${orgName} Org`,
description: `Mirrored organization from GitHub ${orgName}`,
visibility: "public",
visibility: config.giteaConfig?.visibility || "public",
}),
});
console.log(`Create org response status: ${createRes.status} for org: ${orgName}`);
console.log(
`Create org response status: ${createRes.status} for org: ${orgName}`
);
if (!createRes.ok) {
const errorText = await createRes.text();
console.error(`Failed to create org ${orgName}. Status: ${createRes.status}, Response: ${errorText}`);
console.error(
`Failed to create org ${orgName}. Status: ${createRes.status}, Response: ${errorText}`
);
throw new Error(`Failed to create Gitea org: ${errorText}`);
}
// Check if response is actually JSON
const createContentType = createRes.headers.get("content-type");
if (!createContentType || !createContentType.includes("application/json")) {
console.warn(`Expected JSON response but got content-type: ${createContentType}`);
console.warn(
`Expected JSON response but got content-type: ${createContentType}`
);
const responseText = await createRes.text();
console.warn(`Response body: ${responseText}`);
throw new Error(`Invalid response format from Gitea API. Expected JSON but got: ${createContentType}`);
throw new Error(
`Invalid response format from Gitea API. Expected JSON but got: ${createContentType}`
);
}
// Note: Organization creation events are handled by the main mirroring process
@@ -386,12 +550,20 @@ export async function getOrCreateGiteaOrg({
try {
const newOrg = await createRes.json();
console.log(`Successfully created new org: ${orgName} with ID: ${newOrg.id}`);
console.log(
`Successfully created new org: ${orgName} with ID: ${newOrg.id}`
);
return newOrg.id;
} catch (jsonError) {
const responseText = await createResClone.text();
console.error(`Failed to parse JSON response for new org: ${responseText}`);
throw new Error(`Failed to parse JSON response from Gitea API: ${jsonError instanceof Error ? jsonError.message : String(jsonError)}`);
console.error(
`Failed to parse JSON response for new org: ${responseText}`
);
throw new Error(
`Failed to parse JSON response from Gitea API: ${
jsonError instanceof Error ? jsonError.message : String(jsonError)
}`
);
}
} catch (error) {
const errorMessage =
@@ -399,7 +571,9 @@ export async function getOrCreateGiteaOrg({
? error.message
: "Unknown error occurred in getOrCreateGiteaOrg.";
console.error(`Error in getOrCreateGiteaOrg for ${orgName}: ${errorMessage}`);
console.error(
`Error in getOrCreateGiteaOrg for ${orgName}: ${errorMessage}`
);
await createMirrorJob({
userId: config.userId,
@@ -469,7 +643,9 @@ export async function mirrorGitHubRepoToGiteaOrg({
status: "mirrored",
});
console.log(`Repository ${repository.name} database status updated to mirrored in organization ${orgName}`);
console.log(
`Repository ${repository.name} database status updated to mirrored in organization ${orgName}`
);
return;
}
@@ -506,18 +682,34 @@ export async function mirrorGitHubRepoToGiteaOrg({
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`;
const migrateRes = await httpPost(apiUrl, {
clone_addr: cloneAddress,
uid: giteaOrgId,
repo_name: repository.name,
mirror: true,
private: repository.isPrivate,
}, {
"Authorization": `token ${config.giteaConfig.token}`,
const migrateRes = await httpPost(
apiUrl,
{
clone_addr: cloneAddress,
uid: giteaOrgId,
repo_name: repository.name,
mirror: true,
wiki: config.githubConfig?.mirrorWiki || false, // will mirror wiki if it exists
private: repository.isPrivate,
},
{
Authorization: `token ${config.giteaConfig.token}`,
}
);
//mirror releases
await mirrorGitHubReleasesToGitea({
config,
octokit,
repository,
});
// Clone issues
if (config.githubConfig?.mirrorIssues) {
// Skip issues for starred repos if skipStarredIssues is enabled
const shouldMirrorIssues = config.githubConfig?.mirrorIssues &&
!(repository.isStarred && config.githubConfig?.skipStarredIssues);
if (shouldMirrorIssues) {
await mirrorGitRepoIssuesToGitea({
config,
octokit,
@@ -664,11 +856,37 @@ export async function mirrorGitHubOrgToGitea({
status: repoStatusEnum.parse("mirroring"),
});
const giteaOrgId = await getOrCreateGiteaOrg({
orgId: organization.id,
orgName: organization.name,
config,
});
// Get the mirror strategy - use preserveOrgStructure for backward compatibility
const mirrorStrategy = config.giteaConfig?.mirrorStrategy ||
(config.githubConfig?.preserveOrgStructure ? "preserve" : "flat-user");
let giteaOrgId: number;
let targetOrgName: string;
// Determine the target organization based on strategy
if (mirrorStrategy === "single-org" && config.giteaConfig?.organization) {
// For single-org strategy, use the configured destination organization
targetOrgName = config.giteaConfig.organization;
giteaOrgId = await getOrCreateGiteaOrg({
orgId: organization.id,
orgName: targetOrgName,
config,
});
console.log(`Using single organization strategy: all repos will go to ${targetOrgName}`);
} else if (mirrorStrategy === "preserve") {
// For preserve strategy, create/use an org with the same name as GitHub
targetOrgName = organization.name;
giteaOrgId = await getOrCreateGiteaOrg({
orgId: organization.id,
orgName: targetOrgName,
config,
});
} else {
// For flat-user strategy, we shouldn't create organizations at all
// Skip organization creation and let individual repos be handled by getGiteaRepoOwner
console.log(`Using flat-user strategy: repos will be placed under user account`);
targetOrgName = config.giteaConfig?.username || "";
}
//query the db with the org name and get the repos
const orgRepos = await db
@@ -677,9 +895,13 @@ export async function mirrorGitHubOrgToGitea({
.where(eq(repositories.organization, organization.name));
if (orgRepos.length === 0) {
console.log(`No repositories found for organization ${organization.name} - marking as successfully mirrored`);
console.log(
`No repositories found for organization ${organization.name} - marking as successfully mirrored`
);
} else {
console.log(`Mirroring ${orgRepos.length} repositories for organization ${organization.name}`);
console.log(
`Mirroring ${orgRepos.length} repositories for organization ${organization.name}`
);
// Import the processWithRetry function
const { processWithRetry } = await import("@/lib/utils/concurrency");
@@ -701,16 +923,28 @@ export async function mirrorGitHubOrgToGitea({
};
// Log the start of mirroring
console.log(`Starting mirror for repository: ${repo.name} in organization ${organization.name}`);
console.log(
`Starting mirror for repository: ${repo.name} from GitHub org ${organization.name}`
);
// Mirror the repository
await mirrorGitHubRepoToGiteaOrg({
octokit,
config,
repository: repoData,
giteaOrgId,
orgName: organization.name,
});
// Mirror the repository based on strategy
if (mirrorStrategy === "flat-user") {
// For flat-user strategy, mirror directly to user account
await mirrorGithubRepoToGitea({
octokit,
repository: repoData,
config,
});
} else {
// For preserve and single-org strategies, use organization
await mirrorGitHubRepoToGiteaOrg({
octokit,
config,
repository: repoData,
giteaOrgId: giteaOrgId!,
orgName: targetOrgName,
});
}
return repo;
},
@@ -721,12 +955,16 @@ export async function mirrorGitHubOrgToGitea({
onProgress: (completed, total, result) => {
const percentComplete = Math.round((completed / total) * 100);
if (result) {
console.log(`Mirrored repository "${result.name}" in organization ${organization.name} (${completed}/${total}, ${percentComplete}%)`);
console.log(
`Mirrored repository "${result.name}" in organization ${organization.name} (${completed}/${total}, ${percentComplete}%)`
);
}
},
onRetry: (repo, error, attempt) => {
console.log(`Retrying repository ${repo.name} in organization ${organization.name} (attempt ${attempt}): ${error.message}`);
}
console.log(
`Retrying repository ${repo.name} in organization ${organization.name} (attempt ${attempt}): ${error.message}`
);
},
}
);
}
@@ -750,9 +988,10 @@ export async function mirrorGitHubOrgToGitea({
organizationId: organization.id,
organizationName: organization.name,
message: `Successfully mirrored organization: ${organization.name}`,
details: orgRepos.length === 0
? `Organization ${organization.name} was processed successfully (no repositories found).`
: `Organization ${organization.name} was mirrored to Gitea with ${orgRepos.length} repositories.`,
details:
orgRepos.length === 0
? `Organization ${organization.name} was processed successfully (no repositories found).`
: `Organization ${organization.name} was mirrored to Gitea with ${orgRepos.length} repositories.`,
status: repoStatusEnum.parse("mirrored"),
});
} catch (error) {
@@ -829,25 +1068,27 @@ export const syncGiteaRepo = async ({
status: repoStatusEnum.parse("syncing"),
});
// Get the expected owner based on current config
const repoOwner = getGiteaRepoOwner({ config, repository });
// Get the expected owner based on current config (with organization overrides)
const repoOwner = await getGiteaRepoOwnerAsync({ config, repository });
// Check if repo exists at the expected location or alternate location
const { present, actualOwner } = await checkRepoLocation({
config,
repository,
expectedOwner: repoOwner
expectedOwner: repoOwner,
});
if (!present) {
throw new Error(`Repository ${repository.name} not found in Gitea at any expected location`);
throw new Error(
`Repository ${repository.name} not found in Gitea at any expected location`
);
}
// Use the actual owner where the repo was found
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${actualOwner}/${repository.name}/mirror-sync`;
const response = await httpPost(apiUrl, undefined, {
"Authorization": `token ${config.giteaConfig.token}`,
Authorization: `token ${config.giteaConfig.token}`,
});
// Mark repo as "synced" in DB
@@ -951,9 +1192,11 @@ export const mirrorGitRepoIssuesToGitea = async ({
);
// Filter out pull requests
const filteredIssues = issues.filter(issue => !(issue as any).pull_request);
const filteredIssues = issues.filter((issue) => !(issue as any).pull_request);
console.log(`Mirroring ${filteredIssues.length} issues from ${repository.fullName}`);
console.log(
`Mirroring ${filteredIssues.length} issues from ${repository.fullName}`
);
if (filteredIssues.length === 0) {
console.log(`No issues to mirror for ${repository.fullName}`);
@@ -964,7 +1207,7 @@ export const mirrorGitRepoIssuesToGitea = async ({
const giteaLabelsRes = await httpGet(
`${config.giteaConfig.url}/api/v1/repos/${repoOrigin}/${repository.name}/labels`,
{
"Authorization": `token ${config.giteaConfig.token}`,
Authorization: `token ${config.giteaConfig.token}`,
}
);
@@ -994,10 +1237,12 @@ export const mirrorGitRepoIssuesToGitea = async ({
} else {
try {
const created = await httpPost(
`${config.giteaConfig!.url}/api/v1/repos/${repoOrigin}/${repository.name}/labels`,
`${config.giteaConfig!.url}/api/v1/repos/${repoOrigin}/${
repository.name
}/labels`,
{ name, color: "#ededed" }, // Default color
{
"Authorization": `token ${config.giteaConfig!.token}`,
Authorization: `token ${config.giteaConfig!.token}`,
}
);
@@ -1029,10 +1274,12 @@ export const mirrorGitRepoIssuesToGitea = async ({
// Create the issue in Gitea
const createdIssue = await httpPost(
`${config.giteaConfig!.url}/api/v1/repos/${repoOrigin}/${repository.name}/issues`,
`${config.giteaConfig!.url}/api/v1/repos/${repoOrigin}/${
repository.name
}/issues`,
issuePayload,
{
"Authorization": `token ${config.giteaConfig!.token}`,
Authorization: `token ${config.giteaConfig!.token}`,
}
);
@@ -1054,12 +1301,14 @@ export const mirrorGitRepoIssuesToGitea = async ({
comments,
async (comment) => {
await httpPost(
`${config.giteaConfig!.url}/api/v1/repos/${repoOrigin}/${repository.name}/issues/${createdIssue.data.number}/comments`,
`${config.giteaConfig!.url}/api/v1/repos/${repoOrigin}/${
repository.name
}/issues/${createdIssue.data.number}/comments`,
{
body: `@${comment.user?.login} commented on GitHub:\n\n${comment.body}`,
},
{
"Authorization": `token ${config.giteaConfig!.token}`,
Authorization: `token ${config.giteaConfig!.token}`,
}
);
return comment;
@@ -1069,8 +1318,10 @@ export const mirrorGitRepoIssuesToGitea = async ({
maxRetries: 2,
retryDelay: 1000,
onRetry: (_comment, error, attempt) => {
console.log(`Retrying comment (attempt ${attempt}): ${error.message}`);
}
console.log(
`Retrying comment (attempt ${attempt}): ${error.message}`
);
},
}
);
}
@@ -1084,14 +1335,69 @@ export const mirrorGitRepoIssuesToGitea = async ({
onProgress: (completed, total, result) => {
const percentComplete = Math.round((completed / total) * 100);
if (result) {
console.log(`Mirrored issue "${result.title}" (${completed}/${total}, ${percentComplete}%)`);
console.log(
`Mirrored issue "${result.title}" (${completed}/${total}, ${percentComplete}%)`
);
}
},
onRetry: (issue, error, attempt) => {
console.log(`Retrying issue "${issue.title}" (attempt ${attempt}): ${error.message}`);
}
console.log(
`Retrying issue "${issue.title}" (attempt ${attempt}): ${error.message}`
);
},
}
);
console.log(`Completed mirroring ${filteredIssues.length} issues for ${repository.fullName}`);
console.log(
`Completed mirroring ${filteredIssues.length} issues for ${repository.fullName}`
);
};
export async function mirrorGitHubReleasesToGitea({
octokit,
repository,
config,
}: {
octokit: Octokit;
repository: Repository;
config: Partial<Config>;
}) {
if (
!config.giteaConfig?.username ||
!config.giteaConfig?.token ||
!config.giteaConfig?.url
) {
throw new Error("Gitea config is incomplete for mirroring releases.");
}
const repoOwner = await getGiteaRepoOwnerAsync({
config,
repository,
});
const { url, token } = config.giteaConfig;
const releases = await octokit.rest.repos.listReleases({
owner: repository.owner,
repo: repository.name,
});
for (const release of releases.data) {
await httpPost(
`${url}/api/v1/repos/${repoOwner}/${repository.name}/releases`,
{
tag_name: release.tag_name,
target: release.target_commitish,
title: release.name || release.tag_name,
note: release.body || "",
draft: release.draft,
prerelease: release.prerelease,
},
{
Authorization: `token ${token}`,
}
);
}
console.log(`✅ Mirrored ${releases.data.length} GitHub releases to Gitea`);
}

View File

@@ -216,3 +216,76 @@ export const jsonResponse = ({
headers: { "Content-Type": "application/json" },
});
};
/**
* Securely handles errors for API responses by sanitizing error messages
* and preventing sensitive information exposure while maintaining proper logging
*/
export function createSecureErrorResponse(
error: unknown,
context: string,
status: number = 500
): Response {
// Log the full error details server-side for debugging
console.error(`Error in ${context}:`, error);
// Log additional error details if it's an Error object
if (error instanceof Error) {
console.error(`Error name: ${error.name}`);
console.error(`Error message: ${error.message}`);
if (error.stack) {
console.error(`Error stack: ${error.stack}`);
}
}
// Determine safe error message for client
let clientMessage = "An internal server error occurred";
// Only expose specific safe error types to clients
if (error instanceof Error) {
// Safe error patterns that can be exposed (add more as needed)
const safeErrorPatterns = [
/missing required field/i,
/invalid.*format/i,
/not found/i,
/unauthorized/i,
/forbidden/i,
/bad request/i,
/validation.*failed/i,
/user id is required/i,
/no repositories found/i,
/config missing/i,
/invalid userid/i,
/no users found/i,
/missing userid/i,
/github token is required/i,
/invalid github token/i,
/invalid gitea token/i,
/username and password are required/i,
/invalid username or password/i,
/organization already exists/i,
/no configuration found/i,
/github token is missing/i,
/use post method/i,
];
const isSafeError = safeErrorPatterns.some(pattern =>
pattern.test(error.message)
);
if (isSafeError) {
clientMessage = error.message;
}
}
return new Response(
JSON.stringify({
error: clientMessage,
timestamp: new Date().toISOString(),
}),
{
status,
headers: { "Content-Type": "application/json" },
}
);
}

View File

@@ -0,0 +1,136 @@
/**
* Maps between UI config structure and database schema structure
*/
import type {
GitHubConfig,
GiteaConfig,
MirrorOptions,
AdvancedOptions,
SaveConfigApiRequest
} from "@/types/config";
interface DbGitHubConfig {
username: string;
token?: string;
skipForks: boolean;
privateRepositories: boolean;
mirrorIssues: boolean;
mirrorWiki: boolean;
mirrorStarred: boolean;
useSpecificUser: boolean;
singleRepo?: string;
includeOrgs: string[];
excludeOrgs: string[];
mirrorPublicOrgs: boolean;
publicOrgs: string[];
skipStarredIssues: boolean;
}
interface DbGiteaConfig {
username: string;
url: string;
token: string;
organization?: string;
visibility: "public" | "private" | "limited";
starredReposOrg: string;
preserveOrgStructure: boolean;
mirrorStrategy?: "preserve" | "single-org" | "flat-user";
personalReposOrg?: string;
}
/**
* Maps UI config structure to database schema structure
*/
export function mapUiToDbConfig(
githubConfig: GitHubConfig,
giteaConfig: GiteaConfig,
mirrorOptions: MirrorOptions,
advancedOptions: AdvancedOptions
): { githubConfig: DbGitHubConfig; giteaConfig: DbGiteaConfig } {
// Map GitHub config with fields from mirrorOptions and advancedOptions
const dbGithubConfig: DbGitHubConfig = {
username: githubConfig.username,
token: githubConfig.token,
privateRepositories: githubConfig.privateRepositories,
mirrorStarred: githubConfig.mirrorStarred,
// From mirrorOptions
mirrorIssues: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.issues,
mirrorWiki: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.wiki,
// From advancedOptions
skipForks: advancedOptions.skipForks,
skipStarredIssues: advancedOptions.skipStarredIssues,
// Default values for fields not in UI
useSpecificUser: false,
includeOrgs: [],
excludeOrgs: [],
mirrorPublicOrgs: false,
publicOrgs: [],
};
// Gitea config remains mostly the same
const dbGiteaConfig: DbGiteaConfig = {
...giteaConfig,
};
return {
githubConfig: dbGithubConfig,
giteaConfig: dbGiteaConfig,
};
}
/**
* Maps database schema structure to UI config structure
*/
export function mapDbToUiConfig(dbConfig: any): {
githubConfig: GitHubConfig;
giteaConfig: GiteaConfig;
mirrorOptions: MirrorOptions;
advancedOptions: AdvancedOptions;
} {
const githubConfig: GitHubConfig = {
username: dbConfig.githubConfig?.username || "",
token: dbConfig.githubConfig?.token || "",
privateRepositories: dbConfig.githubConfig?.privateRepositories || false,
mirrorStarred: dbConfig.githubConfig?.mirrorStarred || false,
};
const giteaConfig: GiteaConfig = {
url: dbConfig.giteaConfig?.url || "",
username: dbConfig.giteaConfig?.username || "",
token: dbConfig.giteaConfig?.token || "",
organization: dbConfig.giteaConfig?.organization || "github-mirrors",
visibility: dbConfig.giteaConfig?.visibility || "public",
starredReposOrg: dbConfig.giteaConfig?.starredReposOrg || "github",
preserveOrgStructure: dbConfig.giteaConfig?.preserveOrgStructure || false,
mirrorStrategy: dbConfig.giteaConfig?.mirrorStrategy,
personalReposOrg: dbConfig.giteaConfig?.personalReposOrg,
};
const mirrorOptions: MirrorOptions = {
mirrorReleases: false, // Not stored in DB yet
mirrorMetadata: dbConfig.githubConfig?.mirrorIssues || dbConfig.githubConfig?.mirrorWiki || false,
metadataComponents: {
issues: dbConfig.githubConfig?.mirrorIssues || false,
pullRequests: false, // Not stored in DB yet
labels: false, // Not stored in DB yet
milestones: false, // Not stored in DB yet
wiki: dbConfig.githubConfig?.mirrorWiki || false,
},
};
const advancedOptions: AdvancedOptions = {
skipForks: dbConfig.githubConfig?.skipForks || false,
skipStarredIssues: dbConfig.githubConfig?.skipStarredIssues || false,
};
return {
githubConfig,
giteaConfig,
mirrorOptions,
advancedOptions,
};
}

37
src/pages/404.astro Normal file
View File

@@ -0,0 +1,37 @@
---
import '../styles/global.css';
import ThemeScript from '@/components/theme/ThemeScript.astro';
import { NotFound } from '@/components/NotFound';
const generator = Astro.generator;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={generator} />
<title>Page Not Found - Gitea Mirror</title>
<ThemeScript />
</head>
<body>
<NotFound client:load />
</body>
</html>
<style>
/* Floating animation for 404 text */
:global(.animate-float) {
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
</style>

View File

@@ -1,6 +1,7 @@
import type { APIRoute } from "astro";
import { db, mirrorJobs, events } from "@/lib/db";
import { eq, count } from "drizzle-orm";
import { createSecureErrorResponse } from "@/lib/utils";
export const POST: APIRoute = async ({ request }) => {
try {
@@ -87,29 +88,6 @@ export const POST: APIRoute = async ({ request }) => {
{ status: 200, headers: { "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Error cleaning up activities:", error);
// Provide more specific error messages
let errorMessage = "An unknown error occurred.";
if (error instanceof Error) {
errorMessage = error.message;
// Check for common database errors
if (error.message.includes("FOREIGN KEY constraint failed")) {
errorMessage = "Cannot delete activities due to database constraints. Some jobs may still be referenced by other records.";
} else if (error.message.includes("database is locked")) {
errorMessage = "Database is currently locked. Please try again in a moment.";
} else if (error.message.includes("no such table")) {
errorMessage = "Database tables are missing. Please check your database setup.";
}
}
return new Response(
JSON.stringify({
success: false,
error: errorMessage,
}),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
return createSecureErrorResponse(error, "activities cleanup", 500);
}
};

View File

@@ -1,6 +1,7 @@
import type { APIRoute } from "astro";
import { db, mirrorJobs, configs } from "@/lib/db";
import { eq, sql } from "drizzle-orm";
import { createSecureErrorResponse } from "@/lib/utils";
import type { MirrorJob } from "@/lib/db/schema";
import { repoStatusEnum } from "@/types/Repository";
@@ -45,14 +46,6 @@ export const GET: APIRoute = async ({ url }) => {
{ status: 200, headers: { "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Error fetching mirror job activities:", error);
return new Response(
JSON.stringify({
success: false,
error:
error instanceof Error ? error.message : "An unknown error occurred.",
}),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
return createSecureErrorResponse(error, "activities fetch", 500);
}
};

View File

@@ -5,6 +5,7 @@
import type { APIRoute } from 'astro';
import { runAutomaticCleanup } from '@/lib/cleanup-service';
import { createSecureErrorResponse } from '@/lib/utils';
export const POST: APIRoute = async ({ request }) => {
try {
@@ -38,21 +39,7 @@ export const POST: APIRoute = async ({ request }) => {
}
);
} catch (error) {
console.error('Error in manual cleanup trigger:', error);
return new Response(
JSON.stringify({
success: false,
message: 'Failed to run automatic cleanup',
error: error instanceof Error ? error.message : 'Unknown error',
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
},
}
);
return createSecureErrorResponse(error, "cleanup trigger", 500);
}
};

View File

@@ -3,18 +3,20 @@ import { db, configs, users } from "@/lib/db";
import { v4 as uuidv4 } from "uuid";
import { eq } from "drizzle-orm";
import { calculateCleanupInterval } from "@/lib/cleanup-service";
import { createSecureErrorResponse } from "@/lib/utils";
import { mapUiToDbConfig, mapDbToUiConfig } from "@/lib/utils/config-mapper";
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const { userId, githubConfig, giteaConfig, scheduleConfig, cleanupConfig } = body;
const { userId, githubConfig, giteaConfig, scheduleConfig, cleanupConfig, mirrorOptions, advancedOptions } = body;
if (!userId || !githubConfig || !giteaConfig || !scheduleConfig || !cleanupConfig) {
if (!userId || !githubConfig || !giteaConfig || !scheduleConfig || !cleanupConfig || !mirrorOptions || !advancedOptions) {
return new Response(
JSON.stringify({
success: false,
message:
"userId, githubConfig, giteaConfig, scheduleConfig, and cleanupConfig are required.",
"userId, githubConfig, giteaConfig, scheduleConfig, cleanupConfig, mirrorOptions, and advancedOptions are required.",
}),
{
status: 400,
@@ -32,6 +34,14 @@ export const POST: APIRoute = async ({ request }) => {
const existingConfig = existingConfigResult[0];
// Map UI structure to database schema structure first
const { githubConfig: mappedGithubConfig, giteaConfig: mappedGiteaConfig } = mapUiToDbConfig(
githubConfig,
giteaConfig,
mirrorOptions,
advancedOptions
);
// Preserve tokens if fields are empty
if (existingConfig) {
try {
@@ -45,12 +55,12 @@ export const POST: APIRoute = async ({ request }) => {
? JSON.parse(existingConfig.giteaConfig)
: existingConfig.giteaConfig;
if (!githubConfig.token && existingGithub.token) {
githubConfig.token = existingGithub.token;
if (!mappedGithubConfig.token && existingGithub.token) {
mappedGithubConfig.token = existingGithub.token;
}
if (!giteaConfig.token && existingGitea.token) {
giteaConfig.token = existingGitea.token;
if (!mappedGiteaConfig.token && existingGitea.token) {
mappedGiteaConfig.token = existingGitea.token;
}
} catch (tokenError) {
console.error("Failed to preserve tokens:", tokenError);
@@ -119,8 +129,8 @@ export const POST: APIRoute = async ({ request }) => {
await db
.update(configs)
.set({
githubConfig,
giteaConfig,
githubConfig: mappedGithubConfig,
giteaConfig: mappedGiteaConfig,
scheduleConfig: processedScheduleConfig,
cleanupConfig: processedCleanupConfig,
updatedAt: new Date(),
@@ -167,8 +177,8 @@ export const POST: APIRoute = async ({ request }) => {
userId,
name: "Default Configuration",
isActive: true,
githubConfig,
giteaConfig,
githubConfig: mappedGithubConfig,
giteaConfig: mappedGiteaConfig,
include: [],
exclude: [],
scheduleConfig: processedScheduleConfig,
@@ -189,19 +199,7 @@ export const POST: APIRoute = async ({ request }) => {
}
);
} catch (error) {
console.error("Error saving configuration:", error);
return new Response(
JSON.stringify({
success: false,
message:
"Error saving configuration: " +
(error instanceof Error ? error.message : "Unknown error"),
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
return createSecureErrorResponse(error, "config save", 500);
}
};
@@ -225,32 +223,40 @@ export const GET: APIRoute = async ({ request }) => {
.limit(1);
if (config.length === 0) {
// Return a default empty configuration instead of a 404 error
// Return a default empty configuration with UI structure
const defaultDbConfig = {
githubConfig: {
username: "",
token: "",
skipForks: false,
privateRepositories: false,
mirrorIssues: false,
mirrorWiki: false,
mirrorStarred: false,
useSpecificUser: false,
preserveOrgStructure: false,
skipStarredIssues: false,
},
giteaConfig: {
url: "",
token: "",
username: "",
organization: "github-mirrors",
visibility: "public",
starredReposOrg: "github",
preserveOrgStructure: false,
},
};
const uiConfig = mapDbToUiConfig(defaultDbConfig);
return new Response(
JSON.stringify({
id: null,
userId: userId,
name: "Default Configuration",
isActive: true,
githubConfig: {
username: "",
token: "",
skipForks: false,
privateRepositories: false,
mirrorIssues: false,
mirrorStarred: true,
useSpecificUser: false,
preserveOrgStructure: true,
skipStarredIssues: false,
},
giteaConfig: {
url: "",
token: "",
username: "",
organization: "github-mirrors",
visibility: "public",
starredReposOrg: "github",
},
...uiConfig,
scheduleConfig: {
enabled: false,
interval: 3600,
@@ -271,21 +277,18 @@ export const GET: APIRoute = async ({ request }) => {
);
}
return new Response(JSON.stringify(config[0]), {
// Map database structure to UI structure
const dbConfig = config[0];
const uiConfig = mapDbToUiConfig(dbConfig);
return new Response(JSON.stringify({
...dbConfig,
...uiConfig,
}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error fetching configuration:", error);
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Something went wrong",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
return createSecureErrorResponse(error, "config fetch", 500);
}
};

View File

@@ -1,7 +1,7 @@
import type { APIRoute } from "astro";
import { db, repositories, organizations, mirrorJobs, configs } from "@/lib/db";
import { eq, count, and, sql, or } from "drizzle-orm";
import { jsonResponse } from "@/lib/utils";
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
import type { DashboardApiResponse } from "@/types/dashboard";
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import { membershipRoleEnum } from "@/types/organizations";
@@ -108,15 +108,6 @@ export const GET: APIRoute = async ({ request }) => {
return jsonResponse({ data: successResponse });
} catch (error) {
console.error("Error loading dashboard for user:", userId, error);
return jsonResponse({
data: {
success: false,
error: error instanceof Error ? error.message : "Internal server error",
message: "Failed to fetch dashboard data",
},
status: 500,
});
return createSecureErrorResponse(error, "dashboard data fetch", 500);
}
};

View File

@@ -1,5 +1,6 @@
import type { APIRoute } from 'astro';
import { httpGet, HttpError } from '@/lib/http-client';
import { createSecureErrorResponse } from '@/lib/utils';
export const POST: APIRoute = async ({ request }) => {
try {
@@ -115,17 +116,6 @@ export const POST: APIRoute = async ({ request }) => {
}
// Generic error response
return new Response(
JSON.stringify({
success: false,
message: `Gitea connection test failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
},
}
);
return createSecureErrorResponse(error, "Gitea connection test", 500);
}
};

View File

@@ -8,7 +8,7 @@ import {
} from "@/types/organizations";
import type { Organization } from "@/lib/db/schema";
import { repoStatusEnum } from "@/types/Repository";
import { jsonResponse } from "@/lib/utils";
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url);
@@ -137,14 +137,6 @@ export const GET: APIRoute = async ({ request }) => {
return jsonResponse({ data: resPayload, status: 200 });
} catch (error) {
console.error("Error fetching organizations:", error);
return jsonResponse({
data: {
success: false,
error: error instanceof Error ? error.message : "Something went wrong",
},
status: 500,
});
return createSecureErrorResponse(error, "organizations fetch", 500);
}
};

View File

@@ -6,7 +6,7 @@ import {
repoStatusEnum,
type RepositoryApiResponse,
} from "@/types/Repository";
import { jsonResponse } from "@/lib/utils";
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url);
@@ -82,15 +82,6 @@ export const GET: APIRoute = async ({ request }) => {
status: 200,
});
} catch (error) {
console.error("Error fetching repositories:", error);
return jsonResponse({
data: {
success: false,
error: error instanceof Error ? error.message : "Something went wrong",
message: "An error occurred while fetching repositories.",
},
status: 500,
});
return createSecureErrorResponse(error, "repositories fetch", 500);
}
};

View File

@@ -111,7 +111,7 @@ describe("GitHub Test Connection API", () => {
})
};
});
const request = new Request("http://localhost/api/github/test-connection", {
method: "POST",
headers: {
@@ -121,13 +121,15 @@ describe("GitHub Test Connection API", () => {
token: "invalid-token"
})
});
const response = await POST({ request } as any);
expect(response.status).toBe(500);
const data = await response.json();
expect(data.success).toBe(false);
expect(data.message).toContain("Bad credentials");
// The createSecureErrorResponse function returns an error field, not success
// It sanitizes error messages for security, so we expect the generic message
expect(data.error).toBeDefined();
expect(data.error).toBe("An internal server error occurred");
});
});

View File

@@ -1,5 +1,6 @@
import type { APIRoute } from "astro";
import { Octokit } from "@octokit/rest";
import { createSecureErrorResponse } from "@/lib/utils";
export const POST: APIRoute = async ({ request }) => {
try {
@@ -83,19 +84,6 @@ export const POST: APIRoute = async ({ request }) => {
}
// Generic error response
return new Response(
JSON.stringify({
success: false,
message: `GitHub connection test failed: ${
error instanceof Error ? error.message : "Unknown error"
}`,
}),
{
status: 500,
headers: {
"Content-Type": "application/json",
},
}
);
return createSecureErrorResponse(error, "GitHub connection test", 500);
}
};

View File

@@ -1,5 +1,5 @@
import type { APIRoute } from "astro";
import { jsonResponse } from "@/lib/utils";
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
import { db } from "@/lib/db";
import { ENV } from "@/lib/config";
import { getRecoveryStatus, hasJobsNeedingRecovery } from "@/lib/recovery";
@@ -58,7 +58,7 @@ export const GET: APIRoute = async () => {
latestVersion: latestVersion,
updateAvailable: latestVersion !== "unknown" &&
currentVersion !== "unknown" &&
latestVersion !== currentVersion,
compareVersions(currentVersion, latestVersion) < 0,
database: dbStatus,
recovery: recoveryStatus,
system: systemInfo,
@@ -69,19 +69,7 @@ export const GET: APIRoute = async () => {
status: 200,
});
} catch (error) {
console.error("Health check failed:", error);
return jsonResponse({
data: {
status: "error",
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : "Unknown error",
version: process.env.npm_package_version || "unknown",
latestVersion: "unknown",
updateAvailable: false,
},
status: 503, // Service Unavailable
});
return createSecureErrorResponse(error, "health check", 503);
}
};
@@ -186,6 +174,28 @@ function formatBytes(bytes: number): string {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Compare semantic versions
* Returns:
* -1 if v1 < v2
* 0 if v1 = v2
* 1 if v1 > v2
*/
function compareVersions(v1: string, v2: string): number {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const part1 = parts1[i] || 0;
const part2 = parts2[i] || 0;
if (part1 < part2) return -1;
if (part1 > part2) return 1;
}
return 0;
}
/**
* Check for the latest version from GitHub releases
*/

View File

@@ -6,6 +6,7 @@ import { createGitHubClient } from "@/lib/github";
import { mirrorGitHubOrgToGitea } from "@/lib/gitea";
import { repoStatusEnum } from "@/types/Repository";
import { type MembershipRole } from "@/types/organizations";
import { createSecureErrorResponse } from "@/lib/utils";
import { processWithResilience } from "@/lib/utils/concurrency";
import { v4 as uuidv4 } from "uuid";
@@ -149,13 +150,6 @@ export const POST: APIRoute = async ({ request }) => {
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error in mirroring organization:", error);
return new Response(
JSON.stringify({
error:
error instanceof Error ? error.message : "An unknown error occurred.",
}),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
return createSecureErrorResponse(error, "mirror organization", 500);
}
};

View File

@@ -1,35 +1,109 @@
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
import type { MirrorRepoRequest } from "@/types/mirror";
import { POST } from "./mirror-repo";
// Create a mock POST function
const mockPOST = mock(async ({ request }) => {
const body = await request.json();
// Check for missing userId or repositoryIds
if (!body.userId || !body.repositoryIds) {
return new Response(
JSON.stringify({
error: "Missing userId or repositoryIds."
}),
{ status: 400 }
);
}
// Success case
return new Response(
JSON.stringify({
success: true,
message: "Repository mirroring started",
batchId: "test-batch-id"
}),
{ status: 200 }
);
});
// Create a mock module
const mockModule = {
POST: mockPOST
// Mock the database module
const mockDb = {
select: mock(() => ({
from: mock((table: any) => ({
where: mock(() => {
// Return config for configs table
if (table === mockConfigs) {
return {
limit: mock(() => Promise.resolve([{
id: "config-id",
userId: "user-id",
githubConfig: {
token: "github-token",
preserveOrgStructure: false,
mirrorIssues: false
},
giteaConfig: {
url: "https://gitea.example.com",
token: "gitea-token",
username: "giteauser"
}
}]))
};
}
// Return repositories for repositories table
return Promise.resolve([
{
id: "repo-id-1",
name: "test-repo-1",
visibility: "public",
status: "pending",
organization: null,
lastMirrored: null,
errorMessage: null,
forkedFrom: null,
mirroredLocation: ""
},
{
id: "repo-id-2",
name: "test-repo-2",
visibility: "public",
status: "pending",
organization: null,
lastMirrored: null,
errorMessage: null,
forkedFrom: null,
mirroredLocation: ""
}
]);
})
}))
}))
};
const mockConfigs = {};
const mockRepositories = {};
mock.module("@/lib/db", () => ({
db: mockDb,
configs: mockConfigs,
repositories: mockRepositories
}));
// Mock the gitea module
const mockMirrorGithubRepoToGitea = mock(() => Promise.resolve());
const mockMirrorGitHubOrgRepoToGiteaOrg = mock(() => Promise.resolve());
mock.module("@/lib/gitea", () => ({
mirrorGithubRepoToGitea: mockMirrorGithubRepoToGitea,
mirrorGitHubOrgRepoToGiteaOrg: mockMirrorGitHubOrgRepoToGiteaOrg
}));
// Mock the github module
const mockCreateGitHubClient = mock(() => ({}));
mock.module("@/lib/github", () => ({
createGitHubClient: mockCreateGitHubClient
}));
// Mock the concurrency module
const mockProcessWithResilience = mock(() => Promise.resolve([]));
mock.module("@/lib/utils/concurrency", () => ({
processWithResilience: mockProcessWithResilience
}));
// Mock drizzle-orm
mock.module("drizzle-orm", () => ({
eq: mock(() => ({})),
inArray: mock(() => ({}))
}));
// Mock the types
mock.module("@/types/Repository", () => ({
repositoryVisibilityEnum: {
parse: mock((value: string) => value)
},
repoStatusEnum: {
parse: mock((value: string) => value)
}
}));
describe("Repository Mirroring API", () => {
// Mock console.log and console.error to prevent test output noise
let originalConsoleLog: typeof console.log;
@@ -58,12 +132,13 @@ describe("Repository Mirroring API", () => {
})
});
const response = await mockModule.POST({ request } as any);
const response = await POST({ request } as any);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toBe("Missing userId or repositoryIds.");
expect(data.success).toBe(false);
expect(data.message).toBe("userId and repositoryIds are required.");
});
test("returns 400 if repositoryIds is missing", async () => {
@@ -77,12 +152,13 @@ describe("Repository Mirroring API", () => {
})
});
const response = await mockModule.POST({ request } as any);
const response = await POST({ request } as any);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toBe("Missing userId or repositoryIds.");
expect(data.success).toBe(false);
expect(data.message).toBe("userId and repositoryIds are required.");
});
test("returns 200 and starts mirroring repositories", async () => {
@@ -97,13 +173,13 @@ describe("Repository Mirroring API", () => {
})
});
const response = await mockModule.POST({ request } as any);
const response = await POST({ request } as any);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.success).toBe(true);
expect(data.message).toBe("Repository mirroring started");
expect(data.batchId).toBe("test-batch-id");
expect(data.message).toBe("Mirror job started.");
expect(data.repositories).toBeDefined();
});
});

View File

@@ -6,10 +6,11 @@ import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import {
mirrorGithubRepoToGitea,
mirrorGitHubOrgRepoToGiteaOrg,
getGiteaRepoOwnerAsync,
} from "@/lib/gitea";
import { createGitHubClient } from "@/lib/github";
import { processWithResilience } from "@/lib/utils/concurrency";
import { v4 as uuidv4 } from "uuid";
import { createSecureErrorResponse } from "@/lib/utils";
export const POST: APIRoute = async ({ request }) => {
try {
@@ -77,9 +78,6 @@ export const POST: APIRoute = async ({ request }) => {
// Define the concurrency limit - adjust based on API rate limits
const CONCURRENCY_LIMIT = 3;
// Generate a batch ID to group related repositories
const batchId = uuidv4();
// Process repositories in parallel with resilience to container restarts
await processWithResilience(
repos,
@@ -99,12 +97,29 @@ export const POST: APIRoute = async ({ request }) => {
// Log the start of mirroring
console.log(`Starting mirror for repository: ${repo.name}`);
// Mirror the repository based on whether it's in an organization
if (repo.organization && config.githubConfig.preserveOrgStructure) {
// Determine where the repository should be mirrored (with organization overrides)
const owner = await getGiteaRepoOwnerAsync({
config,
repository: repoData,
});
console.log(`Repository ${repo.name} will be mirrored to owner: ${owner}`);
// For single-org and starred repos strategies, or when mirroring to an org,
// always use the org mirroring function to ensure proper organization handling
const mirrorStrategy = config.giteaConfig?.mirrorStrategy ||
(config.githubConfig?.preserveOrgStructure ? "preserve" : "flat-user");
const shouldUseOrgMirror =
owner !== config.giteaConfig?.username || // Different owner means org
mirrorStrategy === "single-org" || // Single-org strategy always uses org
repoData.isStarred; // Starred repos always go to org
if (shouldUseOrgMirror) {
await mirrorGitHubOrgRepoToGiteaOrg({
config,
octokit,
orgName: repo.organization,
orgName: owner,
repository: repoData,
});
} else {
@@ -120,7 +135,6 @@ export const POST: APIRoute = async ({ request }) => {
{
userId: config.userId || "",
jobType: "mirror",
batchId,
getItemId: (repo) => repo.id,
getItemName: (repo) => repo.name,
concurrencyLimit: CONCURRENCY_LIMIT,
@@ -129,15 +143,19 @@ export const POST: APIRoute = async ({ request }) => {
checkpointInterval: 5, // Checkpoint every 5 repositories to reduce event frequency
onProgress: (completed, total, result) => {
const percentComplete = Math.round((completed / total) * 100);
console.log(`Mirroring progress: ${percentComplete}% (${completed}/${total})`);
console.log(
`Mirroring progress: ${percentComplete}% (${completed}/${total})`
);
if (result) {
console.log(`Successfully mirrored repository: ${result.name}`);
}
},
onRetry: (repo, error, attempt) => {
console.log(`Retrying repository ${repo.name} (attempt ${attempt}): ${error.message}`);
}
console.log(
`Retrying repository ${repo.name} (attempt ${attempt}): ${error.message}`
);
},
}
);
@@ -168,7 +186,10 @@ export const POST: APIRoute = async ({ request }) => {
// Enhanced error logging for better debugging
console.error("=== ERROR MIRRORING REPOSITORIES ===");
console.error("Error type:", error?.constructor?.name);
console.error("Error message:", error instanceof Error ? error.message : String(error));
console.error(
"Error message:",
error instanceof Error ? error.message : String(error)
);
if (error instanceof Error) {
console.error("Error stack:", error.stack);
@@ -181,9 +202,11 @@ export const POST: APIRoute = async ({ request }) => {
console.error("- Headers:", Object.fromEntries(request.headers.entries()));
// If it's a JSON parsing error, provide more context
if (error instanceof SyntaxError && error.message.includes('JSON')) {
if (error instanceof SyntaxError && error.message.includes("JSON")) {
console.error("🚨 JSON PARSING ERROR DETECTED:");
console.error("This suggests the response from Gitea API is not valid JSON");
console.error(
"This suggests the response from Gitea API is not valid JSON"
);
console.error("Common causes:");
console.error("- Gitea server returned HTML error page instead of JSON");
console.error("- Network connection interrupted");
@@ -194,16 +217,6 @@ export const POST: APIRoute = async ({ request }) => {
console.error("=====================================");
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "An unknown error occurred",
errorType: error?.constructor?.name || "Unknown",
timestamp: new Date().toISOString(),
troubleshooting: error instanceof SyntaxError && error.message.includes('JSON')
? "JSON parsing error detected. Check Gitea server status and logs. Ensure Gitea is returning valid JSON responses."
: "Check application logs for more details"
}),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
return createSecureErrorResponse(error, "mirror-repo API", 500);
}
};
};

View File

@@ -1,7 +1,7 @@
import type { APIRoute } from "astro";
import { db, configs, repositories } from "@/lib/db";
import { eq, inArray } from "drizzle-orm";
import { getGiteaRepoOwner, isRepoPresentInGitea } from "@/lib/gitea";
import { getGiteaRepoOwnerAsync, isRepoPresentInGitea } from "@/lib/gitea";
import {
mirrorGithubRepoToGitea,
mirrorGitHubOrgRepoToGiteaOrg,
@@ -12,6 +12,7 @@ import { repoStatusEnum, repositoryVisibilityEnum } from "@/types/Repository";
import type { RetryRepoRequest, RetryRepoResponse } from "@/types/retry";
import { processWithRetry } from "@/lib/utils/concurrency";
import { createMirrorJob } from "@/lib/helpers";
import { createSecureErrorResponse } from "@/lib/utils";
export const POST: APIRoute = async ({ request }) => {
try {
@@ -92,6 +93,7 @@ export const POST: APIRoute = async ({ request }) => {
lastMirrored: repo.lastMirrored ?? undefined,
errorMessage: repo.errorMessage ?? undefined,
forkedFrom: repo.forkedFrom ?? undefined,
mirroredLocation: repo.mirroredLocation || "",
};
// Log the start of retry operation
@@ -107,8 +109,8 @@ export const POST: APIRoute = async ({ request }) => {
status: "imported",
});
// Determine if the repository exists in Gitea
let owner = getGiteaRepoOwner({
// Determine if the repository exists in Gitea (with organization overrides)
let owner = await getGiteaRepoOwnerAsync({
config,
repository: repoData,
});
@@ -133,13 +135,23 @@ export const POST: APIRoute = async ({ request }) => {
throw new Error("Octokit client is not initialized.");
}
console.log(`Importing repo: ${repo.name} ${owner}`);
console.log(`Importing repo: ${repo.name} to owner: ${owner}`);
if (repo.organization && config.githubConfig.preserveOrgStructure) {
// For single-org and starred repos strategies, or when mirroring to an org,
// always use the org mirroring function to ensure proper organization handling
const mirrorStrategy = config.giteaConfig?.mirrorStrategy ||
(config.githubConfig?.preserveOrgStructure ? "preserve" : "flat-user");
const shouldUseOrgMirror =
owner !== config.giteaConfig?.username || // Different owner means org
mirrorStrategy === "single-org" || // Single-org strategy always uses org
repoData.isStarred; // Starred repos always go to org
if (shouldUseOrgMirror) {
await mirrorGitHubOrgRepoToGiteaOrg({
config,
octokit,
orgName: repo.organization,
orgName: owner,
repository: {
...repoData,
status: repoStatusEnum.parse("imported"),
@@ -199,12 +211,6 @@ export const POST: APIRoute = async ({ request }) => {
headers: { "Content-Type": "application/json" },
});
} catch (err) {
console.error("Error retrying repo:", err);
return new Response(
JSON.stringify({
error: err instanceof Error ? err.message : "An unknown error occurred",
}),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
return createSecureErrorResponse(err, "repository retry", 500);
}
};

View File

@@ -7,6 +7,7 @@ import type {
ScheduleSyncRepoRequest,
ScheduleSyncRepoResponse,
} from "@/types/sync";
import { createSecureErrorResponse } from "@/lib/utils";
export const POST: APIRoute = async ({ request }) => {
try {
@@ -111,6 +112,7 @@ export const POST: APIRoute = async ({ request }) => {
organization: repo.organization ?? undefined,
lastMirrored: repo.lastMirrored ?? undefined,
errorMessage: repo.errorMessage ?? undefined,
mirroredLocation: repo.mirroredLocation || "",
forkedFrom: repo.forkedFrom ?? undefined,
visibility: repositoryVisibilityEnum.parse(repo.visibility),
},
@@ -132,6 +134,7 @@ export const POST: APIRoute = async ({ request }) => {
errorMessage: repo.errorMessage ?? undefined,
forkedFrom: repo.forkedFrom ?? undefined,
visibility: repositoryVisibilityEnum.parse(repo.visibility),
mirroredLocation: repo.mirroredLocation || "",
})),
};
@@ -140,15 +143,6 @@ export const POST: APIRoute = async ({ request }) => {
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error in scheduling sync:", error);
return new Response(
JSON.stringify({
success: false,
error:
error instanceof Error ? error.message : "An unknown error occurred",
repositories: [],
} satisfies ScheduleSyncRepoResponse),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
return createSecureErrorResponse(error, "schedule sync", 500);
}
};

View File

@@ -6,7 +6,7 @@ import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import { syncGiteaRepo } from "@/lib/gitea";
import type { SyncRepoResponse } from "@/types/sync";
import { processWithResilience } from "@/lib/utils/concurrency";
import { v4 as uuidv4 } from "uuid";
import { createSecureErrorResponse } from "@/lib/utils";
export const POST: APIRoute = async ({ request }) => {
try {
@@ -67,9 +67,6 @@ export const POST: APIRoute = async ({ request }) => {
// Define the concurrency limit - adjust based on API rate limits
const CONCURRENCY_LIMIT = 5;
// Generate a batch ID to group related repositories
const batchId = uuidv4();
// Process repositories in parallel with resilience to container restarts
await processWithResilience(
repos,
@@ -83,6 +80,7 @@ export const POST: APIRoute = async ({ request }) => {
errorMessage: repo.errorMessage ?? undefined,
forkedFrom: repo.forkedFrom ?? undefined,
visibility: repositoryVisibilityEnum.parse(repo.visibility),
mirroredLocation: repo.mirroredLocation || "",
};
// Log the start of syncing
@@ -99,7 +97,6 @@ export const POST: APIRoute = async ({ request }) => {
{
userId: config.userId || "",
jobType: "sync",
batchId,
getItemId: (repo) => repo.id,
getItemName: (repo) => repo.name,
concurrencyLimit: CONCURRENCY_LIMIT,
@@ -134,6 +131,7 @@ export const POST: APIRoute = async ({ request }) => {
errorMessage: repo.errorMessage ?? undefined,
forkedFrom: repo.forkedFrom ?? undefined,
visibility: repositoryVisibilityEnum.parse(repo.visibility),
mirroredLocation: repo.mirroredLocation || "",
})),
};
@@ -143,13 +141,6 @@ export const POST: APIRoute = async ({ request }) => {
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error in syncing repositories:", error);
return new Response(
JSON.stringify({
error:
error instanceof Error ? error.message : "An unknown error occurred",
}),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
return createSecureErrorResponse(error, "repository sync", 500);
}
};

View File

@@ -0,0 +1,82 @@
import type { APIRoute } from "astro";
import { db, organizations } from "@/lib/db";
import { eq, and } from "drizzle-orm";
import { createSecureErrorResponse } from "@/lib/utils";
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
export const PATCH: APIRoute = async ({ request, params, cookies }) => {
try {
// Get token from Authorization header or cookies
const authHeader = request.headers.get("Authorization");
const token = authHeader?.split(" ")[1] || cookies.get("token")?.value;
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
// Verify token and get user ID
let userId: string;
try {
const decoded = jwt.verify(token, JWT_SECRET) as { id: string };
userId = decoded.id;
} catch (error) {
return new Response(JSON.stringify({ error: "Invalid token" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const orgId = params.id;
if (!orgId) {
return new Response(JSON.stringify({ error: "Organization ID is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const body = await request.json();
const { destinationOrg } = body;
// Validate that the organization belongs to the user
const [existingOrg] = await db
.select()
.from(organizations)
.where(and(eq(organizations.id, orgId), eq(organizations.userId, userId)))
.limit(1);
if (!existingOrg) {
return new Response(JSON.stringify({ error: "Organization not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
// Update the organization's destination override
await db
.update(organizations)
.set({
destinationOrg: destinationOrg || null,
updatedAt: new Date(),
})
.where(eq(organizations.id, orgId));
return new Response(
JSON.stringify({
success: true,
message: "Organization destination updated successfully",
destinationOrg: destinationOrg || null,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
} catch (error) {
return createSecureErrorResponse(error, "Update organization destination", 500);
}
};

View File

@@ -0,0 +1,82 @@
import type { APIRoute } from "astro";
import { db, repositories } from "@/lib/db";
import { eq, and } from "drizzle-orm";
import { createSecureErrorResponse } from "@/lib/utils";
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
export const PATCH: APIRoute = async ({ request, params, cookies }) => {
try {
// Get token from Authorization header or cookies
const authHeader = request.headers.get("Authorization");
const token = authHeader?.split(" ")[1] || cookies.get("token")?.value;
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
// Verify token and get user ID
let userId: string;
try {
const decoded = jwt.verify(token, JWT_SECRET) as { id: string };
userId = decoded.id;
} catch (error) {
return new Response(JSON.stringify({ error: "Invalid token" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const repoId = params.id;
if (!repoId) {
return new Response(JSON.stringify({ error: "Repository ID is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const body = await request.json();
const { destinationOrg } = body;
// Validate that the repository belongs to the user
const [existingRepo] = await db
.select()
.from(repositories)
.where(and(eq(repositories.id, repoId), eq(repositories.userId, userId)))
.limit(1);
if (!existingRepo) {
return new Response(JSON.stringify({ error: "Repository not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
// Update the repository's destination override
await db
.update(repositories)
.set({
destinationOrg: destinationOrg || null,
updatedAt: new Date(),
})
.where(eq(repositories.id, repoId));
return new Response(
JSON.stringify({
success: true,
message: "Repository destination updated successfully",
destinationOrg: destinationOrg || null,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
} catch (error) {
return createSecureErrorResponse(error, "Update repository destination", 500);
}
};

View File

@@ -9,7 +9,7 @@ import {
getGithubRepositories,
getGithubStarredRepositories,
} from "@/lib/github";
import { jsonResponse } from "@/lib/utils";
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
export const POST: APIRoute = async ({ request }) => {
const url = new URL(request.url);
@@ -166,12 +166,6 @@ export const POST: APIRoute = async ({ request }) => {
},
});
} catch (error) {
console.error("Error syncing GitHub data for user:", userId, error);
return jsonResponse({
data: {
error: error instanceof Error ? error.message : "Something went wrong",
},
status: 500,
});
return createSecureErrorResponse(error, "GitHub data sync", 500);
}
};

View File

@@ -2,7 +2,7 @@ import type { APIRoute } from "astro";
import { Octokit } from "@octokit/rest";
import { configs, db, organizations, repositories } from "@/lib/db";
import { and, eq } from "drizzle-orm";
import { jsonResponse } from "@/lib/utils";
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
import type {
AddOrganizationApiRequest,
AddOrganizationApiResponse,
@@ -125,12 +125,6 @@ export const POST: APIRoute = async ({ request }) => {
return jsonResponse({ data: resPayload, status: 200 });
} catch (error) {
console.error("Error inserting organization/repositories:", error);
return jsonResponse({
data: {
error: error instanceof Error ? error.message : "Something went wrong",
},
status: 500,
});
return createSecureErrorResponse(error, "organization sync", 500);
}
};

View File

@@ -4,7 +4,7 @@ import { configs, db, repositories } from "@/lib/db";
import { v4 as uuidv4 } from "uuid";
import { and, eq } from "drizzle-orm";
import { type Repository } from "@/lib/db/schema";
import { jsonResponse } from "@/lib/utils";
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
import type {
AddRepositoriesApiRequest,
AddRepositoriesApiResponse,
@@ -97,6 +97,7 @@ export const POST: APIRoute = async ({ request }) => {
status: "imported" as Repository["status"],
lastMirrored: undefined,
errorMessage: undefined,
mirroredLocation: "",
createdAt: repoData.created_at
? new Date(repoData.created_at)
: new Date(),
@@ -126,12 +127,6 @@ export const POST: APIRoute = async ({ request }) => {
return jsonResponse({ data: resPayload, status: 200 });
} catch (error) {
console.error("Error inserting repository:", error);
return jsonResponse({
data: {
error: error instanceof Error ? error.message : "Something went wrong",
},
status: 500,
});
return createSecureErrorResponse(error, "repository sync", 500);
}
};

View File

@@ -1,6 +1,7 @@
import type { APIRoute } from "astro";
import { publishEvent } from "@/lib/events";
import { v4 as uuidv4 } from "uuid";
import { createSecureErrorResponse } from "@/lib/utils";
export const POST: APIRoute = async ({ request }) => {
try {
@@ -44,13 +45,6 @@ export const POST: APIRoute = async ({ request }) => {
{ status: 200 }
);
} catch (error) {
console.error("Error publishing test event:", error);
return new Response(
JSON.stringify({
error: "Failed to publish event",
details: error instanceof Error ? error.message : String(error),
}),
{ status: 500 }
);
return createSecureErrorResponse(error, "test-event API", 500);
}
};

View File

@@ -1,63 +0,0 @@
---
import { getCollection } from 'astro:content';
import MainLayout from '../../layouts/main.astro';
// Enable prerendering for this dynamic route
export const prerender = true;
// Generate static paths for all documentation pages
export async function getStaticPaths() {
const docs = await getCollection('docs');
return docs.map(entry => ({
params: { slug: entry.slug },
props: { entry },
}));
}
// Get the documentation entry from props
const { entry } = Astro.props;
const { Content } = await entry.render();
---
<MainLayout title={entry.data.title}>
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<a
href="/docs/"
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
>
<span aria-hidden="true">&larr;</span> Back to Documentation
</a>
</div>
<article class="bg-card rounded-2xl shadow-lg p-6 border border-border">
<div class="prose prose-neutral dark:prose-invert prose-code:bg-muted prose-code:text-foreground prose-pre:bg-muted prose-pre:text-foreground prose-pre:rounded-lg prose-pre:p-4 prose-table:rounded-lg prose-table:bg-muted prose-th:text-foreground prose-td:text-muted-foreground prose-blockquote:border-l-4 prose-blockquote:border-muted prose-blockquote:bg-muted/50 prose-blockquote:p-4">
<Content />
</div>
</article>
<script type="module">
// Mermaid diagram rendering for code blocks
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
mermaid.initialize({ startOnLoad: false, theme: document.documentElement.classList.contains('dark') ? 'dark' : 'default' });
function renderMermaidDiagrams() {
document.querySelectorAll('pre code.language-mermaid').forEach((block, i) => {
const parent = block.parentElement;
if (!parent) return;
const code = block.textContent;
const id = `mermaid-diagram-${i}`;
const container = document.createElement('div');
container.className = 'my-6';
container.id = id;
parent.replaceWith(container);
mermaid.render(id, code, (svgCode) => {
container.innerHTML = svgCode;
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', renderMermaidDiagrams);
} else {
renderMermaidDiagrams();
}
</script>
</main>
</MainLayout>

View File

@@ -0,0 +1,335 @@
---
import MainLayout from '../../layouts/main.astro';
---
<MainLayout title="Architecture - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<a
href="/docs/"
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
>
<span aria-hidden="true">&larr;</span> Back to Documentation
</a>
</div>
<article class="bg-card rounded-2xl shadow-lg p-6 md:p-8 border border-border">
<!-- Header -->
<div class="mb-12 space-y-4">
<div class="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-1 4h1m-1 4h1"/>
</svg>
<span>Architecture Overview</span>
</div>
<h1 class="text-4xl font-bold tracking-tight">Gitea Mirror Architecture</h1>
<p class="text-lg text-muted-foreground leading-relaxed max-w-4xl">
This document provides a comprehensive overview of the Gitea Mirror application architecture, including component diagrams, project structure, and detailed explanations of each part of the system.
</p>
</div>
<!-- System Overview -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">System Overview</h2>
<div class="bg-card/50 border border-border/50 rounded-lg p-6 mb-8">
<p class="text-base leading-relaxed mb-6">
Gitea Mirror is a web application that automates the mirroring of GitHub repositories to Gitea instances. It provides a user-friendly interface for configuring, monitoring, and managing mirroring operations without requiring users to edit configuration files or run Docker commands.
</p>
<div class="space-y-1">
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3">Technology Stack</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{[
{ name: 'Astro', desc: 'Web framework for Server-Side Rendering (SSR)' },
{ name: 'React', desc: 'Component library for interactive UI elements' },
{ name: 'Tailwind CSS v4', desc: 'Utility-first CSS framework (with Vite plugin)' },
{ name: 'Shadcn UI', desc: 'UI component library built on Tailwind CSS' },
{ name: 'SQLite', desc: 'Database for storing configuration, state, and events' },
{ name: 'Bun', desc: 'JavaScript runtime and package manager' },
{ name: 'Drizzle ORM', desc: 'Type-safe ORM for database interactions' }
].map(tech => (
<div class="flex items-start gap-3">
<div class="w-2 h-2 rounded-full bg-primary mt-2"></div>
<div>
<span class="font-semibold text-foreground">{tech.name}</span>
<p class="text-sm text-muted-foreground">{tech.desc}</p>
</div>
</div>
))}
</div>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Architecture Diagram -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Architecture Diagram</h2>
<div class="my-8">
<div class="architecture-diagram bg-muted/30 rounded-xl p-8 border border-border">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Gitea Mirror System -->
<div class="bg-card rounded-lg border-2 border-primary/20 p-6">
<h3 class="text-lg font-semibold mb-6 text-center text-primary">Gitea Mirror System</h3>
<div class="space-y-4">
{[
{ icon: '🎨', name: 'Frontend', tech: 'Astro + React' },
{ icon: '⚙️', name: 'Backend', tech: 'Bun Runtime' },
{ icon: '🗄️', name: 'Database', tech: 'SQLite + Drizzle' }
].map((component, index) => (
<>
<div class="bg-primary/10 rounded-lg p-4 text-center">
<div class="text-2xl mb-2">{component.icon}</div>
<h4 class="font-semibold">{component.name}</h4>
<p class="text-sm text-muted-foreground mt-1">{component.tech}</p>
</div>
{index < 2 && (
<div class="flex justify-center">
<div class="text-muted-foreground">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/>
</svg>
</div>
</div>
)}
</>
))}
</div>
</div>
<!-- External APIs -->
<div class="space-y-4">
<div class="bg-card rounded-lg border-2 border-amber-500/20 p-6">
<h3 class="text-lg font-semibold mb-6 text-center text-amber-600 dark:text-amber-500">External APIs</h3>
<div class="space-y-4">
<div class="bg-amber-500/10 rounded-lg p-4 text-center">
<div class="text-2xl mb-2">🐙</div>
<h4 class="font-semibold">GitHub API</h4>
<p class="text-sm text-muted-foreground mt-1">Repository Data Source</p>
</div>
<div class="bg-amber-500/10 rounded-lg p-4 text-center">
<div class="text-2xl mb-2">🍵</div>
<h4 class="font-semibold">Gitea API</h4>
<p class="text-sm text-muted-foreground mt-1">Mirror Destination</p>
</div>
</div>
</div>
<div class="flex items-center justify-center gap-4 mt-6">
<div class="text-center">
<div class="text-sm text-muted-foreground mb-2">Data Flow</div>
<div class="flex items-center gap-2">
<span class="text-primary font-semibold">Backend</span>
<svg class="w-6 h-6 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"/>
</svg>
<span class="text-amber-600 dark:text-amber-500 font-semibold">APIs</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Component Breakdown -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Component Breakdown</h2>
<!-- Frontend -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4">Frontend (Astro + React)</h3>
<div class="pl-4 border-l-2 border-primary/20">
<p class="text-muted-foreground mb-4">
The frontend is built with Astro, a modern web framework that allows for server-side rendering and partial hydration. React components are used for interactive elements, providing a responsive and dynamic user interface.
</p>
<h4 class="font-semibold mb-3">Key Frontend Components</h4>
<div class="space-y-3">
{[
{ name: 'Dashboard', desc: 'Overview of mirroring status and recent activity' },
{ name: 'Repository Management', desc: 'Interface for managing repositories to mirror' },
{ name: 'Organization Management', desc: 'Interface for managing GitHub organizations' },
{ name: 'Configuration', desc: 'Settings for GitHub and Gitea connections' },
{ name: 'Activity Log', desc: 'Detailed log of mirroring operations' }
].map(component => (
<div class="flex gap-3">
<span class="text-primary font-mono text-sm">▸</span>
<div>
<strong>{component.name}</strong>
<p class="text-sm text-muted-foreground mt-1">{component.desc}</p>
</div>
</div>
))}
</div>
</div>
</div>
<!-- Backend -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4">Backend (Bun)</h3>
<div class="pl-4 border-l-2 border-primary/20">
<p class="text-muted-foreground mb-4">
The backend is built with Bun and provides API endpoints for the frontend to interact with. It handles:
</p>
<div class="space-y-3">
{[
'Authentication and user management',
'GitHub API integration',
'Gitea API integration',
'Mirroring operations and job queue',
'Real-time updates via Server-Sent Events (SSE) at /api/sse/',
'Job recovery system for interrupted operations',
'Graceful shutdown handling',
'Scheduled automatic mirroring',
'Database interactions with Drizzle ORM'
].map(item => (
<div class="flex gap-3">
<span class="text-primary font-mono text-sm">▸</span>
<span>{item}</span>
</div>
))}
</div>
</div>
</div>
<!-- Database -->
<div>
<h3 class="text-xl font-semibold mb-4">Database (SQLite + Drizzle ORM)</h3>
<div class="pl-4 border-l-2 border-primary/20">
<p class="text-muted-foreground mb-4">
SQLite with Bun's native SQLite driver is used for data persistence, with Drizzle ORM providing type-safe database interactions. The database stores:
</p>
<div class="space-y-3">
{[
'User accounts and authentication data',
'GitHub and Gitea configuration',
'Repository and organization information',
'Mirroring job history and status',
'Event notifications and their read status'
].map(item => (
<div class="flex gap-3">
<span class="text-primary font-mono text-sm">▸</span>
<span>{item}</span>
</div>
))}
</div>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Data Flow -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Data Flow</h2>
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary">
<ol class="space-y-4">
{[
{ title: 'User Authentication', desc: 'Users authenticate through the frontend, which communicates with the backend to validate credentials.' },
{ title: 'Configuration', desc: 'Users configure GitHub and Gitea settings through the UI, which are stored in the SQLite database.' },
{ title: 'Repository Discovery', desc: 'The backend queries the GitHub API to discover repositories based on user configuration.' },
{ title: 'Mirroring Process', desc: 'When triggered, the backend fetches repository data from GitHub and pushes it to Gitea.' },
{ title: 'Status Tracking', desc: 'All operations are logged in the database and displayed in the Activity Log.' }
].map((step, index) => (
<li class="flex gap-4">
<span class="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">
{index + 1}
</span>
<div>
<strong class="text-foreground">{step.title}</strong>
<p class="text-sm text-muted-foreground mt-1">{step.desc}</p>
</div>
</li>
))}
</ol>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Project Structure -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Project Structure</h2>
<div class="bg-muted/30 rounded-lg p-4">
<pre class="text-sm"><code>{`gitea-mirror/
├── src/ # Source code
│ ├── components/ # React components
│ ├── content/ # Documentation and content
│ ├── layouts/ # Astro layout components
│ ├── lib/ # Utility functions and database
│ ├── pages/ # Astro pages and API routes
│ └── styles/ # CSS and Tailwind styles
├── public/ # Static assets
├── data/ # Database and persistent data
├── docker/ # Docker configuration
└── scripts/ # Utility scripts for deployment and maintenance
├── gitea-mirror-lxc-local.sh # Local LXC deployment script
└── manage-db.ts # Database management tool`}</code></pre>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Deployment Options -->
<section>
<h2 class="text-2xl font-bold mb-6">Deployment Options</h2>
<p class="text-muted-foreground mb-6">Gitea Mirror supports multiple deployment options:</p>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
{[
{ icon: '🐳', name: 'Docker', desc: 'Run as a containerized application using Docker and docker-compose' },
{ icon: '📦', name: 'LXC Containers', desc: 'Deploy in Linux Containers on Proxmox VE using community script' },
{ icon: '🏃', name: 'Native', desc: 'Run directly on the host system using Bun runtime' }
].map(option => (
<div class="bg-card rounded-lg border border-border p-4 hover:border-primary/50 transition-colors">
<div class="flex items-start gap-3">
<div class="text-2xl">{option.icon}</div>
<div>
<h4 class="font-semibold mb-1">{option.name}</h4>
<p class="text-sm text-muted-foreground">{option.desc}</p>
</div>
</div>
</div>
))}
</div>
<div class="bg-muted/30 rounded-lg p-4 mt-6">
<h4 class="font-semibold mb-3">Deployment Advantages</h4>
<div class="space-y-2">
<div class="flex gap-3">
<strong class="text-primary">Docker:</strong>
<span class="text-muted-foreground">Isolation, easy updates, consistent environment</span>
</div>
<div class="flex gap-3">
<strong class="text-primary">LXC:</strong>
<span class="text-muted-foreground">Lightweight virtualization, better performance than Docker, system-level isolation</span>
</div>
<div class="flex gap-3">
<strong class="text-primary">Native:</strong>
<span class="text-muted-foreground">Best performance, direct access to system resources</span>
</div>
</div>
</div>
<div class="mt-4 text-sm text-muted-foreground">
<p><strong>Note:</strong> LXC deployment is available through the <a href="https://community-scripts.github.io/ProxmoxVE/scripts?id=gitea-mirror" class="text-primary hover:underline">Proxmox VE Community Scripts</a> project.</p>
</div>
</section>
</article>
</main>
</MainLayout>

View File

@@ -0,0 +1,512 @@
---
import MainLayout from '../../layouts/main.astro';
const envVars = [
{ name: 'NODE_ENV', desc: 'Runtime environment', default: 'development', example: 'production' },
{ name: 'DATABASE_URL', desc: 'SQLite database URL', default: 'file:data/gitea-mirror.db', example: 'file:path/to/database.db' },
{ name: 'JWT_SECRET', desc: 'Secret key for JWT auth', default: 'Auto-generated', example: 'your-secure-string' },
{ name: 'HOST', desc: 'Server host', default: 'localhost', example: '0.0.0.0' },
{ name: 'PORT', desc: 'Server port', default: '4321', example: '8080' }
];
const githubOptions = [
{ name: 'Username', desc: 'Your GitHub username', default: '-' },
{ name: 'Token', desc: 'GitHub personal access token (Classic PAT)', default: '-' },
{ name: 'Private Repositories', desc: 'Include private repositories', default: 'false' },
{ name: 'Mirror Starred', desc: 'Mirror repositories you\'ve starred', default: 'false' },
{ name: 'Mirror Issues', desc: 'Mirror issues from GitHub to Gitea', default: 'false' },
{ name: 'Mirror Wiki', desc: 'Mirror wiki pages from GitHub to Gitea', default: 'false' },
{ name: 'Mirror Organizations', desc: 'Mirror organization repositories', default: 'false' },
{ name: 'Only Mirror Orgs', desc: 'Only mirror organization repositories', default: 'false' },
{ name: 'Skip Forks', desc: 'Exclude repositories that are forks', default: 'false' },
{ name: 'Skip Starred Issues', desc: 'Skip issues for starred repositories', default: 'false' }
];
const giteaOptions = [
{ name: 'URL', desc: 'Gitea server URL', default: '-' },
{ name: 'Token', desc: 'Gitea access token', default: '-' },
{ name: 'Organization', desc: 'Default organization for mirrored repositories', default: '-' },
{ name: 'Visibility', desc: 'Default visibility for mirrored repositories', default: 'public' },
{ name: 'Starred Repos Org', desc: 'Organization for starred repositories', default: 'github' }
];
---
<MainLayout title="Configuration - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<a
href="/docs/"
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
>
<span aria-hidden="true">&larr;</span> Back to Documentation
</a>
</div>
<article class="bg-card rounded-2xl shadow-lg p-6 md:p-8 border border-border">
<!-- Header -->
<div class="mb-12 space-y-4">
<div class="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span>Configuration Guide</span>
</div>
<h1 class="text-4xl font-bold tracking-tight">Gitea Mirror Configuration</h1>
<p class="text-lg text-muted-foreground leading-relaxed max-w-4xl">
This guide provides detailed information on how to configure Gitea Mirror for your environment.
</p>
</div>
<!-- Configuration Methods -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Configuration Methods</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
<div class="bg-card rounded-lg border border-border p-6 hover:border-primary/50 transition-colors">
<div class="flex items-start gap-4">
<div class="text-2xl">🔧</div>
<div>
<h3 class="font-semibold text-lg mb-2">Environment Variables</h3>
<p class="text-sm text-muted-foreground">Set configuration options through environment variables for automated deployments</p>
</div>
</div>
</div>
<div class="bg-card rounded-lg border border-border p-6 hover:border-primary/50 transition-colors">
<div class="flex items-start gap-4">
<div class="text-2xl">🖥️</div>
<div>
<h3 class="font-semibold text-lg mb-2">Web UI</h3>
<p class="text-sm text-muted-foreground">Configure the application through the web interface after installation</p>
</div>
</div>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Environment Variables -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Environment Variables</h2>
<p class="text-muted-foreground mb-6">The following environment variables can be used to configure Gitea Mirror:</p>
<div class="overflow-x-auto">
<table class="w-full border-collapse">
<thead>
<tr class="border-b border-border">
<th class="text-left py-3 px-4 font-semibold">Variable</th>
<th class="text-left py-3 px-4 font-semibold">Description</th>
<th class="text-left py-3 px-4 font-semibold">Default</th>
<th class="text-left py-3 px-4 font-semibold">Example</th>
</tr>
</thead>
<tbody>
{envVars.map((v, i) => (
<tr class={`border-b border-border/50 hover:bg-muted/30 ${i === envVars.length - 1 ? 'border-b-0' : ''}`}>
<td class="py-3 px-4">
<code class="text-sm bg-muted px-1.5 py-0.5 rounded">{v.name}</code>
</td>
<td class="py-3 px-4 text-sm text-muted-foreground">{v.desc}</td>
<td class="py-3 px-4 text-sm"><code>{v.default}</code></td>
<td class="py-3 px-4 text-sm"><code>{v.example}</code></td>
</tr>
))}
</tbody>
</table>
</div>
<!-- Security Note -->
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4 mt-6">
<div class="flex gap-3">
<div class="text-amber-600 dark:text-amber-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div>
<h4 class="font-semibold text-amber-600 dark:text-amber-500 mb-1">Security Note</h4>
<p class="text-sm">The application will automatically generate a secure random <code class="bg-amber-500/10 px-1 py-0.5 rounded">JWT_SECRET</code> on first run if one isn't provided. This generated secret is stored in the data directory for persistence across container restarts.</p>
<p class="text-sm mt-2">While this auto-generation feature provides good security by default, you can still explicitly set your own <code class="bg-amber-500/10 px-1 py-0.5 rounded">JWT_SECRET</code> for complete control over your deployment.</p>
</div>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Web UI Configuration -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Web UI Configuration</h2>
<p class="text-muted-foreground mb-6">After installing and starting Gitea Mirror, you can configure it through the web interface:</p>
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary mb-8">
<ol class="space-y-3">
{[
'Navigate to <code class="bg-muted px-1.5 py-0.5 rounded text-sm">http://your-server:port/</code>',
'If this is your first time, you\'ll be guided through creating an admin account',
'Log in with your credentials',
'Go to the Configuration page'
].map((step, i) => (
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">{i + 1}</span>
<span set:html={step}></span>
</li>
))}
</ol>
</div>
<!-- GitHub Configuration -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4">GitHub Configuration</h3>
<p class="text-muted-foreground mb-4">The GitHub configuration section allows you to connect to GitHub and specify which repositories to mirror.</p>
<div class="overflow-x-auto mb-6">
<table class="w-full border-collapse">
<thead>
<tr class="border-b border-border">
<th class="text-left py-3 px-4 font-semibold">Option</th>
<th class="text-left py-3 px-4 font-semibold">Description</th>
<th class="text-left py-3 px-4 font-semibold">Default</th>
</tr>
</thead>
<tbody>
{githubOptions.map((opt, i) => (
<tr class={`border-b border-border/50 hover:bg-muted/30 ${i === githubOptions.length - 1 ? 'border-b-0' : ''}`}>
<td class="py-3 px-4 font-medium">{opt.name}</td>
<td class="py-3 px-4 text-sm text-muted-foreground">{opt.desc}</td>
<td class="py-3 px-4 text-sm"><code>{opt.default}</code></td>
</tr>
))}
</tbody>
</table>
</div>
<!-- GitHub Token Permissions -->
<div class="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4 mb-6">
<div class="flex gap-3">
<div class="text-blue-600 dark:text-blue-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<h4 class="font-semibold text-blue-600 dark:text-blue-500 mb-2">Required Permissions</h4>
<p class="text-sm mb-3">You need to create a <span class="font-semibold">Classic GitHub PAT Token</span> with the following scopes:</p>
<ul class="space-y-1 text-sm">
<li class="flex gap-2">
<span class="text-blue-600 dark:text-blue-500">•</span>
<span><code class="bg-blue-500/10 px-1 py-0.5 rounded">repo</code> - Full control of private repositories</span>
</li>
<li class="flex gap-2">
<span class="text-blue-600 dark:text-blue-500">•</span>
<span><code class="bg-blue-500/10 px-1 py-0.5 rounded">admin:org</code> - Full control of orgs and teams, read and write org projects</span>
</li>
</ul>
<p class="text-sm mt-2">The organization access is required for mirroring organization repositories.</p>
</div>
</div>
</div>
<div class="pl-4 border-l-2 border-primary/20">
<h5 class="font-semibold mb-3">To create a GitHub token:</h5>
<ol class="space-y-2 text-sm">
<li>Go to <a href="https://github.com/settings/tokens" class="text-primary hover:underline">GitHub Settings > Developer settings > Personal access tokens</a></li>
<li>Click "Generate new token"</li>
<li>Select the required permissions</li>
<li>Copy the generated token and paste it into Gitea Mirror</li>
</ol>
</div>
</div>
<!-- Gitea Configuration -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4">Gitea Configuration</h3>
<p class="text-muted-foreground mb-4">The Gitea configuration section allows you to connect to your Gitea instance and specify how repositories should be mirrored.</p>
<div class="overflow-x-auto mb-6">
<table class="w-full border-collapse">
<thead>
<tr class="border-b border-border">
<th class="text-left py-3 px-4 font-semibold">Option</th>
<th class="text-left py-3 px-4 font-semibold">Description</th>
<th class="text-left py-3 px-4 font-semibold">Default</th>
</tr>
</thead>
<tbody>
{giteaOptions.map((opt, i) => (
<tr class={`border-b border-border/50 hover:bg-muted/30 ${i === giteaOptions.length - 1 ? 'border-b-0' : ''}`}>
<td class="py-3 px-4 font-medium">{opt.name}</td>
<td class="py-3 px-4 text-sm text-muted-foreground">{opt.desc}</td>
<td class="py-3 px-4 text-sm"><code>{opt.default}</code></td>
</tr>
))}
</tbody>
</table>
</div>
<!-- Mirror Strategies -->
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary mb-6">
<h4 class="font-semibold text-lg mb-4">Mirror Strategies</h4>
<p class="text-sm text-muted-foreground mb-4">Choose how your repositories will be organized in Gitea:</p>
<div class="space-y-4">
<div class="bg-card rounded-lg border border-border p-4">
<h5 class="font-semibold text-base mb-2 flex items-center gap-2">
<span class="text-blue-600 dark:text-blue-500">📁</span>
Preserve GitHub Structure
</h5>
<p class="text-sm text-muted-foreground mb-2">Maintains the exact structure from GitHub:</p>
<ul class="space-y-1 text-sm ml-4">
<li class="flex gap-2">
<span class="text-muted-foreground">•</span>
<span>Personal repos → Your Gitea username</span>
</li>
<li class="flex gap-2">
<span class="text-muted-foreground">•</span>
<span>Organization repos → Same organization name in Gitea</span>
</li>
</ul>
</div>
<div class="bg-card rounded-lg border border-border p-4">
<h5 class="font-semibold text-base mb-2 flex items-center gap-2">
<span class="text-purple-600 dark:text-purple-500">🏢</span>
Single Organization
</h5>
<p class="text-sm text-muted-foreground mb-2">Consolidates all repositories into one organization:</p>
<ul class="space-y-1 text-sm ml-4">
<li class="flex gap-2">
<span class="text-muted-foreground">•</span>
<span>All repos → One designated organization</span>
</li>
<li class="flex gap-2">
<span class="text-muted-foreground">•</span>
<span>Requires setting "Organization" field</span>
</li>
</ul>
</div>
<div class="bg-card rounded-lg border border-border p-4">
<h5 class="font-semibold text-base mb-2 flex items-center gap-2">
<span class="text-green-600 dark:text-green-500">👤</span>
Flat User Structure
</h5>
<p class="text-sm text-muted-foreground mb-2">Mirrors all repositories under your user account:</p>
<ul class="space-y-1 text-sm ml-4">
<li class="flex gap-2">
<span class="text-muted-foreground">•</span>
<span>All repos → Your Gitea username</span>
</li>
<li class="flex gap-2">
<span class="text-muted-foreground">•</span>
<span>No organizations needed</span>
</li>
</ul>
</div>
</div>
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-3 mt-4">
<p class="text-sm">
<span class="font-semibold">Note:</span> Starred repositories are always mirrored to the "Starred Repos Org" (default: "starred") regardless of the chosen strategy.
</p>
</div>
</div>
<div class="pl-4 border-l-2 border-primary/20">
<h5 class="font-semibold mb-3">To create a Gitea access token:</h5>
<ol class="space-y-2 text-sm">
<li>Log in to your Gitea instance</li>
<li>Go to Settings > Applications</li>
<li>Under "Generate New Token", enter a name for your token</li>
<li>Click "Generate Token"</li>
<li>Copy the generated token and paste it into Gitea Mirror</li>
</ol>
</div>
</div>
<!-- Schedule Configuration -->
<div>
<h3 class="text-xl font-semibold mb-4">Schedule Configuration</h3>
<p class="text-muted-foreground mb-4">You can configure automatic mirroring on a schedule:</p>
<div class="overflow-x-auto">
<table class="w-full border-collapse">
<thead>
<tr class="border-b border-border">
<th class="text-left py-3 px-4 font-semibold">Option</th>
<th class="text-left py-3 px-4 font-semibold">Description</th>
<th class="text-left py-3 px-4 font-semibold">Default</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-border/50 hover:bg-muted/30">
<td class="py-3 px-4 font-medium">Enable Scheduling</td>
<td class="py-3 px-4 text-sm text-muted-foreground">Enable automatic mirroring</td>
<td class="py-3 px-4 text-sm"><code>false</code></td>
</tr>
<tr class="hover:bg-muted/30">
<td class="py-3 px-4 font-medium">Interval (seconds)</td>
<td class="py-3 px-4 text-sm text-muted-foreground">Time between mirroring operations</td>
<td class="py-3 px-4 text-sm"><code>3600</code> (1 hour)</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Advanced Configuration -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Advanced Configuration</h2>
<!-- Database Management -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4">Database Management</h3>
<p class="text-muted-foreground mb-4">Gitea Mirror includes several database management tools that can be run from the command line:</p>
<div class="bg-muted/30 rounded-lg p-4 overflow-x-auto">
<pre class="text-sm whitespace-pre-wrap break-all"><code>{`# Initialize the database (only if it doesn't exist)
bun run init-db
# Check database status
bun run check-db
# Fix database location issues
bun run fix-db
# Reset all users (for testing signup flow)
bun run reset-users
# Remove database files completely
bun run cleanup-db`}</code></pre>
</div>
</div>
<!-- Event Management -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4">Event Management</h3>
<p class="text-muted-foreground mb-4">Events in Gitea Mirror (such as repository mirroring operations) are stored in the SQLite database and can be viewed in the Activity Log page.</p>
<div class="bg-green-500/10 border border-green-500/20 rounded-lg p-4">
<div class="flex gap-3">
<div class="text-green-600 dark:text-green-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<p class="text-sm mb-2">Event Management Features:</p>
<ul class="space-y-1 text-sm">
<li class="flex gap-2">
<span class="text-green-600 dark:text-green-500">•</span>
<span>View all events with filtering by type, status, and search</span>
</li>
<li class="flex gap-2">
<span class="text-green-600 dark:text-green-500">•</span>
<span>Real-time updates via Server-Sent Events (SSE)</span>
</li>
<li class="flex gap-2">
<span class="text-green-600 dark:text-green-500">•</span>
<span>Clean up old events using the cleanup button in the Activity Log</span>
</li>
<li class="flex gap-2">
<span class="text-green-600 dark:text-green-500">•</span>
<span>Automatic cleanup with configurable retention period</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Automatic Recovery System -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4">Automatic Recovery System</h3>
<p class="text-muted-foreground mb-4">Gitea Mirror includes a robust recovery system that automatically handles interrupted operations:</p>
<div class="bg-card rounded-lg border border-border p-6">
<ul class="space-y-3">
<li class="flex gap-2">
<span class="text-primary">✓</span>
<div>
<span class="font-semibold">Startup Recovery:</span>
<p class="text-sm text-muted-foreground mt-1">Automatically recovers interrupted jobs when the application starts</p>
</div>
</li>
<li class="flex gap-2">
<span class="text-primary">✓</span>
<div>
<span class="font-semibold">Graceful Shutdown:</span>
<p class="text-sm text-muted-foreground mt-1">Saves job state before shutting down to enable recovery on restart</p>
</div>
</li>
<li class="flex gap-2">
<span class="text-primary">✓</span>
<div>
<span class="font-semibold">Job State Persistence:</span>
<p class="text-sm text-muted-foreground mt-1">Stores mirror job progress in the database for resilience</p>
</div>
</li>
</ul>
<div class="mt-4 bg-muted/30 rounded-lg p-4 overflow-x-auto">
<p class="text-sm font-medium mb-2">Manual recovery tools:</p>
<pre class="text-sm whitespace-pre-wrap break-all"><code>{`# Run startup recovery manually
bun run startup-recovery
# Fix interrupted jobs
bun scripts/fix-interrupted-jobs.ts
# Test recovery system
bun run test-recovery`}</code></pre>
</div>
</div>
</div>
<!-- Health Check Endpoint -->
<div>
<h3 class="text-xl font-semibold mb-4">Health Check Endpoint</h3>
<div class="bg-card rounded-lg border border-border p-6">
<h4 class="font-semibold mb-3">System Health Monitoring</h4>
<p class="text-muted-foreground mb-4">Gitea Mirror includes a built-in health check endpoint at <code class="bg-muted px-1.5 py-0.5 rounded">/api/health</code> that provides:</p>
<ul class="space-y-2 mb-6">
<li class="flex gap-2">
<span class="text-primary">✓</span>
<span>System status and uptime</span>
</li>
<li class="flex gap-2">
<span class="text-primary">✓</span>
<span>Database connectivity check</span>
</li>
<li class="flex gap-2">
<span class="text-primary">✓</span>
<span>Memory usage statistics</span>
</li>
<li class="flex gap-2">
<span class="text-primary">✓</span>
<span>Environment information</span>
</li>
</ul>
<div class="bg-muted/30 rounded-lg p-4 overflow-x-auto">
<pre class="text-sm whitespace-pre-wrap break-all"><code>{`# Basic check (returns 200 OK if healthy)
curl -I http://your-server:port/api/health
# Detailed health information (JSON)
curl http://your-server:port/api/health`}</code></pre>
</div>
</div>
</div>
</section>
</article>
</main>
</MainLayout>

View File

@@ -1,18 +1,37 @@
---
import { getCollection } from 'astro:content';
import MainLayout from '../../layouts/main.astro';
import { LuSettings, LuRocket, LuBookOpen } from 'react-icons/lu';
// Helper to pick an icon based on doc.slug
// We'll use inline conditional rendering instead of this function
// Define our documentation pages directly
const docs = [
{
slug: 'architecture',
title: 'Architecture',
description: 'Comprehensive overview of the Gitea Mirror application architecture.',
order: 1,
icon: LuBookOpen,
href: '/docs/architecture'
},
{
slug: 'configuration',
title: 'Configuration',
description: 'Guide to configuring Gitea Mirror for your environment.',
order: 2,
icon: LuSettings,
href: '/docs/configuration'
},
{
slug: 'quickstart',
title: 'Quick Start Guide',
description: 'Get started with Gitea Mirror quickly.',
order: 3,
icon: LuRocket,
href: '/docs/quickstart'
}
];
// Get all documentation entries, sorted by order
const docs = await getCollection('docs');
const sortedDocs = docs.sort((a, b) => {
const orderA = a.data.order || 999;
const orderB = b.data.order || 999;
return orderA - orderB;
});
// Sort by order
const sortedDocs = docs.sort((a, b) => a.order - b.order);
---
<MainLayout title="Documentation">
@@ -21,24 +40,25 @@ const sortedDocs = docs.sort((a, b) => {
<p class="mb-10 text-lg text-muted-foreground text-center">Browse guides and technical docs for Gitea Mirror.</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
{sortedDocs.map(doc => (
<a
href={`/docs/${doc.slug}`}
class="group block p-7 border border-border rounded-2xl bg-card hover:bg-muted transition-colors shadow-lg focus:ring-2 focus:ring-ring outline-none"
tabindex="0"
>
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 bg-muted rounded-full flex items-center justify-center text-muted-foreground">
{doc.slug === 'architecture' && <LuBookOpen className="w-5 h-5" />}
{doc.slug === 'configuration' && <LuSettings className="w-5 h-5" />}
{doc.slug === 'quickstart' && <LuRocket className="w-5 h-5" />}
{!['architecture', 'configuration', 'quickstart'].includes(doc.slug) && <LuBookOpen className="w-5 h-5" />}
{sortedDocs.map(doc => {
const Icon = doc.icon;
return (
<a
href={doc.href}
class="group block p-7 border border-border rounded-2xl bg-card hover:bg-muted transition-colors shadow-lg focus:ring-2 focus:ring-ring outline-none"
tabindex="0"
>
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 bg-muted rounded-full flex items-center justify-center text-muted-foreground">
<Icon className="w-5 h-5" />
</div>
<h2 class="text-xl font-semibold group-hover:text-foreground transition">{doc.title}</h2>
</div>
<h2 class="text-xl font-semibold group-hover:text-foreground transition">{doc.data.title}</h2>
</div>
<p class="text-muted-foreground">{doc.data.description}</p>
</a>
))}
<p class="text-muted-foreground">{doc.description}</p>
</a>
);
})}
</div>
</main>
</MainLayout>
</MainLayout>

View File

@@ -0,0 +1,437 @@
---
import MainLayout from '../../layouts/main.astro';
---
<MainLayout title="Quick Start Guide - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<a
href="/docs/"
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
>
<span aria-hidden="true">&larr;</span> Back to Documentation
</a>
</div>
<article class="bg-card rounded-2xl shadow-lg p-6 md:p-8 border border-border">
<!-- Header -->
<div class="mb-12 space-y-4">
<div class="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<span>Quick Start</span>
</div>
<h1 class="text-4xl font-bold tracking-tight">Gitea Mirror Quick Start Guide</h1>
<p class="text-lg text-muted-foreground leading-relaxed max-w-4xl">
This guide will help you get Gitea Mirror up and running quickly.
</p>
</div>
<!-- Prerequisites -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Prerequisites</h2>
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary mb-8">
<h3 class="font-semibold mb-4">Before you begin, make sure you have:</h3>
<div class="space-y-3">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">1</div>
<div>
<span class="font-semibold text-foreground">A GitHub account with a personal access token</span>
<p class="text-sm text-muted-foreground mt-1">Create one at <a href="https://github.com/settings/tokens" class="text-primary hover:underline">GitHub Settings</a></p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">2</div>
<div>
<span class="font-semibold text-foreground">A Gitea instance with an access token</span>
<p class="text-sm text-muted-foreground mt-1">Available in your Gitea Settings > Applications</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">3</div>
<div>
<span class="font-semibold text-foreground">One of the following:</span>
<ul class="mt-2 space-y-1 text-sm text-muted-foreground pl-4">
<li class="flex gap-2"><span>•</span> Docker and docker-compose (for Docker deployment)</li>
<li class="flex gap-2"><span>•</span> Bun 1.2.9+ (for native deployment)</li>
<li class="flex gap-2"><span>•</span> Proxmox VE or LXD (for LXC container deployment)</li>
</ul>
</div>
</div>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Installation Options -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Installation Options</h2>
<p class="text-muted-foreground mb-8">Choose the installation method that works best for your environment.</p>
<!-- Docker Installation -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4 flex items-center gap-2">
🐳 Using Docker <span class="text-sm text-muted-foreground font-normal">(Recommended for most users)</span>
</h3>
<div class="bg-card rounded-lg border border-border p-6">
<p class="text-muted-foreground mb-4">Docker provides the easiest way to get started with minimal configuration.</p>
<div class="space-y-4">
{[
{ step: 'Clone the repository', cmd: 'git clone https://github.com/arunavo4/gitea-mirror.git\ncd gitea-mirror' },
{ step: 'Start the application in production mode', cmd: 'docker compose up -d' },
{ step: 'Access the application', cmd: null, text: 'Open your browser and navigate to <a href="http://localhost:4321" class="text-primary hover:underline font-medium">http://localhost:4321</a>' }
].map((item, i) => (
<div>
<div class="flex items-center gap-2 mb-2">
<span class="text-sm font-semibold text-muted-foreground">STEP {i + 1}</span>
<span class="text-sm text-muted-foreground">{item.step}</span>
</div>
{item.cmd ? (
<div class="bg-muted/30 rounded-lg p-4">
<pre class="text-sm"><code>{item.cmd}</code></pre>
</div>
) : (
<p class="text-sm" set:html={item.text}></p>
)}
</div>
))}
</div>
</div>
</div>
<!-- Bun Installation -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4 flex items-center gap-2">
🏃 Using Bun <span class="text-sm text-muted-foreground font-normal">(Native Installation)</span>
</h3>
<div class="bg-card rounded-lg border border-border p-6">
<p class="text-muted-foreground mb-4">If you prefer to run the application directly on your system:</p>
<div class="space-y-4">
<div>
<div class="flex items-center gap-2 mb-2">
<span class="text-sm font-semibold text-muted-foreground">STEP 1</span>
<span class="text-sm text-muted-foreground">Clone the repository</span>
</div>
<div class="bg-muted/30 rounded-lg p-4">
<pre class="text-sm"><code>git clone https://github.com/arunavo4/gitea-mirror.git
cd gitea-mirror</code></pre>
</div>
</div>
<div>
<div class="flex items-center gap-2 mb-2">
<span class="text-sm font-semibold text-muted-foreground">STEP 2</span>
<span class="text-sm text-muted-foreground">Run the quick setup script</span>
</div>
<div class="bg-muted/30 rounded-lg p-4">
<pre class="text-sm"><code>bun run setup</code></pre>
</div>
<p class="text-sm text-muted-foreground mt-2">This installs dependencies and initializes the database.</p>
</div>
<div>
<div class="flex items-center gap-2 mb-2">
<span class="text-sm font-semibold text-muted-foreground">STEP 3</span>
<span class="text-sm text-muted-foreground">Choose how to run the application</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-3">
<div class="border border-border rounded-lg p-4">
<h5 class="font-semibold mb-2">Development Mode</h5>
<div class="bg-muted/30 rounded-lg p-3 text-sm">
<pre><code>bun run dev</code></pre>
</div>
<p class="text-xs text-muted-foreground mt-2">For Bun-specific features, use: <code class="bg-muted px-1 rounded">bunx --bun astro dev</code></p>
</div>
<div class="border border-border rounded-lg p-4">
<h5 class="font-semibold mb-2">Production Mode</h5>
<div class="bg-muted/30 rounded-lg p-3 text-sm">
<pre><code>bun run build
bun run start</code></pre>
</div>
</div>
</div>
</div>
<div>
<div class="flex items-center gap-2 mb-2">
<span class="text-sm font-semibold text-muted-foreground">STEP 4</span>
<span class="text-sm text-muted-foreground">Access the application</span>
</div>
<p class="text-sm">Open your browser and navigate to <a href="http://localhost:4321" class="text-primary hover:underline font-medium">http://localhost:4321</a></p>
</div>
</div>
</div>
</div>
<!-- LXC Installation -->
<div>
<h3 class="text-xl font-semibold mb-4 flex items-center gap-2">
📦 Using LXC Containers <span class="text-sm text-muted-foreground font-normal">(Recommended for Proxmox VE)</span>
</h3>
<div class="bg-card rounded-lg border border-border p-6">
<div class="space-y-4">
<h4 class="font-semibold flex items-center gap-2">
<span>Proxmox VE Installation</span>
<span class="text-xs text-muted-foreground font-normal">(Community Script)</span>
</h4>
<p class="text-sm text-muted-foreground mb-4">Deploy Gitea Mirror on Proxmox VE using the community-maintained script:</p>
<div class="bg-muted/30 rounded-lg p-4 overflow-x-auto">
<pre class="text-sm whitespace-pre-wrap break-all"><code>bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/gitea-mirror.sh)"</code></pre>
</div>
<div class="mt-4 text-sm text-muted-foreground">
<p class="font-medium mb-2">This script will:</p>
<ul class="space-y-1 pl-4">
<li class="flex gap-2"><span>•</span> Create a privileged Alpine Linux LXC container</li>
<li class="flex gap-2"><span>•</span> Install Bun runtime and dependencies</li>
<li class="flex gap-2"><span>•</span> Clone and build Gitea Mirror</li>
<li class="flex gap-2"><span>•</span> Configure systemd service for automatic startup</li>
<li class="flex gap-2"><span>•</span> Set up the application to run on port 4321</li>
</ul>
</div>
<div class="mt-4 p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<div class="flex gap-3">
<div class="text-blue-600 dark:text-blue-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div class="text-sm">
<p class="font-semibold text-blue-600 dark:text-blue-500 mb-1">Note</p>
<p>After installation, access Gitea Mirror at <code class="bg-blue-500/10 px-1 py-0.5 rounded">http://&lt;container-ip&gt;:4321</code></p>
</div>
</div>
</div>
</div>
<div class="mt-4 pt-4 border-t border-border">
<p class="text-sm text-muted-foreground">
For more information about the community script, visit the
<a href="https://community-scripts.github.io/ProxmoxVE/scripts?id=gitea-mirror" class="text-primary hover:underline">Community Scripts documentation</a>.
</p>
</div>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Initial Configuration -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Initial Configuration</h2>
<p class="text-muted-foreground mb-6">Follow these steps to configure Gitea Mirror for first use:</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{
num: '1',
title: 'Create Admin Account',
items: [
"You'll be prompted on first access",
'Choose a secure username and password',
'This will be your administrator account'
]
},
{
num: '2',
title: 'Configure GitHub Connection',
items: [
'Navigate to the Configuration page',
'Enter your GitHub credentials',
'Select repositories to mirror',
'Configure filtering options'
]
},
{
num: '3',
title: 'Configure Gitea Connection',
items: [
'Enter your Gitea server URL',
'Enter your Gitea access token',
'Configure organization settings',
'Set default visibility'
]
},
{
num: '4',
title: 'Set Up Scheduling',
items: [
'Enable automatic mirroring',
'Set the mirroring interval',
'Save your configuration'
]
}
].map(step => (
<div class="bg-card rounded-lg border border-border p-4 hover:border-primary/50 transition-colors">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">{step.num}</div>
<div>
<h4 class="font-semibold mb-1">{step.title}</h4>
<ul class="text-sm text-muted-foreground space-y-1">
{step.items.map(item => (
<li>• {item}</li>
))}
</ul>
</div>
</div>
</div>
))}
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Performing Your First Mirror -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Performing Your First Mirror</h2>
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary">
<h3 class="font-semibold mb-4">After completing the configuration, you can start mirroring repositories:</h3>
<ol class="space-y-3">
{[
'Click <strong>"Import GitHub Data"</strong> to fetch repositories from GitHub',
'Go to the <strong>Repositories</strong> page to view your imported repositories',
'Select the repositories you want to mirror',
'Click <strong>"Mirror Selected"</strong> to start the mirroring process',
'Monitor the progress on the <strong>Activity</strong> page',
"You'll receive toast notifications about the success or failure of operations"
].map((step, i) => (
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">{i + 1}</span>
<span set:html={step}></span>
</li>
))}
</ol>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Troubleshooting -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Troubleshooting</h2>
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4">
<div class="flex gap-3">
<div class="text-amber-600 dark:text-amber-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div class="space-y-3">
<h4 class="font-semibold text-amber-600 dark:text-amber-500">If you encounter any issues:</h4>
<div class="space-y-2 text-sm">
<div class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Check the <strong>Activity Log</strong> for detailed error messages</span>
</div>
<div class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Verify your GitHub and Gitea tokens have the correct permissions</span>
</div>
<div class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Ensure your Gitea instance is accessible from the machine running Gitea Mirror</span>
</div>
</div>
<div class="mt-4 space-y-3">
<h5 class="font-semibold">Check logs based on your deployment method:</h5>
<div class="bg-amber-500/5 rounded-lg p-3 space-y-2">
<div class="grid grid-cols-1 gap-2 text-sm">
<div>
<span class="font-medium">Docker:</span>
<code class="bg-amber-500/10 px-1.5 py-0.5 rounded ml-2">docker logs gitea-mirror</code>
</div>
<div>
<span class="font-medium">Native:</span>
<span class="text-muted-foreground ml-2">Check terminal output or system logs</span>
</div>
<div>
<span class="font-medium">LXC:</span>
<code class="bg-amber-500/10 px-1.5 py-0.5 rounded ml-2">systemctl status gitea-mirror</code>
<span class="text-muted-foreground ml-1">or</span>
<code class="bg-amber-500/10 px-1.5 py-0.5 rounded ml-1">journalctl -u gitea-mirror -f</code>
</div>
</div>
</div>
<div class="space-y-2 text-sm">
<div class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Use the health check endpoint: <code class="bg-amber-500/10 px-1.5 py-0.5 rounded">curl http://your-server:4321/api/health</code></span>
</div>
<div class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>For database issues: <code class="bg-amber-500/10 px-1.5 py-0.5 rounded">bun run check-db</code> or <code class="bg-amber-500/10 px-1.5 py-0.5 rounded">bun run fix-db</code></span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Next Steps -->
<section>
<h2 class="text-2xl font-bold mb-6">Next Steps</h2>
<div class="bg-gradient-to-br from-primary/5 via-transparent to-primary/5 rounded-lg p-6 border border-border">
<h3 class="font-semibold mb-4">After your initial setup:</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-3">
{[
'Explore the dashboard for an overview of your mirroring status',
'Set up automatic mirroring schedules for hands-off operation',
'Configure organization mirroring for team repositories',
'Use the cleanup button in Activity Log to manage old events'
].map(item => (
<div class="flex gap-3">
<span class="text-primary">✓</span>
<span>{item}</span>
</div>
))}
</div>
<div class="space-y-3">
<div class="flex gap-3">
<span class="text-primary">📖</span>
<span>Check out the <a href="/docs/configuration" class="text-primary hover:underline font-medium">Configuration Guide</a> for advanced settings</span>
</div>
<div class="flex gap-3">
<span class="text-primary">🏗️</span>
<span>Review the <a href="/docs/architecture" class="text-primary hover:underline font-medium">Architecture Documentation</a> to understand the system</span>
</div>
<div class="flex gap-3">
<span class="text-primary">📊</span>
<span>For server deployments, set up monitoring using the health check endpoint</span>
</div>
</div>
</div>
</div>
</section>
</article>
</main>
</MainLayout>

View File

@@ -1,8 +1,8 @@
// example.test.ts
import { describe, it, expect } from "vitest";
import { describe, test, expect } from "bun:test";
describe("Example Test", () => {
it("should pass", () => {
test("should pass", () => {
expect(true).toBe(true);
});
});

View File

@@ -1,8 +0,0 @@
import "@testing-library/jest-dom";
import { expect, afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
// Run cleanup after each test case (e.g. clearing jsdom)
afterEach(() => {
cleanup();
});

View File

@@ -1,6 +1,7 @@
import { type Config as ConfigType } from "@/lib/db/schema";
export type GiteaOrgVisibility = "public" | "private" | "limited";
export type MirrorStrategy = "preserve" | "single-org" | "flat-user";
export interface GiteaConfig {
url: string;
@@ -9,6 +10,9 @@ export interface GiteaConfig {
organization: string;
visibility: GiteaOrgVisibility;
starredReposOrg: string;
preserveOrgStructure: boolean;
mirrorStrategy?: MirrorStrategy; // New field for the strategy
personalReposOrg?: string; // Override destination for personal repos
}
export interface ScheduleConfig {
@@ -28,11 +32,24 @@ export interface DatabaseCleanupConfig {
export interface GitHubConfig {
username: string;
token: string;
skipForks: boolean;
privateRepositories: boolean;
mirrorIssues: boolean;
mirrorStarred: boolean;
preserveOrgStructure: boolean;
}
export interface MirrorOptions {
mirrorReleases: boolean;
mirrorMetadata: boolean;
metadataComponents: {
issues: boolean;
pullRequests: boolean;
labels: boolean;
milestones: boolean;
wiki: boolean;
};
}
export interface AdvancedOptions {
skipForks: boolean;
skipStarredIssues: boolean;
}
@@ -42,6 +59,8 @@ export interface SaveConfigApiRequest {
giteaConfig: GiteaConfig;
scheduleConfig: ScheduleConfig;
cleanupConfig: DatabaseCleanupConfig;
mirrorOptions?: MirrorOptions;
advancedOptions?: AdvancedOptions;
}
export interface SaveConfigApiResponse {
@@ -64,6 +83,8 @@ export interface ConfigApiResponse {
giteaConfig: GiteaConfig;
scheduleConfig: ScheduleConfig;
cleanupConfig: DatabaseCleanupConfig;
mirrorOptions?: MirrorOptions;
advancedOptions?: AdvancedOptions;
include: string[];
exclude: string[];
createdAt: Date;

73
test-security-fix.js Normal file
View File

@@ -0,0 +1,73 @@
#!/usr/bin/env node
/**
* Simple test to verify that our security fix is working correctly
* This test simulates the original security vulnerability and confirms it's been fixed
*/
import { createSecureErrorResponse } from './src/lib/utils.js';
console.log('🔒 Testing Security Fix for Information Exposure...\n');
// Test 1: Sensitive error should be sanitized
console.log('Test 1: Sensitive error with file path');
const sensitiveError = new Error('ENOENT: no such file or directory, open \'/etc/passwd\'');
const response1 = createSecureErrorResponse(sensitiveError, 'test', 500);
// Parse the response to check what's exposed
const responseText1 = await response1.text();
const responseData1 = JSON.parse(responseText1);
console.log('Original error:', sensitiveError.message);
console.log('Sanitized response:', responseData1.error);
console.log('✅ Sensitive path information hidden:', !responseData1.error.includes('/etc/passwd'));
console.log('');
// Test 2: Safe error should be exposed
console.log('Test 2: Safe error that should be exposed');
const safeError = new Error('Missing required field: userId');
const response2 = createSecureErrorResponse(safeError, 'test', 400);
const responseText2 = await response2.text();
const responseData2 = JSON.parse(responseText2);
console.log('Original error:', safeError.message);
console.log('Response:', responseData2.error);
console.log('✅ Safe error properly exposed:', responseData2.error === safeError.message);
console.log('');
// Test 3: Database connection error should be sanitized
console.log('Test 3: Database connection error');
const dbError = new Error('Connection failed: sqlite3://localhost:5432/secret_db?password=admin123');
const response3 = createSecureErrorResponse(dbError, 'test', 500);
const responseText3 = await response3.text();
const responseData3 = JSON.parse(responseText3);
console.log('Original error:', dbError.message);
console.log('Sanitized response:', responseData3.error);
console.log('✅ Database credentials hidden:', !responseData3.error.includes('password=admin123'));
console.log('');
// Test 4: Stack trace should not be exposed
console.log('Test 4: Stack trace exposure check');
const errorWithStack = new Error('Internal server error');
errorWithStack.stack = 'Error: Internal server error\n at /home/user/secret/app.js:123:45';
const response4 = createSecureErrorResponse(errorWithStack, 'test', 500);
const responseText4 = await response4.text();
const responseData4 = JSON.parse(responseText4);
console.log('Response keys:', Object.keys(responseData4));
console.log('✅ Stack trace not exposed:', !responseData4.hasOwnProperty('stack'));
console.log('✅ File paths not exposed:', !responseData4.error.includes('/home/user/secret'));
console.log('');
console.log('🎉 All security tests passed! The vulnerability has been successfully fixed.');
console.log('');
console.log('Summary of fixes:');
console.log('- ✅ Error details are logged server-side for debugging');
console.log('- ✅ Only safe, whitelisted error messages are sent to clients');
console.log('- ✅ Sensitive information like file paths, credentials, and stack traces are hidden');
console.log('- ✅ Generic error message is returned for unsafe errors');
console.log('- ✅ Timestamp is included for correlation with server logs');

View File

@@ -1,20 +0,0 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { fileURLToPath } from 'url';
import path from 'path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/tests/setup.ts'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});