mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-06 19:46:44 +03:00
Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b53a29e71 | ||
|
|
64e73f9ca8 | ||
|
|
7d23894e5f | ||
|
|
8f2a4683c1 | ||
|
|
b5323ff8b4 | ||
|
|
7fee2adb51 | ||
|
|
af139ecb2d | ||
|
|
fb827724b6 | ||
|
|
2812b576d0 | ||
|
|
347188f43d | ||
|
|
56bee451de | ||
|
|
0e9d54b517 | ||
|
|
7a04665b70 | ||
|
|
3a3ff314e0 | ||
|
|
fed74ee901 | ||
|
|
85ea502276 | ||
|
|
ffb7bd3cb0 | ||
|
|
b39d7a2179 | ||
|
|
bf99a95dc6 | ||
|
|
2ea917fdaa | ||
|
|
b841057f1a | ||
|
|
d588ce91b4 | ||
|
|
553396483e | ||
|
|
ebeabdb4fc | ||
|
|
ff209a6376 | ||
|
|
096e0c03ac | ||
|
|
63f20a7f04 | ||
|
|
34f741beef | ||
|
|
1f98f441f3 | ||
|
|
9c1ac76ff9 | ||
|
|
cf5027bafc | ||
|
|
6fd2774d43 | ||
|
|
8f379baad4 | ||
|
|
91fa3604b6 | ||
|
|
c0fff30fcb | ||
|
|
18de63d192 | ||
|
|
1fe20c3e54 | ||
|
|
7386b54a46 | ||
|
|
432a2bc54d | ||
|
|
f9d18f34ab | ||
|
|
cd86a09bbd | ||
|
|
1e2c1c686d | ||
|
|
f701574e67 | ||
|
|
4528be8cc6 | ||
|
|
80fd43ef42 | ||
|
|
3c52fe58aa | ||
|
|
319e7925ff | ||
|
|
5add8766a4 | ||
|
|
6ce70bb5bf | ||
|
|
f3aae2ec94 | ||
|
|
46d5ec46fc | ||
|
|
0caa53b67f | ||
|
|
18ecdbc252 | ||
|
|
51a6c8ca58 | ||
|
|
41b8806268 | ||
|
|
ac5c7800c1 | ||
|
|
13e7661f07 | ||
|
|
37e5b68bd5 | ||
|
|
89ca5abe7d | ||
|
|
2b78a6a4a8 | ||
|
|
c2f6e73054 | ||
|
|
c4b353aae8 | ||
|
|
4a54cf9009 | ||
|
|
fab4efd93a | ||
|
|
9f21cd6b1a | ||
|
|
9ef6017a23 | ||
|
|
502796371f | ||
|
|
b956b71c5f | ||
|
|
26b82e0f65 | ||
|
|
7c124a37d7 | ||
|
|
3e14edc571 | ||
|
|
a188869cae | ||
|
|
afac3b5ddc | ||
|
|
2ce4bb4373 | ||
|
|
5c9a3afaae | ||
|
|
de4e111095 | ||
|
|
8c4d9508c7 | ||
|
|
921eb5e07d | ||
|
|
ac1b09f7a1 | ||
|
|
9ee67ce77d | ||
|
|
92db61a2c9 | ||
|
|
cbf6e11de3 | ||
|
|
18855f09c4 |
12
.env.example
12
.env.example
@@ -18,6 +18,7 @@ DATABASE_URL=sqlite://data/gitea-mirror.db
|
||||
# Generate with: openssl rand -base64 32
|
||||
BETTER_AUTH_SECRET=change-this-to-a-secure-random-string-in-production
|
||||
BETTER_AUTH_URL=http://localhost:4321
|
||||
# PUBLIC_BETTER_AUTH_URL=https://your-domain.com # Optional: Set this if accessing from different origins (e.g., IP and domain)
|
||||
# ENCRYPTION_SECRET=optional-encryption-key-for-token-encryption # Generate with: openssl rand -base64 48
|
||||
|
||||
# ===========================================
|
||||
@@ -94,6 +95,7 @@ DOCKER_TAG=latest
|
||||
|
||||
# Release and Metadata
|
||||
# MIRROR_RELEASES=false # Mirror GitHub releases
|
||||
# RELEASE_LIMIT=10 # Maximum number of releases to mirror per repository
|
||||
# MIRROR_WIKI=false # Mirror wiki content
|
||||
|
||||
# Issue Tracking (requires MIRROR_METADATA=true)
|
||||
@@ -109,8 +111,10 @@ DOCKER_TAG=latest
|
||||
# ===========================================
|
||||
|
||||
# Basic Schedule Settings
|
||||
# SCHEDULE_ENABLED=false
|
||||
# SCHEDULE_ENABLED=false # When true, auto-imports and mirrors all repos on startup (v3.5.3+)
|
||||
# SCHEDULE_INTERVAL=3600 # Interval in seconds or cron expression (e.g., "0 2 * * *")
|
||||
# GITEA_MIRROR_INTERVAL=8h # Mirror sync interval (5m, 30m, 1h, 8h, 24h, 1d, 7d) - also triggers auto-start
|
||||
# AUTO_IMPORT_REPOS=true # Automatically discover and import new GitHub repositories during syncs
|
||||
# DELAY=3600 # Legacy: same as SCHEDULE_INTERVAL, kept for backward compatibility
|
||||
|
||||
# Execution Settings
|
||||
@@ -148,11 +152,11 @@ DOCKER_TAG=latest
|
||||
# CLEANUP_ENABLED=false
|
||||
# CLEANUP_RETENTION_DAYS=7 # Days to keep events
|
||||
|
||||
# Repository Cleanup
|
||||
# Repository Cleanup (v3.4.0+)
|
||||
# CLEANUP_DELETE_FROM_GITEA=false # Delete repos from Gitea
|
||||
# CLEANUP_DELETE_IF_NOT_IN_GITHUB=true # Delete if not in GitHub - automatically enables cleanup
|
||||
# CLEANUP_DELETE_IF_NOT_IN_GITHUB=false # Auto-remove repos that no longer exist in GitHub
|
||||
# CLEANUP_ORPHANED_REPO_ACTION=archive # Options: skip, archive, delete
|
||||
# CLEANUP_DRY_RUN=true # Test mode without actual deletion
|
||||
# CLEANUP_DRY_RUN=true # Test mode without actual deletion (set to false for production)
|
||||
|
||||
# Protected Repositories (comma-separated)
|
||||
# CLEANUP_PROTECTED_REPOS=important-repo,critical-project
|
||||
|
||||
BIN
.github/assets/logo-new.png
vendored
Normal file
BIN
.github/assets/logo-new.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
59
.github/ci/values-ci.yaml
vendored
Normal file
59
.github/ci/values-ci.yaml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
image:
|
||||
registry: ghcr.io
|
||||
repository: raylabshq/gitea-mirror
|
||||
tag: ""
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8080
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: "nginx"
|
||||
hosts:
|
||||
- host: ci.example.com
|
||||
|
||||
route:
|
||||
enabled: true
|
||||
forceHTTPS: true
|
||||
domain: ["ci.example.com"]
|
||||
gateway: "dummy-gw"
|
||||
gatewayNamespace: "default"
|
||||
http:
|
||||
gatewaySection: "http"
|
||||
https:
|
||||
gatewaySection: "https"
|
||||
|
||||
gitea-mirror:
|
||||
nodeEnv: production
|
||||
core:
|
||||
databaseUrl: "file:data/gitea-mirror.db"
|
||||
betterAuthSecret: "dummy"
|
||||
betterAuthUrl: "http://localhost:4321"
|
||||
betterAuthTrustedOrigins: "http://localhost:4321"
|
||||
github:
|
||||
username: "ci-user"
|
||||
token: "not-used-in-template"
|
||||
type: "personal"
|
||||
privateRepositories: true
|
||||
skipForks: false
|
||||
starredCodeOnly: false
|
||||
gitea:
|
||||
url: "https://gitea.example.com"
|
||||
token: "not-used-in-template"
|
||||
username: "ci-user"
|
||||
organization: "github-mirrors"
|
||||
visibility: "public"
|
||||
mirror:
|
||||
releases: true
|
||||
wiki: true
|
||||
metadata: true
|
||||
issues: true
|
||||
pullRequests: true
|
||||
starred: false
|
||||
automation:
|
||||
schedule_enabled: true
|
||||
schedule_interval: "3600"
|
||||
cleanup:
|
||||
enabled: true
|
||||
interval: "2592000"
|
||||
7
.github/workflows/README.md
vendored
7
.github/workflows/README.md
vendored
@@ -85,3 +85,10 @@ If a workflow fails:
|
||||
- Security vulnerabilities
|
||||
|
||||
For persistent issues, consider opening an issue in the repository.
|
||||
|
||||
|
||||
### Helm Test (`helm-test.yml`)
|
||||
|
||||
This workflow run on the main branch and pull requests. it:
|
||||
- Run yamllint to keep the formating unified
|
||||
- Run helm template with different value files
|
||||
|
||||
75
.github/workflows/docker-build.yml
vendored
75
.github/workflows/docker-build.yml
vendored
@@ -10,6 +10,10 @@ on:
|
||||
- 'package.json'
|
||||
- 'bun.lock*'
|
||||
- '.github/workflows/docker-build.yml'
|
||||
- 'docker-entrypoint.sh'
|
||||
- 'drizzle/**'
|
||||
- 'scripts/**'
|
||||
- 'src/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'Dockerfile'
|
||||
@@ -17,6 +21,10 @@ on:
|
||||
- 'package.json'
|
||||
- 'bun.lock*'
|
||||
- '.github/workflows/docker-build.yml'
|
||||
- 'docker-entrypoint.sh'
|
||||
- 'drizzle/**'
|
||||
- 'scripts/**'
|
||||
- 'src/**'
|
||||
schedule:
|
||||
- cron: '0 0 * * 0' # Weekly security scan on Sunday at midnight
|
||||
|
||||
@@ -48,7 +56,6 @@ jobs:
|
||||
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -89,6 +96,7 @@ jobs:
|
||||
type=sha,prefix=,suffix=,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=${{ steps.tag_version.outputs.VERSION }}
|
||||
type=ref,event=pr,prefix=pr-
|
||||
|
||||
# Build and push Docker image
|
||||
- name: Build and push Docker image
|
||||
@@ -97,20 +105,77 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
load: ${{ github.event_name == 'pull_request' }}
|
||||
tags: ${{ github.event_name == 'pull_request' && 'gitea-mirror:scan' || steps.meta.outputs.tags }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Load image locally for security scanning (PRs only)
|
||||
- name: Load image for scanning
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
load: true
|
||||
tags: gitea-mirror:scan
|
||||
cache-from: type=gha
|
||||
|
||||
# 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
|
||||
|
||||
# Add comment to PR with image details
|
||||
- name: Comment PR with image tag
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const imageTag = `pr-${prNumber}`;
|
||||
const imagePath = `${{ env.REGISTRY }}/${{ env.IMAGE }}:${imageTag}`.toLowerCase();
|
||||
|
||||
const comment = `## 🐳 Docker Image Built Successfully
|
||||
|
||||
Your PR image is available for testing:
|
||||
|
||||
**Image Tag:** \`${imageTag}\`
|
||||
**Full Image Path:** \`${imagePath}\`
|
||||
|
||||
### Pull and Test
|
||||
\`\`\`bash
|
||||
docker pull ${imagePath}
|
||||
docker run -d -p 3000:3000 --name gitea-mirror-test ${imagePath}
|
||||
\`\`\`
|
||||
|
||||
### Docker Compose Testing
|
||||
\`\`\`yaml
|
||||
services:
|
||||
gitea-mirror:
|
||||
image: ${imagePath}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- BETTER_AUTH_SECRET=your-secret-here
|
||||
\`\`\`
|
||||
|
||||
> 💡 **Note:** PR images are tagged as \`pr-<number>\` and only built for \`linux/amd64\` to speed up CI.
|
||||
> Production images (\`latest\`, version tags) are multi-platform (\`linux/amd64\`, \`linux/arm64\`).
|
||||
|
||||
---
|
||||
📦 View in [GitHub Packages](https://github.com/${{ github.repository }}/pkgs/container/gitea-mirror)`;
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: prNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: comment
|
||||
});
|
||||
|
||||
# Docker Scout comprehensive security analysis
|
||||
- name: Docker Scout - Vulnerability Analysis & Recommendations
|
||||
uses: docker/scout-action@v1
|
||||
|
||||
61
.github/workflows/helm-test.yml
vendored
Normal file
61
.github/workflows/helm-test.yml
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
name: Helm Chart CI
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'helm/gitea-mirror/**'
|
||||
- '.github/workflows/helm-test.yml'
|
||||
- '.github/ci/values-ci.yaml'
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'helm/gitea-mirror/**'
|
||||
- '.github/workflows/helm-test.yml'
|
||||
- '.github/ci/values-ci.yaml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
yamllint:
|
||||
name: Lint YAML
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install yamllint
|
||||
run: pip install --disable-pip-version-check yamllint
|
||||
- name: Run yamllint
|
||||
run: |
|
||||
yamllint -c helm/gitea-mirror/.yamllint helm/gitea-mirror
|
||||
|
||||
helm-template:
|
||||
name: Helm lint & template
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Helm
|
||||
uses: azure/setup-helm@v4
|
||||
with:
|
||||
version: v3.19.0
|
||||
- name: Helm lint
|
||||
run: |
|
||||
helm lint ./helm/gitea-mirror
|
||||
- name: Template with defaults
|
||||
run: |
|
||||
helm template test ./helm/gitea-mirror > /tmp/render-defaults.yaml
|
||||
test -s /tmp/render-defaults.yaml
|
||||
- name: Template with CI values
|
||||
run: |
|
||||
helm template test ./helm/gitea-mirror -f .github/ci/values-ci.yaml > /tmp/render-ci.yaml
|
||||
test -s /tmp/render-ci.yaml
|
||||
- name: Show a summary
|
||||
run: |
|
||||
echo "Rendered with defaults:"
|
||||
awk 'NR<=50{print} NR==51{print "..."; exit}' /tmp/render-defaults.yaml
|
||||
echo ""
|
||||
echo "Rendered with CI values:"
|
||||
awk 'NR<=50{print} NR==51{print "..."; exit}' /tmp/render-ci.yaml
|
||||
46
AGENTS.md
Normal file
46
AGENTS.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- `src/` – app code
|
||||
- `components/` (React, PascalCase files), `pages/` (Astro/API routes), `lib/` (domain + utilities, kebab-case), `hooks/`, `layouts/`, `styles/`, `tests/`, `types/`, `data/`, `content/`.
|
||||
- `scripts/` – operational TS scripts (DB init, recovery): e.g., `scripts/manage-db.ts`.
|
||||
- `drizzle/` – SQL migrations; `data/` – runtime SQLite (`gitea-mirror.db`).
|
||||
- `public/` – static assets; `dist/` – build output.
|
||||
- Key config: `astro.config.mjs`, `tsconfig.json` (alias `@/* → src/*`), `bunfig.toml` (test preload), `.env(.example)`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- Prereq: Bun `>= 1.2.9` (see `package.json`).
|
||||
- Setup: `bun run setup` – install deps and init DB.
|
||||
- Dev: `bun run dev` – start Astro dev server.
|
||||
- Build: `bun run build` – produce `dist/`.
|
||||
- Preview/Start: `bun run preview` (static preview) or `bun run start` (SSR entry).
|
||||
- Database: `bun run db:generate|migrate|push|studio` and `bun run manage-db init|check|fix|reset-users`.
|
||||
- Tests: `bun test` | `bun run test:watch` | `bun run test:coverage`.
|
||||
- Docker: see `docker-compose.yml` and variants in repo root.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Language: TypeScript, Astro, React.
|
||||
- Indentation: 2 spaces; keep existing semicolon/quote style in touched files.
|
||||
- Components: PascalCase `.tsx` in `src/components/` (e.g., `MainLayout.tsx`).
|
||||
- Modules/utils: kebab-case in `src/lib/` (e.g., `gitea-enhanced.ts`).
|
||||
- Imports: prefer alias `@/…` (configured in `tsconfig.json`).
|
||||
- Do not introduce new lint/format configs; follow current patterns.
|
||||
|
||||
## Testing Guidelines
|
||||
- Runner: Bun test (`bun:test`) with preload `src/tests/setup.bun.ts` (see `bunfig.toml`).
|
||||
- Location/Names: `**/*.test.ts(x)` under `src/**` (examples in `src/lib/**`).
|
||||
- Scope: add unit tests for new logic and API route tests for handlers.
|
||||
- Aim for meaningful coverage on DB, auth, and mirroring paths.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Commits: short, imperative, scoped when helpful (e.g., `lib: fix token parsing`, `ui: align buttons`).
|
||||
- PRs must include:
|
||||
- Summary, rationale, and testing steps/commands.
|
||||
- Linked issues (e.g., `Closes #123`).
|
||||
- Screenshots/gifs for UI changes.
|
||||
- Notes on DB/migration or .env impacts; update `docs/`/CHANGELOG if applicable.
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Never commit secrets. Copy `.env.example` → `.env` and fill values; prefer `bun run startup-env-config` to validate.
|
||||
- SQLite files live in `data/`; avoid committing generated DBs.
|
||||
- Certificates (if used) reside in `certs/`; manage locally or via Docker secrets.
|
||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -58,6 +58,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Updated README with new features
|
||||
- Enhanced CLAUDE.md with repository status definitions
|
||||
|
||||
## [3.7.1] - 2025-09-14
|
||||
|
||||
### Fixed
|
||||
- Cleanup archiving for mirror repositories now works reliably (refs #84; awaiting user confirmation).
|
||||
- Gitea rejects names violating the AlphaDashDot rule; archiving a mirror now uses a sanitized rename strategy (`archived-<name>`), with a timestamped fallback on conflicts or validation errors.
|
||||
- Owner resolution during cleanup no longer uses the GitHub owner by mistake. It prefers `mirroredLocation`, falls back to computed Gitea owner via configuration, and verifies location with a presence check to avoid `GetUserByName` 404s.
|
||||
- Repositories UI crash resolved when cleanup marked repos as archived.
|
||||
- Added `"archived"` to repository/job status enums, fixing Zod validation errors on the Repositories page.
|
||||
|
||||
### Changed
|
||||
- Archiving logic for mirror repos is non-destructive by design: data is preserved, repo is renamed with an archive marker, and mirror interval is reduced (best‑effort) to minimize sync attempts.
|
||||
- Cleanup service updates DB to `status: "archived"` and `isArchived: true` on successful archive path.
|
||||
|
||||
### Notes
|
||||
- This release addresses the scenario where a GitHub source disappears (deleted/banned), ensuring Gitea backups are preserved even when using `CLEANUP_DELETE_IF_NOT_IN_GITHUB=true` with `CLEANUP_ORPHANED_REPO_ACTION=archive`.
|
||||
- No database migration required.
|
||||
|
||||
## [3.2.6] - 2025-08-09
|
||||
|
||||
### Fixed
|
||||
|
||||
20
CLAUDE.md
20
CLAUDE.md
@@ -193,7 +193,7 @@ export async function POST({ request }: APIContext) {
|
||||
|
||||
### Advanced Options (UI Fields)
|
||||
- **skipForks**: Skip forked repositories (default: false)
|
||||
- **skipStarredIssues**: Skip issues for starred repositories (default: false) - enables "Lightweight mode" for starred repos
|
||||
- **starredCodeOnly**: Skip issues for starred repositories (default: false) - enables "Lightweight mode" for starred repos
|
||||
|
||||
### Repository Statuses
|
||||
Repositories can have the following statuses:
|
||||
@@ -208,6 +208,24 @@ Repositories can have the following statuses:
|
||||
- **deleting**: Repository being deleted
|
||||
- **deleted**: Repository deleted
|
||||
|
||||
### Scheduling and Synchronization (Issue #72 Fixes)
|
||||
|
||||
#### Fixed Issues
|
||||
1. **Mirror Interval Bug**: Added `mirror_interval` parameter to Gitea API calls when creating mirrors (previously defaulted to 24h)
|
||||
2. **Auto-Discovery**: Scheduler now automatically discovers and imports new GitHub repositories
|
||||
3. **Interval Updates**: Sync operations now update existing mirrors' intervals to match configuration
|
||||
4. **Repository Cleanup**: Integrated automatic cleanup of orphaned repositories (repos removed from GitHub)
|
||||
|
||||
#### Environment Variables for Auto-Import
|
||||
- **AUTO_IMPORT_REPOS**: Set to `false` to disable automatic repository discovery (default: enabled)
|
||||
|
||||
#### How Scheduling Works
|
||||
- **Scheduler Service**: Runs every minute to check for scheduled tasks
|
||||
- **Sync Interval**: Configured via `GITEA_MIRROR_INTERVAL` or UI (e.g., "8h", "30m", "1d")
|
||||
- **Auto-Import**: Checks GitHub for new repositories during each scheduled sync
|
||||
- **Auto-Cleanup**: Removes repositories that no longer exist in GitHub (if enabled)
|
||||
- **Mirror Interval Update**: Updates Gitea's internal mirror interval during sync operations
|
||||
|
||||
### Authentication Configuration
|
||||
|
||||
#### SSO Provider Configuration
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
|
||||
FROM oven/bun:1.2.21-alpine AS base
|
||||
FROM oven/bun:1.2.23-alpine AS base
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat python3 make g++ gcc wget sqlite openssl ca-certificates
|
||||
|
||||
|
||||
64
README.md
64
README.md
@@ -40,7 +40,10 @@ First user signup becomes admin. Configure GitHub and Gitea through the web inte
|
||||
- 🚫 **Repository ignore** - Mark specific repos to skip
|
||||
- 🔐 Secure authentication with Better Auth (email/password, SSO, OIDC)
|
||||
- 📊 Real-time dashboard with activity logs
|
||||
- ⏱️ Scheduled automatic mirroring with flexible intervals
|
||||
- ⏱️ Scheduled automatic mirroring with configurable intervals
|
||||
- 🔄 **Auto-discovery** - Automatically import new GitHub repositories (v3.4.0+)
|
||||
- 🧹 **Repository cleanup** - Auto-remove repos deleted from GitHub (v3.4.0+)
|
||||
- 🎯 **Proper mirror intervals** - Respects configured sync intervals (v3.4.0+)
|
||||
- 🗑️ Automatic database cleanup with configurable retention
|
||||
- 🐳 Dockerized with multi-arch support (AMD64/ARM64)
|
||||
|
||||
@@ -204,25 +207,62 @@ Enable in Settings → Mirror Options → Mirror metadata
|
||||
- **Automatic Cleanup** - Configure retention period for activity logs
|
||||
- **Scheduled Sync** - Set custom intervals for automatic mirroring
|
||||
|
||||
### Automatic Mirroring
|
||||
### Automatic Syncing & Synchronization
|
||||
|
||||
Gitea Mirror can automatically sync your repositories at regular intervals. There are two ways to configure this:
|
||||
Gitea Mirror provides powerful automatic synchronization features:
|
||||
|
||||
#### Via Web Interface (Recommended)
|
||||
Navigate to the Configuration page and enable "Automatic Mirroring" with your preferred interval (e.g., every 6 hours, daily, etc.).
|
||||
#### Features (v3.4.0+)
|
||||
- **Auto-discovery**: Automatically discovers and imports new GitHub repositories
|
||||
- **Repository cleanup**: Removes repositories that no longer exist in GitHub
|
||||
- **Proper intervals**: Mirrors respect your configured sync intervals (not Gitea's default 24h)
|
||||
- **Smart scheduling**: Only syncs repositories that need updating
|
||||
- **Auto-start on boot** (v3.5.3+): Automatically imports and mirrors all repositories when `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` is set - no manual clicks required!
|
||||
|
||||
#### Via Environment Variables
|
||||
Set `GITEA_MIRROR_INTERVAL` to automatically enable scheduled mirroring:
|
||||
#### Configuration via Web Interface (Recommended)
|
||||
Navigate to the Configuration page and enable "Automatic Syncing" with your preferred interval.
|
||||
|
||||
#### Configuration via Environment Variables
|
||||
|
||||
**🚀 Set it and forget it!** With these environment variables, Gitea Mirror will automatically:
|
||||
1. **Import** all your GitHub repositories on startup (no manual import needed!)
|
||||
2. **Mirror** them to Gitea immediately
|
||||
3. **Keep them synchronized** based on your interval
|
||||
4. **Auto-discover** new repos you create/star on GitHub
|
||||
5. **Clean up** repos you delete from GitHub
|
||||
|
||||
```bash
|
||||
# Examples of supported formats:
|
||||
# Option 1: Enable automatic scheduling (triggers auto-start)
|
||||
SCHEDULE_ENABLED=true
|
||||
SCHEDULE_INTERVAL=3600 # Check every hour (or use cron: "0 * * * *")
|
||||
|
||||
# Option 2: Set mirror interval (also triggers auto-start)
|
||||
GITEA_MIRROR_INTERVAL=8h # Every 8 hours
|
||||
GITEA_MIRROR_INTERVAL=30m # Every 30 minutes
|
||||
GITEA_MIRROR_INTERVAL=1d # Daily
|
||||
GITEA_MIRROR_INTERVAL=86400 # Every 86400 seconds (24 hours)
|
||||
# Other examples: 5m, 30m, 1h, 24h, 1d, 7d
|
||||
|
||||
# Advanced: Use cron expressions for specific times
|
||||
SCHEDULE_INTERVAL="0 2 * * *" # Daily at 2 AM (optimize bandwidth usage)
|
||||
|
||||
# Auto-import new repositories (default: true)
|
||||
AUTO_IMPORT_REPOS=true
|
||||
|
||||
# Auto-cleanup orphaned repositories
|
||||
CLEANUP_DELETE_IF_NOT_IN_GITHUB=true
|
||||
CLEANUP_ORPHANED_REPO_ACTION=archive # 'archive' (recommended) or 'delete'
|
||||
CLEANUP_DRY_RUN=false # Set to true to test without changes
|
||||
```
|
||||
|
||||
When this variable is set, the scheduler automatically enables and runs at the specified interval. The timer starts from the last successful sync, not from container startup.
|
||||
**Important Notes**:
|
||||
- **Auto-Start**: When `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` is set, the service automatically imports all GitHub repositories and mirrors them on startup. No manual "Import" or "Mirror" button clicks required!
|
||||
- The scheduler checks every minute for tasks to run. The `GITEA_MIRROR_INTERVAL` determines how often each repository is actually synced. For example, with `8h`, each repo syncs every 8 hours from its last successful sync.
|
||||
|
||||
**🛡️ Backup Protection Features**:
|
||||
- **No Accidental Deletions**: Repository cleanup is automatically skipped if GitHub is inaccessible (account deleted, banned, or API errors)
|
||||
- **Archive Never Deletes Data**: The `archive` action preserves all repository data:
|
||||
- Regular repositories: Made read-only using Gitea's archive feature
|
||||
- Mirror repositories: Renamed with `[ARCHIVED]` prefix (Gitea API limitation prevents archiving mirrors)
|
||||
- Failed operations: Repository remains fully accessible even if marking as archived fails
|
||||
- **The Whole Point of Backups**: Your Gitea mirrors are preserved even when GitHub sources disappear - that's why you have backups!
|
||||
- **Strongly Recommended**: Always use `CLEANUP_ORPHANED_REPO_ACTION=archive` (default) instead of `delete`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
346
bun.lock
346
bun.lock
@@ -5,10 +5,11 @@
|
||||
"name": "gitea-mirror",
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/mdx": "4.3.4",
|
||||
"@astrojs/node": "9.4.3",
|
||||
"@astrojs/react": "^4.3.0",
|
||||
"@better-auth/sso": "^1.3.7",
|
||||
"@astrojs/mdx": "4.3.6",
|
||||
"@astrojs/node": "9.4.4",
|
||||
"@astrojs/react": "^4.3.1",
|
||||
"@better-auth/sso": "^1.3.24",
|
||||
"@octokit/plugin-throttling": "^11.0.2",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
@@ -19,6 +20,7 @@
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
@@ -27,47 +29,47 @@
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react": "^19.1.16",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"astro": "^5.13.4",
|
||||
"astro": "^5.14.1",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-auth": "^1.3.7",
|
||||
"better-auth": "^1.3.24",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dotenv": "^17.2.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"fuse.js": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.542.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"tw-animate-css": "^1.3.7",
|
||||
"typescript": "^5.9.2",
|
||||
"uuid": "^11.1.0",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"uuid": "^13.0.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.1.5",
|
||||
"zod": "^4.1.11",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/jest-dom": "^6.9.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/bun": "^1.2.21",
|
||||
"@types/bun": "^1.2.23",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"drizzle-kit": "^0.31.5",
|
||||
"jsdom": "^26.1.0",
|
||||
"tsx": "^4.20.5",
|
||||
"tsx": "^4.20.6",
|
||||
"vitest": "^3.2.4",
|
||||
},
|
||||
},
|
||||
@@ -87,19 +89,19 @@
|
||||
|
||||
"@astrojs/compiler": ["@astrojs/compiler@2.12.2", "", {}, "sha512-w2zfvhjNCkNMmMMOn5b0J8+OmUaBL1o40ipMvqcG6NRpdC+lKxmTi48DT8Xw0SzJ3AfmeFLB45zXZXtmbsjcgw=="],
|
||||
|
||||
"@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.2", "", {}, "sha512-KCkCqR3Goym79soqEtbtLzJfqhTWMyVaizUi35FLzgGSzBotSw8DB1qwsu7U96ihOJgYhDk2nVPz+3LnXPeX6g=="],
|
||||
"@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.3", "", {}, "sha512-6Pl0bQEIChuW5wqN7jdKrzWfCscW2rG/Cz+fzt4PhSQX2ivBpnhXgFUCs0M3DCYvjYHnPVG2W36X5rmFjZ62sw=="],
|
||||
|
||||
"@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.6", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.2", "@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.4", "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-bwylYktCTsLMVoCOEHbn2GSUA3c5KT/qilekBKA3CBng0bo1TYjNZPr761vxumRk9kJGqTOtU+fgCAp5Vwokug=="],
|
||||
"@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.7", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.3", "@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.2.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.12.2", "smol-toml": "^1.4.2", "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-KXGdq6/BC18doBCYXp08alHlWChH0hdD2B1qv9wIyOHbvwI5K6I7FhSta8dq1hBQNdun8YkKPR013D/Hm8xd0g=="],
|
||||
|
||||
"@astrojs/mdx": ["@astrojs/mdx@4.3.4", "", { "dependencies": { "@astrojs/markdown-remark": "6.3.6", "@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-Ew3iP+6zuzzJWNEH5Qr1iknrue1heEfgmfuMpuwLaSwqlUiJQ0NDb2oxKosgWU1ROYmVf1H4KCmS6QdMWKyFjw=="],
|
||||
"@astrojs/mdx": ["@astrojs/mdx@4.3.6", "", { "dependencies": { "@astrojs/markdown-remark": "6.3.7", "@mdx-js/mdx": "^3.1.1", "acorn": "^8.15.0", "es-module-lexer": "^1.7.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.6", "unist-util-visit": "^5.0.0", "vfile": "^6.0.3" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-jH04tYgaqLfq3To42+z1oEcXrXUzo3BxZ4fTkb+7BEmOJkQ9/c3iIixFEC+x0GgE8lJb4SuEDGldpAv7+1yY8A=="],
|
||||
|
||||
"@astrojs/node": ["@astrojs/node@9.4.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.2", "send": "^1.2.0", "server-destroy": "^1.0.1" }, "peerDependencies": { "astro": "^5.7.0" } }, "sha512-P9BQHY8wQU1y9obknXzxV5SS3EpdaRnuDuHKr3RFC7t+2AzcMXeVmMJprQGijnQ8VdijJ8aS7+12tx325TURMQ=="],
|
||||
"@astrojs/node": ["@astrojs/node@9.4.4", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.3", "send": "^1.2.0", "server-destroy": "^1.0.1" }, "peerDependencies": { "astro": "^5.7.0" } }, "sha512-zQelZmeejnpw3Y5cj2gCyAZ6HT7tjgsWLZH8k40s3bTaT6lqJXlPtKJeIsuEcod21vZLODqBEQeu0CWrWm01EQ=="],
|
||||
|
||||
"@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="],
|
||||
|
||||
"@astrojs/react": ["@astrojs/react@4.3.0", "", { "dependencies": { "@vitejs/plugin-react": "^4.4.1", "ultrahtml": "^1.6.0", "vite": "^6.3.5" }, "peerDependencies": { "@types/react": "^17.0.50 || ^18.0.21 || ^19.0.0", "@types/react-dom": "^17.0.17 || ^18.0.6 || ^19.0.0", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0" } }, "sha512-N02aj52Iezn69qHyx5+XvPqgsPMEnel9mI5JMbGiRMTzzLMuNaxRVoQTaq2024Dpr7BLsxCjqMkNvelqMDhaHA=="],
|
||||
"@astrojs/react": ["@astrojs/react@4.3.1", "", { "dependencies": { "@vitejs/plugin-react": "^4.7.0", "ultrahtml": "^1.6.0", "vite": "^6.3.6" }, "peerDependencies": { "@types/react": "^17.0.50 || ^18.0.21 || ^19.0.0", "@types/react-dom": "^17.0.17 || ^18.0.6 || ^19.0.0", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0" } }, "sha512-Jhv35TsDHuQLvwof2z10P3g1s9wIR4UN9jE7O4NZBJNXOt/+qk+L0rY9th4SX7VzccKmRltUGxAhI1cXH52gXw=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -111,7 +113,7 @@
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.27.3", "", {}, "sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.28.3", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.3", "@babel/parser": "^7.28.3", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.3", "@babel/types": "^7.28.2", "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-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ=="],
|
||||
"@babel/core": ["@babel/core@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@jridgewell/remapping": "^2.3.5", "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-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="],
|
||||
|
||||
@@ -131,9 +133,9 @@
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.28.3", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.2" } }, "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw=="],
|
||||
"@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.28.3", "", { "dependencies": { "@babel/types": "^7.28.2" }, "bin": "./bin/babel-parser.js" }, "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA=="],
|
||||
"@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -143,13 +145,15 @@
|
||||
|
||||
"@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.28.3", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.3", "@babel/template": "^7.27.2", "@babel/types": "^7.28.2", "debug": "^4.3.1" } }, "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ=="],
|
||||
"@babel/traverse": ["@babel/traverse@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/types": "^7.28.4", "debug": "^4.3.1" } }, "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
|
||||
"@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
|
||||
|
||||
"@better-auth/sso": ["@better-auth/sso@1.3.7", "", { "dependencies": { "@better-fetch/fetch": "^1.1.18", "better-auth": "^1.3.7", "fast-xml-parser": "^5.2.5", "jose": "^5.9.6", "oauth2-mock-server": "^7.2.0", "samlify": "^2.10.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-MTwBiNash7HN0nLtQiL1tvYgWBn6GjYj6EYvtrQeb0/+UW0tjBDgsl39ojiFFSWGuT0gxPv+ij8tQNaFmQ1+2g=="],
|
||||
"@better-auth/core": ["@better-auth/core@1.3.24", "", { "dependencies": { "better-call": "1.0.19", "zod": "^4.1.5" } }, "sha512-nU4aj5SA0COXAls0p3htIWmGPOG+76HULd9tG8CEUfwcK95rRrUIUN74FKvsAu3b18AVj3E7cL4bYrQS3KYKRw=="],
|
||||
|
||||
"@better-auth/utils": ["@better-auth/utils@0.2.6", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-3y/vaL5Ox33dBwgJ6ub3OPkVqr6B5xL2kgxNHG8eHZuryLyG/4JSPGqjbdRSgjuy9kALUZYDFl+ORIAxlWMSuA=="],
|
||||
"@better-auth/sso": ["@better-auth/sso@1.3.24", "", { "dependencies": { "@better-fetch/fetch": "^1.1.18", "fast-xml-parser": "^5.2.5", "jose": "^6.1.0", "oauth2-mock-server": "^7.2.1", "samlify": "^2.10.1", "zod": "^4.1.5" }, "peerDependencies": { "better-auth": "1.3.24" } }, "sha512-amlUbuKpTotFPBOsl+6L4WvPYQ4Hd37DfLxAeeiCqCaKUiHLyiepgH7/zPll4vMSB5gYt1e312J70S1Kz9v53g=="],
|
||||
|
||||
"@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="],
|
||||
|
||||
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.18", "", {}, "sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA=="],
|
||||
|
||||
@@ -181,9 +185,9 @@
|
||||
|
||||
"@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=="],
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="],
|
||||
|
||||
"@esbuild-kit/esm-loader": ["tsx@4.20.5", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw=="],
|
||||
"@esbuild-kit/esm-loader": ["tsx@4.20.6", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.9", "", { "os": "aix", "cpu": "ppc64" }, "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA=="],
|
||||
|
||||
@@ -247,47 +251,53 @@
|
||||
|
||||
"@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.0" }, "os": "darwin", "cpu": "arm64" }, "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.0" }, "os": "darwin", "cpu": "x64" }, "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.0", "", { "os": "linux", "cpu": "arm" }, "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="],
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.0" }, "os": "linux", "cpu": "arm" }, "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="],
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.0" }, "os": "linux", "cpu": "arm64" }, "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.0" }, "os": "linux", "cpu": "ppc64" }, "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="],
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.0" }, "os": "linux", "cpu": "s390x" }, "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="],
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.0" }, "os": "linux", "cpu": "x64" }, "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="],
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" }, "os": "linux", "cpu": "arm64" }, "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="],
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.0" }, "os": "linux", "cpu": "x64" }, "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.3", "", { "dependencies": { "@emnapi/runtime": "^1.4.4" }, "cpu": "none" }, "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.3", "", { "os": "win32", "cpu": "x64" }, "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g=="],
|
||||
|
||||
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
@@ -297,15 +307,15 @@
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="],
|
||||
|
||||
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
|
||||
|
||||
"@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=="],
|
||||
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.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-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/ciphers@0.6.0", "", {}, "sha512-mIbq/R9QXk5/cTfESb1OKtyFnk7oc1Om/8onA1158K9/OZUQFDEVy55jVTato+xmp3XX6F6Qh0zz0Nc1AxAlRQ=="],
|
||||
"@noble/ciphers": ["@noble/ciphers@2.0.0", "", {}, "sha512-j/l6jpnpaIBM87cAYPJzi/6TgqmBv9spkqPyCXvRYsu5uxqh6tPJZDnD85yo8VWqzTuTQPgfv7NgT63u7kbwAQ=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
"@noble/hashes": ["@noble/hashes@2.0.0", "", {}, "sha512-h8VUBlE8R42+XIDO229cgisD287im3kdY6nbNZJFjc6ZvKIXPYXe6Vc/t+kyjFdMFyt5JpapzTsEg8n63w5/lw=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -321,7 +331,7 @@
|
||||
|
||||
"@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/openapi-types": ["@octokit/openapi-types@26.0.0", "", {}, "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA=="],
|
||||
|
||||
"@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@13.0.1", "", { "dependencies": { "@octokit/types": "^14.1.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-m1KvHlueScy4mQJWvFDCxFBTIdXS0K1SgFGLmqHyX90mZdCIv6gWBbKRhatxRjhGlONuTK/hztYdaqrTXcFZdQ=="],
|
||||
|
||||
@@ -329,13 +339,15 @@
|
||||
|
||||
"@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/plugin-throttling": ["@octokit/plugin-throttling@11.0.2", "", { "dependencies": { "@octokit/types": "^15.0.0", "bottleneck": "^2.15.3" }, "peerDependencies": { "@octokit/core": "^7.0.0" } }, "sha512-ntNIig4zZhQVOZF4fG9Wt8QCoz9ehb+xnlUwp74Ic2ANChCk8oKmRwV9zDDCtrvU1aERIOvtng8wsalEX7Jk5Q=="],
|
||||
|
||||
"@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@7.0.0", "", { "dependencies": { "@octokit/types": "^14.0.0" } }, "sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg=="],
|
||||
|
||||
"@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=="],
|
||||
"@octokit/types": ["@octokit/types@15.0.0", "", { "dependencies": { "@octokit/openapi-types": "^26.0.0" } }, "sha512-8o6yDfmoGJUIeR9OfYU0/TUJTnMPG2r68+1yEdUeG2Fdqpj8Qetg0ziKIgcBm0RW/j29H41WP37CYCEhp6GoHQ=="],
|
||||
|
||||
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
|
||||
|
||||
@@ -399,6 +411,8 @@
|
||||
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@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-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@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-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="],
|
||||
|
||||
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@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.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@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-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="],
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@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-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||
@@ -439,9 +453,9 @@
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.34", "", {}, "sha512-LyAREkZHP5pMom7c24meKmJCdhf2hEyvam2q0unr3or9ydwDL+DJ8chTF6Av/RFPb3rH8UFBdMzO5MxTZW97oA=="],
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.38", "", {}, "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw=="],
|
||||
|
||||
"@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=="],
|
||||
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "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-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.41.1", "", { "os": "android", "cpu": "arm" }, "sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw=="],
|
||||
|
||||
@@ -483,17 +497,17 @@
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.41.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Wq2zpapRYLfi4aKxf2Xff0tN+7slj2d4R87WEzqw7ZLsVvO5zwYCIuEGSZYiK41+GlwUo1HiR+GdkLEJnCKTCw=="],
|
||||
|
||||
"@shikijs/core": ["@shikijs/core@3.4.2", "", { "dependencies": { "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-AG8vnSi1W2pbgR2B911EfGqtLE9c4hQBYkv/x7Z+Kt0VxhgQKcW7UNDVYsu9YxwV6u+OJrvdJrMq6DNWoBjihQ=="],
|
||||
"@shikijs/core": ["@shikijs/core@3.12.2", "", { "dependencies": { "@shikijs/types": "3.12.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-L1Safnhra3tX/oJK5kYHaWmLEBJi1irASwewzY3taX5ibyXyMkkSDZlq01qigjryOBwrXSdFgTiZ3ryzSNeu7Q=="],
|
||||
|
||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.4.2", "", { "dependencies": { "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-1/adJbSMBOkpScCE/SB6XkjJU17ANln3Wky7lOmrnpl+zBdQ1qXUJg2GXTYVHRq+2j3hd1DesmElTXYDgtfSOQ=="],
|
||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.12.2", "", { "dependencies": { "@shikijs/types": "3.12.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Nm3/azSsaVS7hk6EwtHEnTythjQfwvrO5tKqMlaH9TwG1P+PNaR8M0EAKZ+GaH2DFwvcr4iSfTveyxMIvXEHMw=="],
|
||||
|
||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.4.2", "", { "dependencies": { "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-zcZKMnNndgRa3ORja6Iemsr3DrLtkX3cAF7lTJkdMB6v9alhlBsX9uNiCpqofNrXOvpA3h6lHcLJxgCIhVOU5Q=="],
|
||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.12.2", "", { "dependencies": { "@shikijs/types": "3.12.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hozwnFHsLvujK4/CPVHNo3Bcg2EsnG8krI/ZQ2FlBlCRpPZW4XAEQmEwqegJsypsTAN9ehu2tEYe30lYKSZW/w=="],
|
||||
|
||||
"@shikijs/langs": ["@shikijs/langs@3.4.2", "", { "dependencies": { "@shikijs/types": "3.4.2" } }, "sha512-H6azIAM+OXD98yztIfs/KH5H4PU39t+SREhmM8LaNXyUrqj2mx+zVkr8MWYqjceSjDw9I1jawm1WdFqU806rMA=="],
|
||||
"@shikijs/langs": ["@shikijs/langs@3.12.2", "", { "dependencies": { "@shikijs/types": "3.12.2" } }, "sha512-bVx5PfuZHDSHoBal+KzJZGheFuyH4qwwcwG/n+MsWno5cTlKmaNtTsGzJpHYQ8YPbB5BdEdKU1rga5/6JGY8ww=="],
|
||||
|
||||
"@shikijs/themes": ["@shikijs/themes@3.4.2", "", { "dependencies": { "@shikijs/types": "3.4.2" } }, "sha512-qAEuAQh+brd8Jyej2UDDf+b4V2g1Rm8aBIdvt32XhDPrHvDkEnpb7Kzc9hSuHUxz0Iuflmq7elaDuQAP9bHIhg=="],
|
||||
"@shikijs/themes": ["@shikijs/themes@3.12.2", "", { "dependencies": { "@shikijs/types": "3.12.2" } }, "sha512-fTR3QAgnwYpfGczpIbzPjlRnxyONJOerguQv1iwpyQZ9QXX4qy/XFQqXlf17XTsorxnHoJGbH/LXBvwtqDsF5A=="],
|
||||
|
||||
"@shikijs/types": ["@shikijs/types@3.4.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg=="],
|
||||
"@shikijs/types": ["@shikijs/types@3.12.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-K5UIBzxCyv0YoxN3LMrKB9zuhp1bV+LgewxuVwHdl4Gz5oePoUFrr9EfgJlGlDeXCU1b/yhdnXeuRvAnz8HN8Q=="],
|
||||
|
||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||
|
||||
@@ -503,35 +517,35 @@
|
||||
|
||||
"@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.12", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.5.1", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.12" } }, "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ=="],
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.13", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.5.1", "lightningcss": "1.30.1", "magic-string": "^0.30.18", "source-map-js": "^1.2.1", "tailwindcss": "4.1.13" } }, "sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.12", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.12", "@tailwindcss/oxide-darwin-arm64": "4.1.12", "@tailwindcss/oxide-darwin-x64": "4.1.12", "@tailwindcss/oxide-freebsd-x64": "4.1.12", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.12", "@tailwindcss/oxide-linux-arm64-musl": "4.1.12", "@tailwindcss/oxide-linux-x64-gnu": "4.1.12", "@tailwindcss/oxide-linux-x64-musl": "4.1.12", "@tailwindcss/oxide-wasm32-wasi": "4.1.12", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.12", "@tailwindcss/oxide-win32-x64-msvc": "4.1.12" } }, "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw=="],
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.13", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.13", "@tailwindcss/oxide-darwin-arm64": "4.1.13", "@tailwindcss/oxide-darwin-x64": "4.1.13", "@tailwindcss/oxide-freebsd-x64": "4.1.13", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.13", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.13", "@tailwindcss/oxide-linux-arm64-musl": "4.1.13", "@tailwindcss/oxide-linux-x64-gnu": "4.1.13", "@tailwindcss/oxide-linux-x64-musl": "4.1.13", "@tailwindcss/oxide-wasm32-wasi": "4.1.13", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.13", "@tailwindcss/oxide-win32-x64-msvc": "4.1.13" } }, "sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.12", "", { "os": "android", "cpu": "arm64" }, "sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ=="],
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.13", "", { "os": "android", "cpu": "arm64" }, "sha512-BrpTrVYyejbgGo57yc8ieE+D6VT9GOgnNdmh5Sac6+t0m+v+sKQevpFVpwX3pBrM2qKrQwJ0c5eDbtjouY/+ew=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw=="],
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YP+Jksc4U0KHcu76UhRDHq9bx4qtBftp9ShK/7UGfq0wpaP96YVnnjFnj3ZFrUAjc5iECzODl/Ts0AN7ZPOANQ=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg=="],
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-aAJ3bbwrn/PQHDxCto9sxwQfT30PzyYJFG0u/BWZGeVXi5Hx6uuUOQEI2Fa43qvmUjTRQNZnGqe9t0Zntexeuw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww=="],
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.13", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Wt8KvASHwSXhKE/dJLCCWcTSVmBj3xhVhp/aF3RpAhGeZ3sVo7+NTfgiN8Vey/Fi8prRClDs6/f0KXPDTZE6nQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.12", "", { "os": "linux", "cpu": "arm" }, "sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ=="],
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.13", "", { "os": "linux", "cpu": "arm" }, "sha512-mbVbcAsW3Gkm2MGwA93eLtWrwajz91aXZCNSkGTx/R5eb6KpKD5q8Ueckkh9YNboU8RH7jiv+ol/I7ZyQ9H7Bw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g=="],
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-wdtfkmpXiwej/yoAkrCP2DNzRXCALq9NVLgLELgLim1QpSfhQM5+ZxQQF8fkOiEpuNoKLp4nKZ6RC4kmeFH0HQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA=="],
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.12", "", { "os": "linux", "cpu": "x64" }, "sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q=="],
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.12", "", { "os": "linux", "cpu": "x64" }, "sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A=="],
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.12", "", { "dependencies": { "@emnapi/core": "^1.4.5", "@emnapi/runtime": "^1.4.5", "@emnapi/wasi-threads": "^1.0.4", "@napi-rs/wasm-runtime": "^0.2.12", "@tybys/wasm-util": "^0.10.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.13", "", { "dependencies": { "@emnapi/core": "^1.4.5", "@emnapi/runtime": "^1.4.5", "@emnapi/wasi-threads": "^1.0.4", "@napi-rs/wasm-runtime": "^0.2.12", "@tybys/wasm-util": "^0.10.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg=="],
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.12", "", { "os": "win32", "cpu": "x64" }, "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA=="],
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.13", "", { "os": "win32", "cpu": "x64" }, "sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.12", "", { "dependencies": { "@tailwindcss/node": "4.1.12", "@tailwindcss/oxide": "4.1.12", "tailwindcss": "4.1.12" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-4pt0AMFDx7gzIrAOIYgYP0KCBuKWqyW8ayrdiLEjoJTT4pKTjrzG/e4uzWtTLDziC+66R9wbUqZBccJalSE5vQ=="],
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.13", "", { "dependencies": { "@tailwindcss/node": "4.1.13", "@tailwindcss/oxide": "4.1.13", "tailwindcss": "4.1.13" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-0PmqLQ010N58SbMTJ7BVJ4I2xopiQn/5i6nlb4JmxzQf8zcS5+m2Cv6tqh+sfDwtIdjoEnOvwsGQ1hkUi8QEHQ=="],
|
||||
|
||||
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.12", "", { "dependencies": { "@tanstack/virtual-core": "3.13.12" }, "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-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA=="],
|
||||
|
||||
@@ -539,7 +553,7 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.8.0", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-WgXcWzVM6idy5JaftTVC8Vs83NKRmGJz4Hqs4oyOuO2J4r/y79vvKZsb+CaGyCSEbUPI6OsewfPd0G1A0/TUZQ=="],
|
||||
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.0", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-QHdxYMJ0YPGKYofMc6zYvo7LOViVhdc6nPg/OtM2cf9MQrwEcTxFCs7d/GJ5eSyPkHzOiBkc/KfLdFJBHzldtQ=="],
|
||||
|
||||
"@testing-library/react": ["@testing-library/react@16.3.0", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw=="],
|
||||
|
||||
@@ -555,7 +569,7 @@
|
||||
|
||||
"@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="],
|
||||
"@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="],
|
||||
|
||||
"@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="],
|
||||
|
||||
@@ -585,7 +599,7 @@
|
||||
|
||||
"@types/node": ["@types/node@22.15.23", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-7Ec1zaFPF4RJ0eXu1YT/xgiebqwqoJz8rYPDi/O2BcZ++Wpt0Kq9cl0eg6NN6bYbPnR67ZLo7St5Q3UK0SnARw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.1.12", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="],
|
||||
"@types/react": ["@types/react@19.1.16", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-WBM/nDbEZmDUORKnh5i1bTnAz6vTohUf9b8esSMu+b24+srbaxa04UbJgWx78CVfNXA20sNu0odEIluZDFdCog=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.1.9", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ=="],
|
||||
|
||||
@@ -595,7 +609,7 @@
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.0.2", "", { "dependencies": { "@babel/core": "^7.28.3", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.34", "@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" } }, "sha512-tmyFgixPZCx2+e6VO9TNITWcCQl8+Nl/E8YbAyPVv85QCc7/A3JrdfG2A8gIzvVhWuzMOVrFW1aReaNxrI6tbw=="],
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.0.4", "", { "dependencies": { "@babel/core": "^7.28.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.38", "@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" } }, "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
|
||||
|
||||
@@ -633,7 +647,7 @@
|
||||
|
||||
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
|
||||
"acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="],
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
@@ -667,7 +681,7 @@
|
||||
|
||||
"astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="],
|
||||
|
||||
"astro": ["astro@5.13.4", "", { "dependencies": { "@astrojs/compiler": "^2.12.2", "@astrojs/internal-helpers": "0.7.2", "@astrojs/markdown-remark": "6.3.6", "@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", "smol-toml": "^1.3.4", "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.4", "zod-to-json-schema": "^3.24.5", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.33.3" }, "bin": { "astro": "astro.js" } }, "sha512-Mgq5GYy3EHtastGXqdnh1UPuN++8NmJSluAspA5hu33O7YRs/em/L03cUfRXtc60l5yx5BfYJsjF2MFMlcWlzw=="],
|
||||
"astro": ["astro@5.14.1", "", { "dependencies": { "@astrojs/compiler": "^2.12.2", "@astrojs/internal-helpers": "0.7.3", "@astrojs/markdown-remark": "6.3.7", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^2.4.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.2.0", "acorn": "^8.15.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", "ci-info": "^4.3.0", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^1.0.2", "cssesc": "^3.0.0", "debug": "^4.4.1", "deterministic-object-hash": "^2.0.2", "devalue": "^5.3.2", "diff": "^5.2.0", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.7.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.2.0", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.18", "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.3.0", "picomatch": "^4.0.3", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.7.2", "shiki": "^3.12.0", "smol-toml": "^1.4.2", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.5.2", "unist-util-visit": "^5.0.0", "unstorage": "^1.17.0", "vfile": "^6.0.3", "vite": "^6.3.6", "vitefu": "^1.1.1", "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.2.3", "zod": "^3.25.76", "zod-to-json-schema": "^3.24.6", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "astro.js" } }, "sha512-gPa8NY7/lP8j8g81iy8UwANF3+aukKRWS68IlthZQNgykpg80ne6lbHOp6FErYycxQ1TUhgEfkXVDQZAoJx8Bg=="],
|
||||
|
||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||
|
||||
@@ -683,14 +697,16 @@
|
||||
|
||||
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
||||
|
||||
"better-auth": ["better-auth@1.3.7", "", { "dependencies": { "@better-auth/utils": "0.2.6", "@better-fetch/fetch": "^1.1.18", "@noble/ciphers": "^0.6.0", "@noble/hashes": "^1.8.0", "@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/server": "^13.1.2", "better-call": "^1.0.13", "defu": "^6.1.4", "jose": "^5.10.0", "kysely": "^0.28.5", "nanostores": "^0.11.4" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-/1fEyx2SGgJQM5ujozDCh9eJksnVkNU/J7Fk/tG5Y390l8nKbrPvqiFlCjlMM+scR+UABJbQzA6An7HT50LHyQ=="],
|
||||
"better-auth": ["better-auth@1.3.24", "", { "dependencies": { "@better-auth/core": "1.3.24", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "^1.1.18", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/server": "^13.1.2", "better-call": "1.0.19", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.1.5" } }, "sha512-LyxIbnB2FExhjqQ/J1G8S8EAbmTBDFOz6CjqHNNu15Gux+c4fF0Si1YNLprROEb4EGNuGUfslurW0Q6nZ+Dobg=="],
|
||||
|
||||
"better-call": ["better-call@1.0.16", "", { "dependencies": { "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-42dgJ1rOtc0anOoxjXPOWuel/Z/4aeO7EJ2SiXNwvlkySSgjXhNjAjTMWa8DL1nt6EXS3jl3VKC3mPsU/lUgVA=="],
|
||||
"better-call": ["better-call@1.0.19", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-sI3GcA1SCVa3H+CDHl8W8qzhlrckwXOTKhqq3OOPXjgn5aTOMIqGY34zLY/pHA6tRRMjTUC3lz5Mi7EbDA24Kw=="],
|
||||
|
||||
"blob-to-buffer": ["blob-to-buffer@1.2.9", "", {}, "sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA=="],
|
||||
|
||||
"body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g=="],
|
||||
|
||||
"bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="],
|
||||
|
||||
"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=="],
|
||||
@@ -701,7 +717,7 @@
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="],
|
||||
"bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
@@ -737,7 +753,7 @@
|
||||
|
||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
|
||||
"ci-info": ["ci-info@4.2.0", "", {}, "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg=="],
|
||||
"ci-info": ["ci-info@4.3.0", "", {}, "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
@@ -831,9 +847,9 @@
|
||||
|
||||
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
|
||||
|
||||
"dotenv": ["dotenv@17.2.1", "", {}, "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ=="],
|
||||
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@0.31.4", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-tCPWVZWZqWVx2XUsVpJRnH9Mx0ClVOf5YUHerZ5so1OKSlqww4zy1R5ksEdGRcO3tM3zj0PYN6V48TbQCL1RfA=="],
|
||||
"drizzle-kit": ["drizzle-kit@0.31.5", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-+CHgPFzuoTQTt7cOYCV6MOw2w8vqEn/ap1yv4bpZOWL03u7rlVRQhUY0WYT3rHsgVTXwYQDZaSUJSQrMBUKuWg=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.44.5", "", { "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-jBe37K7d8ZSKptdKfakQFdeljtu3P2Cbo7tJoJSVZADzIKOBo9IAJPOmMsH2bZl90bZgh8FQlD8BjxXA/zuBkQ=="],
|
||||
|
||||
@@ -961,7 +977,7 @@
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"h3": ["h3@1.15.3", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.4", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.0", "radix3": "^1.1.2", "ufo": "^1.6.1", "uncrypto": "^0.1.3" } }, "sha512-z6GknHqyX0h9aQaTx22VZDf6QyZn+0Nh+Ym8O/u0SGSkyF5cuTJYKlc8MkzW3Nzf9LE1ivcpmYC3FUGpywhuUQ=="],
|
||||
"h3": ["h3@1.15.4", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.2", "radix3": "^1.1.2", "ufo": "^1.6.1", "uncrypto": "^0.1.3" } }, "sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
@@ -1009,7 +1025,7 @@
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"import-meta-resolve": ["import-meta-resolve@4.1.0", "", {}, "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw=="],
|
||||
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
|
||||
|
||||
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
|
||||
|
||||
@@ -1053,7 +1069,7 @@
|
||||
|
||||
"jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="],
|
||||
|
||||
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
|
||||
"jose": ["jose@6.1.0", "", {}, "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
@@ -1123,7 +1139,7 @@
|
||||
|
||||
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.542.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw=="],
|
||||
"lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="],
|
||||
|
||||
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||
|
||||
@@ -1275,7 +1291,7 @@
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"nanostores": ["nanostores@0.11.4", "", {}, "sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ=="],
|
||||
"nanostores": ["nanostores@1.0.1", "", {}, "sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw=="],
|
||||
|
||||
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||
|
||||
@@ -1287,11 +1303,11 @@
|
||||
|
||||
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||
|
||||
"node-fetch-native": ["node-fetch-native@1.6.6", "", {}, "sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ=="],
|
||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||
|
||||
"node-forge": ["node-forge@1.3.1", "", {}, "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA=="],
|
||||
|
||||
"node-mock-http": ["node-mock-http@1.0.0", "", {}, "sha512-0uGYQ1WQL1M5kKvGRXWQ3uZCHtLTO8hln3oBjIusM75WoesZ909uQJs/Hb946i2SS+Gsrhkaa6iAO17jRIv6DQ=="],
|
||||
"node-mock-http": ["node-mock-http@1.0.3", "", {}, "sha512-jN8dK25fsfnMrVsEhluUTPkBFY+6ybu7jSB1n+ri/vOGjJxU8J9CZhpSGkHXSkFjtUhbmoncG/YG9ta5Ludqog=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
|
||||
|
||||
@@ -1483,9 +1499,9 @@
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
|
||||
"sharp": ["sharp@0.34.3", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.4", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.3", "@img/sharp-darwin-x64": "0.34.3", "@img/sharp-libvips-darwin-arm64": "1.2.0", "@img/sharp-libvips-darwin-x64": "1.2.0", "@img/sharp-libvips-linux-arm": "1.2.0", "@img/sharp-libvips-linux-arm64": "1.2.0", "@img/sharp-libvips-linux-ppc64": "1.2.0", "@img/sharp-libvips-linux-s390x": "1.2.0", "@img/sharp-libvips-linux-x64": "1.2.0", "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", "@img/sharp-libvips-linuxmusl-x64": "1.2.0", "@img/sharp-linux-arm": "0.34.3", "@img/sharp-linux-arm64": "0.34.3", "@img/sharp-linux-ppc64": "0.34.3", "@img/sharp-linux-s390x": "0.34.3", "@img/sharp-linux-x64": "0.34.3", "@img/sharp-linuxmusl-arm64": "0.34.3", "@img/sharp-linuxmusl-x64": "0.34.3", "@img/sharp-wasm32": "0.34.3", "@img/sharp-win32-arm64": "0.34.3", "@img/sharp-win32-ia32": "0.34.3", "@img/sharp-win32-x64": "0.34.3" } }, "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg=="],
|
||||
|
||||
"shiki": ["shiki@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/engine-javascript": "3.4.2", "@shikijs/engine-oniguruma": "3.4.2", "@shikijs/langs": "3.4.2", "@shikijs/themes": "3.4.2", "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-wuxzZzQG8kvZndD7nustrNFIKYJ1jJoWIPaBpVe2+KHSvtzMi4SBjOxrigs8qeqce/l3U0cwiC+VAkLKSunHQQ=="],
|
||||
"shiki": ["shiki@3.12.2", "", { "dependencies": { "@shikijs/core": "3.12.2", "@shikijs/engine-javascript": "3.12.2", "@shikijs/engine-oniguruma": "3.12.2", "@shikijs/langs": "3.12.2", "@shikijs/themes": "3.12.2", "@shikijs/types": "3.12.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-uIrKI+f9IPz1zDT+GMz+0RjzKJiijVr6WDWm9Pe3NNY6QigKCfifCEv9v9R2mDASKKjzjQ2QpFLcxaR3iHSnMA=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
@@ -1501,11 +1517,11 @@
|
||||
|
||||
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||
|
||||
"smol-toml": ["smol-toml@1.3.4", "", {}, "sha512-UOPtVuYkzYGee0Bd2Szz8d2G3RfMfJ2t3qVdZUAozZyAk+a0Sxa+QKix0YCwjL/A1RR0ar44nCxaoN9FxdJGwA=="],
|
||||
"smol-toml": ["smol-toml@1.4.2", "", {}, "sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g=="],
|
||||
|
||||
"sonner": ["sonner@2.0.7", "", { "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-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
|
||||
|
||||
"source-map": ["source-map@0.7.4", "", {}, "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA=="],
|
||||
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
@@ -1539,7 +1555,7 @@
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.12", "", {}, "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA=="],
|
||||
"tailwindcss": ["tailwindcss@4.1.13", "", {}, "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w=="],
|
||||
|
||||
"tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="],
|
||||
|
||||
@@ -1579,9 +1595,9 @@
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tsx": ["tsx@4.20.5", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw=="],
|
||||
"tsx": ["tsx@4.20.6", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.3.7", "", {}, "sha512-lvLb3hTIpB5oGsk8JmLoAjeCHV58nKa2zHYn8yWOoG5JJusH3bhJlF2DLAZ/5NmJ+jyH3ssiAx/2KmbhavJy/A=="],
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
@@ -1589,7 +1605,7 @@
|
||||
|
||||
"typesafe-path": ["typesafe-path@0.2.2", "", {}, "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA=="],
|
||||
|
||||
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"typescript-auto-import-cache": ["typescript-auto-import-cache@0.3.6", "", { "dependencies": { "semver": "^7.3.8" } }, "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ=="],
|
||||
|
||||
@@ -1607,7 +1623,7 @@
|
||||
|
||||
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||
|
||||
"unifont": ["unifont@0.5.0", "", { "dependencies": { "css-tree": "^3.0.0", "ohash": "^2.0.0" } }, "sha512-4DueXMP5Hy4n607sh+vJ+rajoLu778aU3GzqeTCqsD/EaUcvqZT9wPC8kgK6Vjh22ZskrxyRCR71FwNOaYn6jA=="],
|
||||
"unifont": ["unifont@0.5.2", "", { "dependencies": { "css-tree": "^3.0.0", "ofetch": "^1.4.1", "ohash": "^2.0.0" } }, "sha512-LzR4WUqzH9ILFvjLAUU7dK3Lnou/qd5kD+IakBtBK4S15/+x2y9VX+DcWQv6s551R6W+vzwgVS6tFg3XggGBgg=="],
|
||||
|
||||
"unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="],
|
||||
|
||||
@@ -1633,7 +1649,7 @@
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"unstorage": ["unstorage@1.16.0", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^4.0.3", "destr": "^2.0.5", "h3": "^1.15.2", "lru-cache": "^10.4.3", "node-fetch-native": "^1.6.6", "ofetch": "^1.4.1", "ufo": "^1.6.1" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6.0.3 || ^7.0.0", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-WQ37/H5A7LcRPWfYOrDa1Ys02xAbpPJq6q5GkO88FBXVSQzHd7+BjEwfRqyaSWCv9MbsJy058GWjjPjcJ16GGA=="],
|
||||
"unstorage": ["unstorage@1.17.1", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^4.0.3", "destr": "^2.0.5", "h3": "^1.15.4", "lru-cache": "^10.4.3", "node-fetch-native": "^1.6.7", "ofetch": "^1.4.1", "ufo": "^1.6.1" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6.0.3 || ^7.0.0", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-KKGwRTT0iVBCErKemkJCLs7JdxNVfqTPc/85ae1XES0+bsHbc/sFBfVi5kJp156cc51BHinIH2l3k0EZ24vOBQ=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
|
||||
|
||||
@@ -1645,7 +1661,7 @@
|
||||
|
||||
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
|
||||
|
||||
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
|
||||
"uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
@@ -1657,11 +1673,11 @@
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="],
|
||||
|
||||
"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": ["vite@6.3.6", "", { "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-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA=="],
|
||||
|
||||
"vite-node": ["vite-node@3.2.4", "", { "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-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
|
||||
|
||||
"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=="],
|
||||
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
||||
|
||||
"vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "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.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.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.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
|
||||
|
||||
@@ -1753,21 +1769,27 @@
|
||||
|
||||
"yoctocolors": ["yoctocolors@2.1.1", "", {}, "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ=="],
|
||||
|
||||
"zod": ["zod@4.1.5", "", {}, "sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg=="],
|
||||
"zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="],
|
||||
|
||||
"zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="],
|
||||
|
||||
"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=="],
|
||||
"@ampproject/remapping/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
|
||||
|
||||
"@ampproject/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
|
||||
|
||||
"@astrojs/react/@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@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" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
"@astrojs/telemetry/ci-info": ["ci-info@4.2.0", "", {}, "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg=="],
|
||||
|
||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@babel/generator/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="],
|
||||
"@babel/generator/@babel/parser": ["@babel/parser@7.28.3", "", { "dependencies": { "@babel/types": "^7.28.2" }, "bin": "./bin/babel-parser.js" }, "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA=="],
|
||||
|
||||
"@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="],
|
||||
"@babel/generator/@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
@@ -1777,18 +1799,34 @@
|
||||
|
||||
"@babel/helper-module-imports/@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=="],
|
||||
|
||||
"@babel/helper-module-transforms/@babel/traverse": ["@babel/traverse@7.28.3", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.3", "@babel/template": "^7.27.2", "@babel/types": "^7.28.2", "debug": "^4.3.1" } }, "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@babel/template/@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=="],
|
||||
|
||||
"@jridgewell/remapping/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="],
|
||||
"@octokit/core/@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
|
||||
|
||||
"@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="],
|
||||
"@octokit/endpoint/@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
|
||||
|
||||
"@octokit/graphql/@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
|
||||
|
||||
"@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
|
||||
|
||||
"@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
|
||||
|
||||
"@octokit/request/@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
|
||||
|
||||
"@octokit/request-error/@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
|
||||
|
||||
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@rollup/pluginutils/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"@tailwindcss/node/jiti": ["jiti@2.5.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w=="],
|
||||
|
||||
"@tailwindcss/node/magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" }, "bundled": true }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="],
|
||||
@@ -1821,7 +1859,11 @@
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"astro/zod": ["zod@3.25.75", "", {}, "sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg=="],
|
||||
"astro/magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
|
||||
|
||||
"astro/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"astro/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"basic-auth/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
@@ -1841,6 +1883,10 @@
|
||||
|
||||
"cmdk/@radix-ui/react-dialog": ["@radix-ui/react-dialog@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-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "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-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="],
|
||||
|
||||
"esast-util-from-js/acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="],
|
||||
|
||||
"estree-util-to-js/source-map": ["source-map@0.7.4", "", {}, "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA=="],
|
||||
|
||||
"express/cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="],
|
||||
|
||||
"express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
@@ -1857,10 +1903,16 @@
|
||||
|
||||
"magicast/@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=="],
|
||||
|
||||
"micromark-extension-mdxjs/acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"oauth2-mock-server/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
|
||||
|
||||
"ofetch/node-fetch-native": ["node-fetch-native@1.6.6", "", {}, "sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
||||
@@ -1881,6 +1933,12 @@
|
||||
|
||||
"vaul/@radix-ui/react-dialog": ["@radix-ui/react-dialog@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-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "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-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="],
|
||||
|
||||
"vite/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"vite-node/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=="],
|
||||
|
||||
"vitest/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=="],
|
||||
|
||||
"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=="],
|
||||
@@ -1899,9 +1957,9 @@
|
||||
|
||||
"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/@babel/core": ["@babel/core@7.28.3", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.3", "@babel/parser": "^7.28.3", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.3", "@babel/types": "^7.28.2", "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-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ=="],
|
||||
|
||||
"@astrojs/react/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.9", "", {}, "sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w=="],
|
||||
"@astrojs/react/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
@@ -1909,8 +1967,30 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@babel/helper-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.28.3", "", { "dependencies": { "@babel/types": "^7.28.2" }, "bin": "./bin/babel-parser.js" }, "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA=="],
|
||||
|
||||
"@babel/helper-module-transforms/@babel/traverse/@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
|
||||
|
||||
"@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="],
|
||||
|
||||
"@octokit/endpoint/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="],
|
||||
|
||||
"@octokit/graphql/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="],
|
||||
|
||||
"@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="],
|
||||
|
||||
"@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="],
|
||||
|
||||
"@octokit/request-error/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="],
|
||||
|
||||
"@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="],
|
||||
|
||||
"@tailwindcss/node/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"astro/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"boxen/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
|
||||
@@ -1961,20 +2041,20 @@
|
||||
|
||||
"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/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=="],
|
||||
"@astrojs/react/@vitejs/plugin-react/@babel/core/@babel/helpers": ["@babel/helpers@7.28.3", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.2" } }, "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw=="],
|
||||
|
||||
"@astrojs/react/@vitejs/plugin-react/@babel/core/@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.27.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.27.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg=="],
|
||||
"@astrojs/react/@vitejs/plugin-react/@babel/core/@babel/parser": ["@babel/parser@7.28.3", "", { "dependencies": { "@babel/types": "^7.28.2" }, "bin": "./bin/babel-parser.js" }, "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA=="],
|
||||
|
||||
"@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/traverse": ["@babel/traverse@7.28.3", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.3", "@babel/template": "^7.27.2", "@babel/types": "^7.28.2", "debug": "^4.3.1" } }, "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ=="],
|
||||
|
||||
"@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/@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=="],
|
||||
"@astrojs/react/@vitejs/plugin-react/@babel/core/@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
|
||||
|
||||
"@astrojs/react/@vitejs/plugin-react/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
|
||||
|
||||
"@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
|
||||
|
||||
"boxen/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||
|
||||
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Gitea Mirror alternate deployment configuration
|
||||
# Standard deployment with host path and minimal environments
|
||||
# Minimal Gitea Mirror deployment
|
||||
# Only includes what CANNOT be configured via the Web UI
|
||||
# Everything else can be set up through the web interface after deployment
|
||||
|
||||
services:
|
||||
gitea-mirror:
|
||||
image: ghcr.io/raylabshq/gitea-mirror:latest
|
||||
@@ -11,17 +13,43 @@ services:
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
# For a complete list of all supported environment variables, see:
|
||||
# docs/ENVIRONMENT_VARIABLES.md or .env.example
|
||||
# === ABSOLUTELY REQUIRED ===
|
||||
# This MUST be set and CANNOT be changed via UI
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} # Min 32 chars, required for sessions
|
||||
|
||||
# === CORE SETTINGS ===
|
||||
# These are technically required but have working defaults
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=file:data/gitea-mirror.db
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- BETTER_AUTH_URL=http://localhost:4321
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production}
|
||||
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321}
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
|
||||
# === QUICK START ===
|
||||
#
|
||||
# 1. Create a .env file with only ONE required variable:
|
||||
# BETTER_AUTH_SECRET=your-32-character-minimum-secret-key-here
|
||||
#
|
||||
# 2. Run:
|
||||
# docker-compose -f docker-compose.alt.yml up -d
|
||||
#
|
||||
# 3. Access at http://localhost:4321
|
||||
#
|
||||
# 4. Sign up for an account (first user becomes admin)
|
||||
#
|
||||
# 5. Configure everything else through the web UI:
|
||||
# - GitHub credentials
|
||||
# - Gitea credentials
|
||||
# - Mirror settings
|
||||
# - Scheduling options
|
||||
# - Auto-import settings
|
||||
# - Cleanup preferences
|
||||
#
|
||||
# That's it! Everything else can be configured via the web interface.
|
||||
@@ -1,174 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# PostgreSQL database for Authentik
|
||||
authentik-db:
|
||||
image: postgres:15-alpine
|
||||
container_name: authentik-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: authentik
|
||||
POSTGRES_PASSWORD: authentik-db-password
|
||||
POSTGRES_DB: authentik
|
||||
volumes:
|
||||
- authentik-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- authentik-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U authentik"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Redis cache for Authentik
|
||||
authentik-redis:
|
||||
image: redis:7-alpine
|
||||
container_name: authentik-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --save 60 1 --loglevel warning
|
||||
volumes:
|
||||
- authentik-redis-data:/data
|
||||
networks:
|
||||
- authentik-net
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Authentik Server
|
||||
authentik-server:
|
||||
image: ghcr.io/goauthentik/server:2024.2
|
||||
container_name: authentik-server
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
# Core Settings
|
||||
AUTHENTIK_SECRET_KEY: "change-me-to-a-random-50-char-string-for-production"
|
||||
AUTHENTIK_ERROR_REPORTING__ENABLED: false
|
||||
|
||||
# Database
|
||||
AUTHENTIK_POSTGRESQL__HOST: authentik-db
|
||||
AUTHENTIK_POSTGRESQL__USER: authentik
|
||||
AUTHENTIK_POSTGRESQL__NAME: authentik
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: authentik-db-password
|
||||
|
||||
# Redis
|
||||
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||
|
||||
# Email (optional - for testing, uses console backend)
|
||||
AUTHENTIK_EMAIL__HOST: localhost
|
||||
AUTHENTIK_EMAIL__PORT: 25
|
||||
AUTHENTIK_EMAIL__USE_TLS: false
|
||||
AUTHENTIK_EMAIL__USE_SSL: false
|
||||
AUTHENTIK_EMAIL__TIMEOUT: 10
|
||||
AUTHENTIK_EMAIL__FROM: authentik@localhost
|
||||
|
||||
# Log Level
|
||||
AUTHENTIK_LOG_LEVEL: info
|
||||
|
||||
# Disable analytics
|
||||
AUTHENTIK_DISABLE_UPDATE_CHECK: true
|
||||
AUTHENTIK_DISABLE_STARTUP_ANALYTICS: true
|
||||
|
||||
# Default admin user (only created on first run)
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD: admin-password
|
||||
AUTHENTIK_BOOTSTRAP_TOKEN: initial-admin-token
|
||||
AUTHENTIK_BOOTSTRAP_EMAIL: admin@example.com
|
||||
volumes:
|
||||
- authentik-media:/media
|
||||
- authentik-templates:/templates
|
||||
ports:
|
||||
- "9000:9000" # HTTP
|
||||
- "9443:9443" # HTTPS (if configured)
|
||||
networks:
|
||||
- authentik-net
|
||||
- gitea-mirror-net
|
||||
depends_on:
|
||||
authentik-db:
|
||||
condition: service_healthy
|
||||
authentik-redis:
|
||||
condition: service_healthy
|
||||
|
||||
# Authentik Worker (background tasks)
|
||||
authentik-worker:
|
||||
image: ghcr.io/goauthentik/server:2024.2
|
||||
container_name: authentik-worker
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
# Same environment as server
|
||||
AUTHENTIK_SECRET_KEY: "change-me-to-a-random-50-char-string-for-production"
|
||||
AUTHENTIK_ERROR_REPORTING__ENABLED: false
|
||||
AUTHENTIK_POSTGRESQL__HOST: authentik-db
|
||||
AUTHENTIK_POSTGRESQL__USER: authentik
|
||||
AUTHENTIK_POSTGRESQL__NAME: authentik
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: authentik-db-password
|
||||
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||
AUTHENTIK_EMAIL__HOST: localhost
|
||||
AUTHENTIK_EMAIL__PORT: 25
|
||||
AUTHENTIK_EMAIL__USE_TLS: false
|
||||
AUTHENTIK_EMAIL__USE_SSL: false
|
||||
AUTHENTIK_EMAIL__TIMEOUT: 10
|
||||
AUTHENTIK_EMAIL__FROM: authentik@localhost
|
||||
AUTHENTIK_LOG_LEVEL: info
|
||||
AUTHENTIK_DISABLE_UPDATE_CHECK: true
|
||||
AUTHENTIK_DISABLE_STARTUP_ANALYTICS: true
|
||||
volumes:
|
||||
- authentik-media:/media
|
||||
- authentik-templates:/templates
|
||||
networks:
|
||||
- authentik-net
|
||||
depends_on:
|
||||
authentik-db:
|
||||
condition: service_healthy
|
||||
authentik-redis:
|
||||
condition: service_healthy
|
||||
|
||||
# Gitea Mirror Application (uncomment to run together)
|
||||
# gitea-mirror:
|
||||
# build: .
|
||||
# # OR use pre-built image:
|
||||
# # image: ghcr.io/raylabshq/gitea-mirror:latest
|
||||
# container_name: gitea-mirror
|
||||
# restart: unless-stopped
|
||||
# environment:
|
||||
# # Core Settings
|
||||
# BETTER_AUTH_URL: http://localhost:4321
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS: http://localhost:4321,http://localhost:9000
|
||||
# BETTER_AUTH_SECRET: "your-32-character-secret-key-here"
|
||||
#
|
||||
# # GitHub Settings (configure as needed)
|
||||
# GITHUB_USERNAME: ${GITHUB_USERNAME}
|
||||
# GITHUB_TOKEN: ${GITHUB_TOKEN}
|
||||
#
|
||||
# # Gitea Settings (configure as needed)
|
||||
# GITEA_URL: ${GITEA_URL}
|
||||
# GITEA_USERNAME: ${GITEA_USERNAME}
|
||||
# GITEA_TOKEN: ${GITEA_TOKEN}
|
||||
# volumes:
|
||||
# - ./data:/app/data
|
||||
# ports:
|
||||
# - "4321:4321"
|
||||
# networks:
|
||||
# - gitea-mirror-net
|
||||
# depends_on:
|
||||
# - authentik-server
|
||||
|
||||
volumes:
|
||||
authentik-db-data:
|
||||
name: authentik-db-data
|
||||
authentik-redis-data:
|
||||
name: authentik-redis-data
|
||||
authentik-media:
|
||||
name: authentik-media
|
||||
authentik-templates:
|
||||
name: authentik-templates
|
||||
|
||||
networks:
|
||||
authentik-net:
|
||||
name: authentik-net
|
||||
driver: bridge
|
||||
gitea-mirror-net:
|
||||
name: gitea-mirror-net
|
||||
driver: bridge
|
||||
@@ -1,130 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# PostgreSQL database for Keycloak
|
||||
keycloak-db:
|
||||
image: postgres:15-alpine
|
||||
container_name: keycloak-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: keycloak
|
||||
POSTGRES_USER: keycloak
|
||||
POSTGRES_PASSWORD: keycloak-db-password
|
||||
volumes:
|
||||
- keycloak-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- keycloak-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U keycloak"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Keycloak Identity Provider
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:23.0
|
||||
container_name: keycloak
|
||||
restart: unless-stopped
|
||||
command: start-dev # Use 'start' for production with HTTPS
|
||||
environment:
|
||||
# Admin credentials
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin-password
|
||||
|
||||
# Database configuration
|
||||
KC_DB: postgres
|
||||
KC_DB_URL_HOST: keycloak-db
|
||||
KC_DB_URL_DATABASE: keycloak
|
||||
KC_DB_USERNAME: keycloak
|
||||
KC_DB_PASSWORD: keycloak-db-password
|
||||
|
||||
# HTTP settings
|
||||
KC_HTTP_ENABLED: true
|
||||
KC_HTTP_PORT: 8080
|
||||
KC_HOSTNAME_STRICT: false
|
||||
KC_HOSTNAME_STRICT_HTTPS: false
|
||||
KC_PROXY: edge # If behind a proxy
|
||||
|
||||
# Development settings (remove for production)
|
||||
KC_HOSTNAME: localhost
|
||||
KC_HOSTNAME_PORT: 8080
|
||||
KC_HOSTNAME_ADMIN: localhost
|
||||
|
||||
# Features
|
||||
KC_FEATURES: token-exchange,admin-fine-grained-authz
|
||||
|
||||
# Health and metrics
|
||||
KC_HEALTH_ENABLED: true
|
||||
KC_METRICS_ENABLED: true
|
||||
|
||||
# Log level
|
||||
KC_LOG_LEVEL: INFO
|
||||
# Uncomment for debug logging
|
||||
# KC_LOG_LEVEL: DEBUG
|
||||
# QUARKUS_LOG_CATEGORY__ORG_KEYCLOAK_SERVICES: DEBUG
|
||||
ports:
|
||||
- "8080:8080" # HTTP
|
||||
- "8443:8443" # HTTPS (if configured)
|
||||
- "9000:9000" # Management
|
||||
networks:
|
||||
- keycloak-net
|
||||
- gitea-mirror-net
|
||||
depends_on:
|
||||
keycloak-db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
# For custom themes (optional)
|
||||
- keycloak-themes:/opt/keycloak/themes
|
||||
# For importing realm configurations
|
||||
- ./keycloak-realm-export.json:/opt/keycloak/data/import/realm.json:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
start_period: 60s
|
||||
|
||||
# Gitea Mirror Application (uncomment to run together)
|
||||
# gitea-mirror:
|
||||
# build: .
|
||||
# # OR use pre-built image:
|
||||
# # image: ghcr.io/raylabshq/gitea-mirror:latest
|
||||
# container_name: gitea-mirror
|
||||
# restart: unless-stopped
|
||||
# environment:
|
||||
# # Core Settings
|
||||
# BETTER_AUTH_URL: http://localhost:4321
|
||||
# BETTER_AUTH_TRUSTED_ORIGINS: http://localhost:4321,http://localhost:8080
|
||||
# BETTER_AUTH_SECRET: "your-32-character-secret-key-here"
|
||||
#
|
||||
# # GitHub Settings (configure as needed)
|
||||
# GITHUB_USERNAME: ${GITHUB_USERNAME}
|
||||
# GITHUB_TOKEN: ${GITHUB_TOKEN}
|
||||
#
|
||||
# # Gitea Settings (configure as needed)
|
||||
# GITEA_URL: ${GITEA_URL}
|
||||
# GITEA_USERNAME: ${GITEA_USERNAME}
|
||||
# GITEA_TOKEN: ${GITEA_TOKEN}
|
||||
# volumes:
|
||||
# - ./data:/app/data
|
||||
# ports:
|
||||
# - "4321:4321"
|
||||
# networks:
|
||||
# - gitea-mirror-net
|
||||
# depends_on:
|
||||
# keycloak:
|
||||
# condition: service_healthy
|
||||
|
||||
volumes:
|
||||
keycloak-db-data:
|
||||
name: keycloak-db-data
|
||||
keycloak-themes:
|
||||
name: keycloak-themes
|
||||
|
||||
networks:
|
||||
keycloak-net:
|
||||
name: keycloak-net
|
||||
driver: bridge
|
||||
gitea-mirror-net:
|
||||
name: gitea-mirror-net
|
||||
driver: bridge
|
||||
@@ -53,6 +53,15 @@ services:
|
||||
- GITEA_ORGANIZATION=${GITEA_ORGANIZATION:-github-mirrors}
|
||||
- GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public}
|
||||
- DELAY=${DELAY:-3600}
|
||||
# Scheduling and Sync Configuration (Issue #72 fixes)
|
||||
- SCHEDULE_ENABLED=${SCHEDULE_ENABLED:-false}
|
||||
- GITEA_MIRROR_INTERVAL=${GITEA_MIRROR_INTERVAL:-8h}
|
||||
- AUTO_IMPORT_REPOS=${AUTO_IMPORT_REPOS:-true}
|
||||
- AUTO_MIRROR_REPOS=${AUTO_MIRROR_REPOS:-false}
|
||||
# Repository Cleanup Configuration
|
||||
- CLEANUP_DELETE_IF_NOT_IN_GITHUB=${CLEANUP_DELETE_IF_NOT_IN_GITHUB:-false}
|
||||
- CLEANUP_ORPHANED_REPO_ACTION=${CLEANUP_ORPHANED_REPO_ACTION:-archive}
|
||||
- CLEANUP_DRY_RUN=${CLEANUP_DRY_RUN:-true}
|
||||
# Optional: Skip TLS verification (insecure, use only for testing)
|
||||
# - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false}
|
||||
# Header Authentication (for Reverse Proxy SSO)
|
||||
|
||||
@@ -120,156 +120,13 @@ fi
|
||||
# Dependencies are already installed during the Docker build process
|
||||
|
||||
# Initialize the database if it doesn't exist
|
||||
# Note: Drizzle migrations will be run automatically when the app starts (see src/lib/db/index.ts)
|
||||
if [ ! -f "/app/data/gitea-mirror.db" ]; then
|
||||
echo "Initializing database..."
|
||||
if [ -f "dist/scripts/init-db.js" ]; then
|
||||
bun dist/scripts/init-db.js
|
||||
elif [ -f "dist/scripts/manage-db.js" ]; then
|
||||
bun dist/scripts/manage-db.js init
|
||||
elif [ -f "scripts/manage-db.ts" ]; then
|
||||
bun scripts/manage-db.ts init
|
||||
else
|
||||
echo "Warning: Could not find database initialization scripts in dist/scripts."
|
||||
echo "Creating and initializing database manually..."
|
||||
|
||||
# Create the database file
|
||||
touch /app/data/gitea-mirror.db
|
||||
|
||||
# Initialize the database with required tables
|
||||
sqlite3 /app/data/gitea-mirror.db <<EOF
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
github_config TEXT NOT NULL,
|
||||
gitea_config TEXT NOT NULL,
|
||||
include TEXT NOT NULL DEFAULT '["*"]',
|
||||
exclude TEXT NOT NULL DEFAULT '[]',
|
||||
schedule_config TEXT NOT NULL,
|
||||
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)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS repositories (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
config_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
full_name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
clone_url TEXT NOT NULL,
|
||||
owner TEXT NOT NULL,
|
||||
organization TEXT,
|
||||
mirrored_location TEXT DEFAULT '',
|
||||
is_private INTEGER NOT NULL DEFAULT 0,
|
||||
is_fork INTEGER NOT NULL DEFAULT 0,
|
||||
forked_from TEXT,
|
||||
has_issues INTEGER NOT NULL DEFAULT 0,
|
||||
is_starred INTEGER NOT NULL DEFAULT 0,
|
||||
is_archived INTEGER NOT NULL DEFAULT 0,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
has_lfs INTEGER NOT NULL DEFAULT 0,
|
||||
has_submodules INTEGER NOT NULL DEFAULT 0,
|
||||
default_branch TEXT NOT NULL,
|
||||
visibility TEXT NOT NULL DEFAULT 'public',
|
||||
status TEXT NOT NULL DEFAULT 'imported',
|
||||
last_mirrored INTEGER,
|
||||
error_message 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),
|
||||
FOREIGN KEY (config_id) REFERENCES configs(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
config_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
avatar_url TEXT NOT NULL,
|
||||
membership_role TEXT NOT NULL DEFAULT 'member',
|
||||
is_included INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'imported',
|
||||
last_mirrored INTEGER,
|
||||
error_message TEXT,
|
||||
repository_count INTEGER NOT NULL DEFAULT 0,
|
||||
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),
|
||||
FOREIGN KEY (config_id) REFERENCES configs(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mirror_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
repository_id TEXT,
|
||||
repository_name TEXT,
|
||||
organization_id TEXT,
|
||||
organization_name TEXT,
|
||||
details TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'imported',
|
||||
message TEXT NOT NULL,
|
||||
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- New fields for job resilience
|
||||
job_type TEXT NOT NULL DEFAULT 'mirror',
|
||||
batch_id TEXT,
|
||||
total_items INTEGER,
|
||||
completed_items INTEGER DEFAULT 0,
|
||||
item_ids TEXT, -- JSON array as text
|
||||
completed_item_ids TEXT DEFAULT '[]', -- JSON array as text
|
||||
in_progress INTEGER NOT NULL DEFAULT 0, -- Boolean as integer
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
last_checkpoint TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_user_id ON mirror_jobs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_batch_id ON mirror_jobs(batch_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_in_progress ON mirror_jobs(in_progress);
|
||||
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_job_type ON mirror_jobs(job_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_mirror_jobs_timestamp ON mirror_jobs(timestamp);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_user_channel ON events(user_id, channel);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_read ON events(read);
|
||||
EOF
|
||||
echo "Database initialized with required tables."
|
||||
fi
|
||||
echo "Database not found. It will be created and initialized via Drizzle migrations on first app startup..."
|
||||
# Create empty database file so migrations can run
|
||||
touch /app/data/gitea-mirror.db
|
||||
else
|
||||
echo "Database already exists, checking for issues..."
|
||||
if [ -f "dist/scripts/fix-db-issues.js" ]; then
|
||||
bun dist/scripts/fix-db-issues.js
|
||||
elif [ -f "dist/scripts/manage-db.js" ]; then
|
||||
bun dist/scripts/manage-db.js fix
|
||||
elif [ -f "scripts/manage-db.ts" ]; then
|
||||
bun scripts/manage-db.ts fix
|
||||
fi
|
||||
|
||||
echo "Database exists, checking integrity..."
|
||||
echo "Database already exists, Drizzle will check for pending migrations on startup..."
|
||||
fi
|
||||
|
||||
# Extract version from package.json and set as environment variable
|
||||
|
||||
@@ -36,6 +36,7 @@ Essential application settings required for running Gitea Mirror.
|
||||
| `DATABASE_URL` | Database connection URL | `sqlite://data/gitea-mirror.db` | No |
|
||||
| `BETTER_AUTH_SECRET` | Secret key for session signing (generate with: `openssl rand -base64 32`) | - | Yes |
|
||||
| `BETTER_AUTH_URL` | Primary base URL for authentication. This should be the main URL where your application is accessed. | `http://localhost:4321` | No |
|
||||
| `PUBLIC_BETTER_AUTH_URL` | Client-side auth URL for multi-origin access. Set this to your primary domain when you need to access the app from different origins (e.g., both IP and domain). The client will use this URL for all auth requests instead of the current browser origin. | - | No |
|
||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Trusted origins for authentication requests. Comma-separated list of URLs. Use this to specify additional access URLs (e.g., local IP + domain: `http://10.10.20.45:4321,https://gitea-mirror.mydomain.tld`), SSO providers, reverse proxies, etc. | - | No |
|
||||
| `ENCRYPTION_SECRET` | Optional encryption key for tokens (generate with: `openssl rand -base64 48`) | - | No |
|
||||
|
||||
@@ -133,6 +134,7 @@ Control what content gets mirrored from GitHub to Gitea.
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `MIRROR_RELEASES` | Mirror GitHub releases | `false` | `true`, `false` |
|
||||
| `RELEASE_LIMIT` | Maximum number of releases to mirror per repository | `10` | Number (1-100) |
|
||||
| `MIRROR_WIKI` | Mirror wiki content | `false` | `true`, `false` |
|
||||
| `MIRROR_METADATA` | Master toggle for metadata mirroring | `false` | `true`, `false` |
|
||||
| `MIRROR_ISSUES` | Mirror issues (requires MIRROR_METADATA=true) | `false` | `true`, `false` |
|
||||
@@ -148,10 +150,29 @@ Configure automatic scheduled mirroring.
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `SCHEDULE_ENABLED` | Enable automatic mirroring | `false` | `true`, `false` |
|
||||
| `SCHEDULE_INTERVAL` | Interval in seconds or cron expression | `3600` | Number or cron string (e.g., `"0 2 * * *"`) |
|
||||
| `SCHEDULE_ENABLED` | Enable automatic mirroring. **When set to `true`, automatically imports and mirrors all repositories on startup** (v3.5.3+) | `false` | `true`, `false` |
|
||||
| `SCHEDULE_INTERVAL` | Interval in seconds or cron expression. **Supports cron syntax for scheduled runs** (e.g., `"0 2 * * *"` for 2 AM daily) | `3600` | Number (seconds) or cron string |
|
||||
| `DELAY` | Legacy: same as SCHEDULE_INTERVAL | `3600` | Number (seconds) |
|
||||
|
||||
> **🚀 Auto-Start Feature (v3.5.3+)**
|
||||
> Setting either `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` triggers auto-start functionality where the service will:
|
||||
> 1. **Import** all GitHub repositories on startup
|
||||
> 2. **Mirror** them to Gitea immediately
|
||||
> 3. **Continue syncing** at the configured interval
|
||||
> 4. **Auto-discover** new repositories
|
||||
> 5. **Clean up** deleted repositories (if configured)
|
||||
>
|
||||
> This eliminates the need for manual button clicks - perfect for Docker/Kubernetes deployments!
|
||||
|
||||
> **⏰ Scheduling with Cron Expressions**
|
||||
> Use cron expressions in `SCHEDULE_INTERVAL` to run at specific times:
|
||||
> - `"0 2 * * *"` - Daily at 2 AM
|
||||
> - `"0 */6 * * *"` - Every 6 hours
|
||||
> - `"0 0 * * 0"` - Weekly on Sunday at midnight
|
||||
> - `"0 3 * * 1-5"` - Weekdays at 3 AM (Monday-Friday)
|
||||
>
|
||||
> This is useful for optimizing bandwidth usage during low-activity periods.
|
||||
|
||||
### Execution Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
@@ -173,6 +194,8 @@ Configure automatic scheduled mirroring.
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
|----------|-------------|---------|---------|
|
||||
| `AUTO_IMPORT_REPOS` | Automatically discover and import new GitHub repositories during scheduled syncs | `true` | `true`, `false` |
|
||||
| `AUTO_MIRROR_REPOS` | Automatically mirror newly imported repositories during scheduled syncs (no manual “Mirror All” required) | `false` | `true`, `false` |
|
||||
| `SCHEDULE_ONLY_MIRROR_UPDATED` | Only mirror repos with updates | `false` | `true`, `false` |
|
||||
| `SCHEDULE_UPDATE_INTERVAL` | Check for updates interval (milliseconds) | `86400000` | Number |
|
||||
| `SCHEDULE_SKIP_RECENTLY_MIRRORED` | Skip recently mirrored repos | `true` | `true`, `false` |
|
||||
@@ -205,10 +228,25 @@ Configure automatic cleanup of old events and data.
|
||||
|----------|-------------|---------|---------|
|
||||
| `CLEANUP_DELETE_FROM_GITEA` | Delete repositories from Gitea | `false` | `true`, `false` |
|
||||
| `CLEANUP_DELETE_IF_NOT_IN_GITHUB` | Delete repos not found in GitHub (automatically enables cleanup) | `true` | `true`, `false` |
|
||||
| `CLEANUP_ORPHANED_REPO_ACTION` | Action for orphaned repositories | `archive` | `skip`, `archive`, `delete` |
|
||||
| `CLEANUP_ORPHANED_REPO_ACTION` | Action for orphaned repositories. **Note**: `archive` is recommended to preserve backups | `archive` | `skip`, `archive`, `delete` |
|
||||
| `CLEANUP_DRY_RUN` | Test mode without actual deletion | `true` | `true`, `false` |
|
||||
| `CLEANUP_PROTECTED_REPOS` | Comma-separated list of protected repository names | - | Comma-separated strings |
|
||||
|
||||
**🛡️ Safety Features (Backup Protection)**:
|
||||
- **GitHub Failures Don't Delete Backups**: Cleanup is automatically skipped if GitHub API returns errors (404, 403, connection issues)
|
||||
- **Archive Never Deletes**: The `archive` action ALWAYS preserves repository data, it never deletes
|
||||
- **Graceful Degradation**: If marking as archived fails, the repository remains fully accessible in Gitea
|
||||
- **The Purpose of Backups**: Your mirrors are preserved even when GitHub sources disappear - that's the whole point!
|
||||
|
||||
**Archive Behavior (Aligned with Gitea API)**:
|
||||
- **Regular repositories**: Uses Gitea's native archive feature (PATCH `/repos/{owner}/{repo}` with `archived: true`)
|
||||
- Makes repository read-only while preserving all data
|
||||
- **Mirror repositories**: Uses rename strategy (Gitea API returns 422 for archiving mirrors)
|
||||
- Renamed with `[ARCHIVED]` prefix for clear identification
|
||||
- Description updated with preservation notice and timestamp
|
||||
- Mirror interval set to 8760h (1 year) to minimize sync attempts
|
||||
- Repository remains fully accessible and cloneable
|
||||
|
||||
### Execution Settings
|
||||
|
||||
| Variable | Description | Default | Options |
|
||||
@@ -300,21 +338,28 @@ services:
|
||||
|
||||
### Multiple Access URLs
|
||||
|
||||
To allow access to Gitea Mirror through multiple URLs (e.g., local IP and public domain), use the `BETTER_AUTH_TRUSTED_ORIGINS` variable:
|
||||
To allow access to Gitea Mirror through multiple URLs (e.g., local IP and public domain), you need to configure both server and client settings:
|
||||
|
||||
**Example Configuration:**
|
||||
```bash
|
||||
# Primary URL (required) - typically your public domain
|
||||
# Primary URL (required) - where the auth server is hosted
|
||||
BETTER_AUTH_URL=https://gitea-mirror.mydomain.tld
|
||||
|
||||
# Additional access URLs (optional) - local IPs, alternate domains
|
||||
# Client-side URL (optional) - tells the browser where to send auth requests
|
||||
# Set this to your primary domain when accessing from different origins
|
||||
PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.mydomain.tld
|
||||
|
||||
# Additional trusted origins (optional) - origins allowed to make auth requests
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=http://10.10.20.45:4321,http://192.168.1.100:4321
|
||||
```
|
||||
|
||||
This setup allows you to:
|
||||
- Access via local network IP: `http://10.10.20.45:4321`
|
||||
- Access via public domain: `https://gitea-mirror.mydomain.tld`
|
||||
- Both URLs will work for authentication and session management
|
||||
- Auth requests from the IP will be sent to the domain (via `PUBLIC_BETTER_AUTH_URL`)
|
||||
- Each origin requires separate login due to browser cookie isolation
|
||||
|
||||
**Important:** When accessing from different origins (IP vs domain), you'll need to log in separately on each origin as cookies cannot be shared across different origins for security reasons.
|
||||
|
||||
### Trusted Origins
|
||||
|
||||
@@ -363,4 +408,4 @@ BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://192.168.1.100:3000
|
||||
- `admin:org` (read organization data)
|
||||
- Additional scopes may be required for specific features
|
||||
|
||||
For more examples and detailed configuration, see the `.env.example` file in the repository.
|
||||
For more examples and detailed configuration, see the `.env.example` file in the repository.
|
||||
|
||||
@@ -60,7 +60,7 @@ bun run dev
|
||||
|
||||
## Key Features
|
||||
|
||||
- 🔄 **Automatic Mirroring** - Keep repositories synchronized
|
||||
- 🔄 **Automatic Syncing** - Keep repositories synchronized
|
||||
- 🗂️ **Organization Support** - Mirror entire organizations
|
||||
- ⭐ **Starred Repos** - Mirror your starred repositories
|
||||
- 🔐 **Self-Hosted** - Full control over your data
|
||||
|
||||
3
drizzle/0003_open_spacker_dave.sql
Normal file
3
drizzle/0003_open_spacker_dave.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE `organizations` ADD `public_repository_count` integer;--> statement-breakpoint
|
||||
ALTER TABLE `organizations` ADD `private_repository_count` integer;--> statement-breakpoint
|
||||
ALTER TABLE `organizations` ADD `fork_repository_count` integer;
|
||||
18
drizzle/0004_grey_butterfly.sql
Normal file
18
drizzle/0004_grey_butterfly.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE `rate_limits` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`provider` text DEFAULT 'github' NOT NULL,
|
||||
`limit` integer NOT NULL,
|
||||
`remaining` integer NOT NULL,
|
||||
`used` integer NOT NULL,
|
||||
`reset` integer NOT NULL,
|
||||
`retry_after` integer,
|
||||
`status` text DEFAULT 'ok' NOT NULL,
|
||||
`last_checked` integer NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_rate_limits_user_provider` ON `rate_limits` (`user_id`,`provider`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_rate_limits_status` ON `rate_limits` (`status`);
|
||||
11
drizzle/0005_polite_preak.sql
Normal file
11
drizzle/0005_polite_preak.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Step 1: Remove duplicate repositories, keeping the most recently updated one
|
||||
-- This handles cases where users have duplicate entries from before the unique constraint
|
||||
DELETE FROM repositories
|
||||
WHERE rowid NOT IN (
|
||||
SELECT MAX(rowid)
|
||||
FROM repositories
|
||||
GROUP BY user_id, full_name
|
||||
);
|
||||
--> statement-breakpoint
|
||||
-- Step 2: Now create the unique index safely
|
||||
CREATE UNIQUE INDEX uniq_repositories_user_full_name ON repositories (user_id, full_name);
|
||||
1805
drizzle/meta/0003_snapshot.json
Normal file
1805
drizzle/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1933
drizzle/meta/0004_snapshot.json
Normal file
1933
drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1941
drizzle/meta/0005_snapshot.json
Normal file
1941
drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,27 @@
|
||||
"when": 1753539600567,
|
||||
"tag": "0002_bored_captain_cross",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1757390828679,
|
||||
"tag": "0003_open_spacker_dave",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1757392620734,
|
||||
"tag": "0004_grey_butterfly",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1757786449446,
|
||||
"tag": "0005_polite_preak",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
21
helm/gitea-mirror/.yamllint
Normal file
21
helm/gitea-mirror/.yamllint
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
extends: default
|
||||
|
||||
ignore: |
|
||||
.yamllint
|
||||
node_modules
|
||||
templates
|
||||
unittests/bash
|
||||
|
||||
rules:
|
||||
truthy:
|
||||
allowed-values: ['true', 'false']
|
||||
check-keys: False
|
||||
level: error
|
||||
line-length: disable
|
||||
document-start: disable
|
||||
comments:
|
||||
min-spaces-from-content: 1
|
||||
braces:
|
||||
max-spaces-inside: 2
|
||||
|
||||
12
helm/gitea-mirror/Chart.yaml
Normal file
12
helm/gitea-mirror/Chart.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
apiVersion: v2
|
||||
name: gitea-mirror
|
||||
description: Kubernetes helm chart for gitea-mirror
|
||||
type: application
|
||||
version: 0.0.1
|
||||
appVersion: 3.7.2
|
||||
icon: https://github.com/RayLabsHQ/gitea-mirror/blob/main/.github/assets/logo.png
|
||||
keywords:
|
||||
- git
|
||||
- gitea
|
||||
sources:
|
||||
- https://github.com/RayLabsHQ/gitea-mirror
|
||||
307
helm/gitea-mirror/README.md
Normal file
307
helm/gitea-mirror/README.md
Normal file
@@ -0,0 +1,307 @@
|
||||
# gitea-mirror (Helm Chart)
|
||||
|
||||
Deploy **gitea-mirror** to Kubernetes using Helm. The chart packages a Deployment, Service, optional Ingress or Gateway API HTTPRoutes, ConfigMap and Secret, a PVC (optional), and an optional ServiceAccount.
|
||||
|
||||
- **Chart name:** `gitea-mirror`
|
||||
- **Type:** `application`
|
||||
- **App version:** `3.7.2` (default image tag, can be overridden)
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Kubernetes 1.23+
|
||||
- Helm 3.8+
|
||||
- (Optional) Gateway API (v1) if you plan to use `route.*` HTTPRoutes, see https://github.com/kubernetes-sigs/gateway-api/
|
||||
- (Optional) An Ingress controller if you plan to use `ingress.*`
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
From the repo root (chart path: `helm/gitea-mirror`):
|
||||
|
||||
```bash
|
||||
# Create a namespace (optional)
|
||||
kubectl create namespace gitea-mirror
|
||||
|
||||
# Install with minimal required secrets/values
|
||||
helm upgrade --install gitea-mirror ./helm/gitea-mirror --namespace gitea-mirror --set "gitea-mirror.github.username=<your-gh-username>" --set "gitea-mirror.github.token=<your-gh-token>" --set "gitea-mirror.gitea.url=https://gitea.example.com" --set "gitea-mirror.gitea.token=<your-gitea-token>"
|
||||
```
|
||||
|
||||
The default Service is `ClusterIP` on port `8080`. You can expose it via Ingress or Gateway API; see below.
|
||||
|
||||
---
|
||||
|
||||
## Upgrading
|
||||
|
||||
Standard Helm upgrade:
|
||||
|
||||
```bash
|
||||
helm upgrade gitea-mirror ./helm/gitea-mirror -n gitea-mirror
|
||||
```
|
||||
|
||||
If you change persistence settings or storage class, a rollout may require PVC recreation.
|
||||
|
||||
---
|
||||
|
||||
## Uninstalling
|
||||
|
||||
```bash
|
||||
helm uninstall gitea-mirror -n gitea-mirror
|
||||
```
|
||||
|
||||
If you enabled persistence with a PVC the data may persist; delete the PVC manually if you want a clean slate.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Global image & pod settings
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `image.registry` | string | `ghcr.io` | Container registry. |
|
||||
| `image.repository` | string | `raylabshq/gitea-mirror` | Image repository. |
|
||||
| `image.tag` | string | `""` | Image tag; when empty, uses the chart `appVersion` (`3.7.2`). |
|
||||
| `image.pullPolicy` | string | `IfNotPresent` | K8s image pull policy. |
|
||||
| `imagePullSecrets` | list | `[]` | Image pull secrets. |
|
||||
| `podSecurityContext.runAsUser` | int | `1001` | UID. |
|
||||
| `podSecurityContext.runAsGroup` | int | `1001` | GID. |
|
||||
| `podSecurityContext.fsGroup` | int | `1001` | FS group. |
|
||||
| `podSecurityContext.fsGroupChangePolicy` | string | `OnRootMismatch` | FS group change policy. |
|
||||
| `nodeSelector` / `tolerations` / `affinity` / `topologySpreadConstraints` | — | — | Standard scheduling knobs. |
|
||||
| `extraVolumes` / `extraVolumeMounts` | list | `[]` | Append custom volumes/mounts. |
|
||||
| `priorityClassName` | string | `""` | Optional Pod priority class. |
|
||||
|
||||
### Deployment
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `deployment.port` | int | `8080` | Container port & named `http` port. |
|
||||
| `deployment.strategy.type` | string | `Recreate` | Update strategy (`Recreate` or `RollingUpdate`). |
|
||||
| `deployment.strategy.rollingUpdate.maxUnavailable/maxSurge` | string/int | — | Used when `type=RollingUpdate`. |
|
||||
| `deployment.env` | list | `[]` | Extra environment variables. |
|
||||
| `deployment.resources` | map | `{}` | CPU/memory requests & limits. |
|
||||
| `deployment.terminationGracePeriodSeconds` | int | `60` | Grace period. |
|
||||
| `livenessProbe.*` | — | enabled, `/api/health` | Liveness probe (HTTP GET to `/api/health`). |
|
||||
| `readinessProbe.*` | — | enabled, `/api/health` | Readiness probe. |
|
||||
| `startupProbe.*` | — | enabled, `/api/health` | Startup probe. |
|
||||
|
||||
> The Pod mounts a volume at `/app/data` (PVC or `emptyDir` depending on `persistence.enabled`).
|
||||
|
||||
### Service
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `service.type` | string | `ClusterIP` | Service type. |
|
||||
| `service.port` | int | `8080` | Service port. |
|
||||
| `service.clusterIP` | string | `None` | ClusterIP (only when `type=ClusterIP`). |
|
||||
| `service.externalTrafficPolicy` | string | `""` | External traffic policy (LB). |
|
||||
| `service.loadBalancerIP` | string | `""` | LoadBalancer IP. |
|
||||
| `service.loadBalancerClass` | string | `""` | LoadBalancer class. |
|
||||
| `service.annotations` / `service.labels` | map | `{}` | Extra metadata. |
|
||||
|
||||
### Ingress (optional)
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `ingress.enabled` | bool | `false` | Enable Ingress. |
|
||||
| `ingress.className` | string | `""` | IngressClass name. |
|
||||
| `ingress.hosts[0].host` | string | `mirror.example.com` | Hostname. |
|
||||
| `ingress.tls` | list | `[]` | TLS blocks (secret name etc.). |
|
||||
| `ingress.annotations` | map | `{}` | Controller-specific annotations. |
|
||||
|
||||
> The Ingress exposes `/` to the chart’s Service.
|
||||
|
||||
### Gateway API HTTPRoutes (optional)
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `route.enabled` | bool | `false` | Enable Gateway API HTTPRoutes. |
|
||||
| `route.forceHTTPS` | bool | `true` | If true, create an HTTP route that redirects to HTTPS (301). |
|
||||
| `route.domain` | list | `["mirror.example.com"]` | Hostnames. |
|
||||
| `route.gateway` | string | `""` | Gateway name. |
|
||||
| `route.gatewayNamespace` | string | `""` | Gateway namespace. |
|
||||
| `route.http.gatewaySection` | string | `""` | SectionName for HTTP listener. |
|
||||
| `route.https.gatewaySection` | string | `""` | SectionName for HTTPS listener. |
|
||||
| `route.http.filters` / `route.https.filters` | list | `[]` | Additional filters. (Defaults add HSTS header on HTTPS.) |
|
||||
|
||||
### Persistence
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `persistence.enabled` | bool | `true` | Enable persistent storage. |
|
||||
| `persistence.create` | bool | `true` | Create a PVC from the chart. |
|
||||
| `persistence.claimName` | string | `gitea-mirror-storage` | PVC name. |
|
||||
| `persistence.storageClass` | string | `""` | StorageClass to use. |
|
||||
| `persistence.accessModes` | list | `["ReadWriteOnce"]` | Access modes. |
|
||||
| `persistence.size` | string | `1Gi` | Requested size. |
|
||||
| `persistence.volumeName` | string | `""` | Bind to existing PV by name (optional). |
|
||||
| `persistence.annotations` | map | `{}` | PVC annotations. |
|
||||
|
||||
### ServiceAccount (optional)
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `serviceAccount.create` | bool | `false` | Create a ServiceAccount. |
|
||||
| `serviceAccount.name` | string | `""` | SA name (defaults to release fullname). |
|
||||
| `serviceAccount.automountServiceAccountToken` | bool | `false` | Automount token. |
|
||||
| `serviceAccount.annotations` / `labels` | map | `{}` | Extra metadata. |
|
||||
|
||||
---
|
||||
|
||||
## Application configuration (`gitea-mirror.*`)
|
||||
|
||||
These values populate a **ConfigMap** (non-secret) and a **Secret** (for tokens and sensitive fields). Environment variables from both are consumed by the container.
|
||||
|
||||
### Core
|
||||
|
||||
| Key | Default | Mapped env |
|
||||
| --- | --- | --- |
|
||||
| `gitea-mirror.nodeEnv` | `production` | `NODE_ENV` |
|
||||
| `gitea-mirror.core.databaseUrl` | `file:data/gitea-mirror.db` | `DATABASE_URL` |
|
||||
| `gitea-mirror.core.encryptionSecret` | `""` | `ENCRYPTION_SECRET` (Secret) |
|
||||
| `gitea-mirror.core.betterAuthSecret` | `""` | `BETTER_AUTH_SECRET` |
|
||||
| `gitea-mirror.core.betterAuthUrl` | `http://localhost:4321` | `BETTER_AUTH_URL` |
|
||||
| `gitea-mirror.core.betterAuthTrustedOrigins` | `http://localhost:4321` | `BETTER_AUTH_TRUSTED_ORIGINS` |
|
||||
|
||||
### GitHub
|
||||
|
||||
| Key | Default | Mapped env |
|
||||
| --- | --- | --- |
|
||||
| `gitea-mirror.github.username` | `""` | `GITHUB_USERNAME` |
|
||||
| `gitea-mirror.github.token` | `""` | `GITHUB_TOKEN` (Secret) |
|
||||
| `gitea-mirror.github.type` | `personal` | `GITHUB_TYPE` |
|
||||
| `gitea-mirror.github.privateRepositories` | `true` | `PRIVATE_REPOSITORIES` |
|
||||
| `gitea-mirror.github.skipForks` | `false` | `SKIP_FORKS` |
|
||||
| `gitea-mirror.github.starredCodeOnly` | `false` | `SKIP_STARRED_ISSUES` |
|
||||
| `gitea-mirror.github.mirrorStarred` | `false` | `MIRROR_STARRED` |
|
||||
|
||||
### Gitea
|
||||
|
||||
| Key | Default | Mapped env |
|
||||
| --- | --- | --- |
|
||||
| `gitea-mirror.gitea.url` | `""` | `GITEA_URL` |
|
||||
| `gitea-mirror.gitea.token` | `""` | `GITEA_TOKEN` (Secret) |
|
||||
| `gitea-mirror.gitea.username` | `""` | `GITEA_USERNAME` |
|
||||
| `gitea-mirror.gitea.organization` | `github-mirrors` | `GITEA_ORGANIZATION` |
|
||||
| `gitea-mirror.gitea.visibility` | `public` | `GITEA_ORG_VISIBILITY` |
|
||||
|
||||
### Mirror options
|
||||
|
||||
| Key | Default | Mapped env |
|
||||
| --- | --- | --- |
|
||||
| `gitea-mirror.mirror.releases` | `true` | `MIRROR_RELEASES` |
|
||||
| `gitea-mirror.mirror.wiki` | `true` | `MIRROR_WIKI` |
|
||||
| `gitea-mirror.mirror.metadata` | `true` | `MIRROR_METADATA` |
|
||||
| `gitea-mirror.mirror.issues` | `true` | `MIRROR_ISSUES` |
|
||||
| `gitea-mirror.mirror.pullRequests` | `true` | `MIRROR_PULL_REQUESTS` |
|
||||
| `gitea-mirror.mirror.starred` | _(see note above)_ | `MIRROR_STARRED` |
|
||||
|
||||
### Automation & cleanup
|
||||
|
||||
| Key | Default | Mapped env |
|
||||
| --- | --- | --- |
|
||||
| `gitea-mirror.automation.schedule_enabled` | `true` | `SCHEDULE_ENABLED` |
|
||||
| `gitea-mirror.automation.schedule_interval` | `3600` | `SCHEDULE_INTERVAL` (seconds) |
|
||||
| `gitea-mirror.cleanup.enabled` | `true` | `CLEANUP_ENABLED` |
|
||||
| `gitea-mirror.cleanup.retentionDays` | `30` | `CLEANUP_RETENTION_DAYS` |
|
||||
|
||||
> **Secrets:** If you set `gitea-mirror.existingSecret` (name of an existing Secret), the chart will **not** create its own Secret and will reference yours instead. Otherwise it creates a Secret with `GITHUB_TOKEN`, `GITEA_TOKEN`, `ENCRYPTION_SECRET`.
|
||||
|
||||
---
|
||||
|
||||
## Exposing the service
|
||||
|
||||
### Using Ingress
|
||||
|
||||
```yaml
|
||||
ingress:
|
||||
enabled: true
|
||||
className: "nginx"
|
||||
hosts:
|
||||
- host: mirror.example.com
|
||||
tls:
|
||||
- secretName: mirror-tls
|
||||
hosts:
|
||||
- mirror.example.com
|
||||
```
|
||||
|
||||
This creates an Ingress routing `/` to the service on port `8080`.
|
||||
|
||||
### Using Gateway API (HTTPRoute)
|
||||
|
||||
```yaml
|
||||
route:
|
||||
enabled: true
|
||||
domain: ["mirror.example.com"]
|
||||
gateway: "my-gateway"
|
||||
gatewayNamespace: "gateway-system"
|
||||
http:
|
||||
gatewaySection: "http"
|
||||
https:
|
||||
gatewaySection: "https"
|
||||
# Example extra filter already included by default: add HSTS header
|
||||
```
|
||||
|
||||
If `forceHTTPS: true`, the chart emits an HTTP route that redirects to HTTPS with 301. An HTTPS route is always created when `route.enabled=true`.
|
||||
|
||||
---
|
||||
|
||||
## Persistence & data
|
||||
|
||||
By default, the chart provisions a PVC named `gitea-mirror-storage` with `1Gi` and mounts it at `/app/data`. To use an existing PV or tune storage, adjust `persistence.*` in `values.yaml`. If you disable persistence, an `emptyDir` will be used instead.
|
||||
|
||||
---
|
||||
|
||||
## Environment & health endpoints
|
||||
|
||||
The container listens on `PORT` (defaults to `deployment.port` = `8080`) and exposes `GET /api/health` for liveness/readiness/startup probes.
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### Minimal (tokens via chart-managed Secret)
|
||||
|
||||
```yaml
|
||||
gitea-mirror:
|
||||
github:
|
||||
username: "gitea-mirror"
|
||||
token: "<gh-token>"
|
||||
gitea:
|
||||
url: "https://gitea.company.tld"
|
||||
token: "<gitea-token>"
|
||||
```
|
||||
|
||||
### Bring your own Secret
|
||||
|
||||
```yaml
|
||||
gitea-mirror:
|
||||
existingSecret: "gitea-mirror-secrets"
|
||||
github:
|
||||
username: "gitea-mirror"
|
||||
gitea:
|
||||
url: "https://gitea.company.tld"
|
||||
```
|
||||
|
||||
Where `gitea-mirror-secrets` contains keys `GITHUB_TOKEN`, `GITEA_TOKEN`, `ENCRYPTION_SECRET`.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
Lint the chart:
|
||||
|
||||
```bash
|
||||
yamllint -c helm/gitea-mirror/.yamllint helm/gitea-mirror
|
||||
```
|
||||
|
||||
Tweak probes, resources, and scheduling as needed; see `values.yaml`.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This chart is part of the `RayLabsHQ/gitea-mirror` repository. See the repository for licensing details.
|
||||
59
helm/gitea-mirror/templates/_helpers.tpl
Normal file
59
helm/gitea-mirror/templates/_helpers.tpl
Normal file
@@ -0,0 +1,59 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
|
||||
{{- define "gitea-mirror.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||
If release name contains chart name it will be used as a full name.
|
||||
*/}}
|
||||
{{- define "gitea-mirror.fullname" -}}
|
||||
{{- if .Values.fullnameOverride -}}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride -}}
|
||||
{{- if contains $name .Release.Name -}}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "gitea-mirror.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "gitea-mirror.labels" -}}
|
||||
helm.sh/chart: {{ include "gitea-mirror.chart" . }}
|
||||
app: {{ include "gitea-mirror.name" . }}
|
||||
{{ include "gitea-mirror.selectorLabels" . }}
|
||||
app.kubernetes.io/version: {{ .Values.image.tag | default .Chart.AppVersion | quote }}
|
||||
version: {{ .Values.image.tag | default .Chart.AppVersion | quote }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Selector labels
|
||||
*/}}
|
||||
{{- define "gitea-mirror.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "gitea-mirror.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
ServiceAccount name
|
||||
*/}}
|
||||
{{- define "gitea-mirror.serviceAccountName" -}}
|
||||
{{ .Values.serviceAccount.name | default (include "gitea-mirror.fullname" .) }}
|
||||
{{- end -}}
|
||||
38
helm/gitea-mirror/templates/configmap.yaml
Normal file
38
helm/gitea-mirror/templates/configmap.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
{{- $gm := index .Values "gitea-mirror" -}}
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "gitea-mirror.fullname" . }}
|
||||
labels:
|
||||
{{- include "gitea-mirror.labels" . | nindent 4 }}
|
||||
data:
|
||||
NODE_ENV: {{ $gm.nodeEnv | quote }}
|
||||
# Core configuration
|
||||
DATABASE_URL: {{ $gm.core.databaseUrl | quote }}
|
||||
BETTER_AUTH_SECRET: {{ $gm.core.betterAuthSecret | quote }}
|
||||
BETTER_AUTH_URL: {{ $gm.core.betterAuthUrl | quote }}
|
||||
BETTER_AUTH_TRUSTED_ORIGINS: {{ $gm.core.betterAuthTrustedOrigins | quote }}
|
||||
# GitHub Config
|
||||
GITHUB_USERNAME: {{ $gm.github.username | quote }}
|
||||
GITHUB_TYPE: {{ $gm.github.type | quote }}
|
||||
PRIVATE_REPOSITORIES: {{ $gm.github.privateRepositories | quote }}
|
||||
MIRROR_STARRED: {{ $gm.github.mirrorStarred | quote }}
|
||||
SKIP_FORKS: {{ $gm.github.skipForks | quote }}
|
||||
SKIP_STARRED_ISSUES: {{ $gm.github.starredCodeOnly | quote }}
|
||||
# Gitea Config
|
||||
GITEA_URL: {{ $gm.gitea.url | quote }}
|
||||
GITEA_USERNAME: {{ $gm.gitea.username | quote }}
|
||||
GITEA_ORGANIZATION: {{ $gm.gitea.organization | quote }}
|
||||
GITEA_ORG_VISIBILITY: {{ $gm.gitea.visibility | quote }}
|
||||
# Mirror Options
|
||||
MIRROR_RELEASES: {{ $gm.mirror.releases | quote }}
|
||||
MIRROR_WIKI: {{ $gm.mirror.wiki | quote }}
|
||||
MIRROR_METADATA: {{ $gm.mirror.metadata | quote }}
|
||||
MIRROR_ISSUES: {{ $gm.mirror.issues | quote }}
|
||||
MIRROR_PULL_REQUESTS: {{ $gm.mirror.pullRequests | quote }}
|
||||
# Automation
|
||||
SCHEDULE_ENABLED: {{ $gm.automation.schedule_enabled| quote }}
|
||||
SCHEDULE_INTERVAL: {{ $gm.automation.schedule_interval | quote }}
|
||||
# Cleanup
|
||||
CLEANUP_ENABLED: {{ $gm.cleanup.enabled | quote }}
|
||||
CLEANUP_RETENTION_DAYS: {{ $gm.cleanup.retentionDays | quote }}
|
||||
143
helm/gitea-mirror/templates/deployment.yaml
Normal file
143
helm/gitea-mirror/templates/deployment.yaml
Normal file
@@ -0,0 +1,143 @@
|
||||
{{- $gm := index .Values "gitea-mirror" -}}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "gitea-mirror.fullname" . }}
|
||||
{{- with .Values.deployment.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "gitea-mirror.labels" . | nindent 4 }}
|
||||
{{- if .Values.deployment.labels }}
|
||||
{{- toYaml .Values.deployment.labels | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: {{ .Values.deployment.strategy.type }}
|
||||
{{- if eq .Values.deployment.strategy.type "RollingUpdate" }}
|
||||
rollingUpdate:
|
||||
maxUnavailable: {{ .Values.deployment.strategy.rollingUpdate.maxUnavailable }}
|
||||
maxSurge: {{ .Values.deployment.strategy.rollingUpdate.maxSurge }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "gitea-mirror.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "gitea-mirror.labels" . | nindent 8 }}
|
||||
{{- if .Values.deployment.labels }}
|
||||
{{- toYaml .Values.deployment.labels | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if (or .Values.serviceAccount.create .Values.serviceAccount.name) }}
|
||||
serviceAccountName: {{ include "gitea-mirror.serviceAccountName" . }}
|
||||
{{- end }}
|
||||
{{- if .Values.priorityClassName }}
|
||||
priorityClassName: "{{ .Values.priorityClassName }}"
|
||||
{{- end }}
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.podSecurityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
terminationGracePeriodSeconds: {{ .Values.deployment.terminationGracePeriodSeconds }}
|
||||
containers:
|
||||
- name: gitea-mirror
|
||||
image: {{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag | default (printf "v%s" .Chart.AppVersion) }}
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ include "gitea-mirror.fullname" . }}
|
||||
{{- if $gm.existingSecret }}
|
||||
- secretRef:
|
||||
name: {{ $gm.existingSecret }}
|
||||
{{- else }}
|
||||
- secretRef:
|
||||
name: {{ include "gitea-mirror.fullname" . }}
|
||||
{{- end }}
|
||||
env:
|
||||
- name: PORT
|
||||
value: "{{ .Values.deployment.port }}"
|
||||
{{- if .Values.deployment.env }}
|
||||
{{- toYaml .Values.deployment.env | nindent 12 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.deployment.port }}
|
||||
{{- if .Values.livenessProbe.enabled }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: "http"
|
||||
initialDelaySeconds: {{ .Values.livenessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.livenessProbe.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.livenessProbe.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.livenessProbe.failureThreshold }}
|
||||
successThreshold: {{ .Values.livenessProbe.successThreshold }}
|
||||
{{- end }}
|
||||
{{- if .Values.readinessProbe.enabled }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: "http"
|
||||
initialDelaySeconds: {{ .Values.readinessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.readinessProbe.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.readinessProbe.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.readinessProbe.failureThreshold }}
|
||||
successThreshold: {{ .Values.readinessProbe.successThreshold }}
|
||||
{{- end }}
|
||||
{{- if .Values.startupProbe.enabled }}
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: "http"
|
||||
initialDelaySeconds: {{ .Values.startupProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.startupProbe.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.startupProbe.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.startupProbe.failureThreshold }}
|
||||
successThreshold: {{ .Values.startupProbe.successThreshold }}
|
||||
{{- end }}
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
{{- if .Values.extraVolumeMounts }}
|
||||
{{- toYaml .Values.extraVolumeMounts | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.deployment.resources }}
|
||||
resources:
|
||||
{{- toYaml .Values.deployment.resources | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.topologySpreadConstraints }}
|
||||
topologySpreadConstraints:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
{{- if .Values.persistence.enabled }}
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Values.persistence.claimName }}
|
||||
{{- else if not .Values.persistence.enabled }}
|
||||
- name: data
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- if .Values.extraVolumes }}
|
||||
{{- toYaml .Values.extraVolumes | nindent 8 }}
|
||||
{{- end }}
|
||||
77
helm/gitea-mirror/templates/httproute.yaml
Normal file
77
helm/gitea-mirror/templates/httproute.yaml
Normal file
@@ -0,0 +1,77 @@
|
||||
{{- if .Values.route.enabled }}
|
||||
{{- if .Values.route.forceHTTPS }}
|
||||
---
|
||||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: HTTPRoute
|
||||
metadata:
|
||||
name: {{ include "gitea-mirror.fullname" . }}-http
|
||||
labels:
|
||||
{{- include "gitea-mirror.labels" . | nindent 4 }}
|
||||
spec:
|
||||
parentRefs:
|
||||
- name: {{ .Values.route.gateway }}
|
||||
sectionName: {{ .Values.route.http.gatewaySection }}
|
||||
namespace: {{ .Values.route.gatewayNamespace }}
|
||||
hostnames: {{ .Values.route.domain }}
|
||||
rules:
|
||||
- filters:
|
||||
- type: RequestRedirect
|
||||
requestRedirect:
|
||||
scheme: https
|
||||
statusCode: 301
|
||||
{{- with .Values.route.http.filters }}
|
||||
{{ toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
---
|
||||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: HTTPRoute
|
||||
metadata:
|
||||
name: {{ include "gitea-mirror.fullname" . }}-http
|
||||
labels:
|
||||
{{- include "gitea-mirror.labels" . | nindent 4 }}
|
||||
spec:
|
||||
parentRefs:
|
||||
- name: {{ .Values.route.gateway }}
|
||||
sectionName: {{ .Values.route.http.gatewaySection }}
|
||||
namespace: {{ .Values.route.gatewayNamespace }}
|
||||
hostnames: {{ .Values.route.domain }}
|
||||
rules:
|
||||
- matches:
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: /
|
||||
backendRefs:
|
||||
- name: {{ include "gitea-mirror.fullname" . }}
|
||||
port: {{ .Values.service.port }}
|
||||
{{- with .Values.route.http.filters }}
|
||||
filters:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: HTTPRoute
|
||||
metadata:
|
||||
name: {{ include "gitea-mirror.fullname" . }}-https
|
||||
labels:
|
||||
{{- include "gitea-mirror.labels" . | nindent 4 }}
|
||||
spec:
|
||||
parentRefs:
|
||||
- name: {{ .Values.route.gateway }}
|
||||
sectionName: {{ .Values.route.https.gatewaySection }}
|
||||
namespace: {{ .Values.route.gatewayNamespace }}
|
||||
hostnames: {{ .Values.route.domain }}
|
||||
rules:
|
||||
- matches:
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: /
|
||||
backendRefs:
|
||||
- name: {{ include "gitea-mirror.fullname" . }}
|
||||
port: {{ .Values.service.port }}
|
||||
{{- with .Values.route.https.filters }}
|
||||
filters:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
40
helm/gitea-mirror/templates/ingress.yaml
Normal file
40
helm/gitea-mirror/templates/ingress.yaml
Normal file
@@ -0,0 +1,40 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "gitea-mirror.fullname" . }}
|
||||
labels:
|
||||
{{- include "gitea-mirror.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- . | toYaml | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- range .Values.ingress.tls }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ tpl . $ | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ tpl .host $ | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: {{ .path | default "/" }}
|
||||
pathType: {{ .pathType | default "Prefix" }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "gitea-mirror.fullname" $ }}
|
||||
port:
|
||||
number: {{ $.Values.service.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
26
helm/gitea-mirror/templates/pvc.yaml
Normal file
26
helm/gitea-mirror/templates/pvc.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
{{- if and .Values.persistence.enabled .Values.persistence.create }}
|
||||
{{- $gm := index .Values "gitea-mirror" -}}
|
||||
kind: PersistentVolumeClaim
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: {{ .Values.persistence.claimName }}
|
||||
labels:
|
||||
{{- include "gitea-mirror.labels" . | nindent 4 }}
|
||||
{{- with .Values.persistence.annotations }}
|
||||
annotations:
|
||||
{{ . | toYaml | indent 4}}
|
||||
{{- end }}
|
||||
spec:
|
||||
accessModes:
|
||||
{{- toYaml .Values.persistence.accessModes | nindent 4 }}
|
||||
{{- with .Values.persistence.storageClass }}
|
||||
storageClassName: {{ . }}
|
||||
{{- end }}
|
||||
volumeMode: Filesystem
|
||||
{{- with .Values.persistence.volumeName }}
|
||||
volumeName: {{ . }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.size }}
|
||||
{{- end }}
|
||||
14
helm/gitea-mirror/templates/secret.yaml
Normal file
14
helm/gitea-mirror/templates/secret.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
{{- $gm := index .Values "gitea-mirror" -}}
|
||||
{{- if (empty $gm.existingSecret) }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "gitea-mirror.fullname" . }}
|
||||
labels:
|
||||
{{- include "gitea-mirror.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
GITHUB_TOKEN: {{ $gm.github.token | quote }}
|
||||
GITEA_TOKEN: {{ $gm.gitea.token | quote }}
|
||||
ENCRYPTION_SECRET: {{ $gm.core.encryptionSecret | quote }}
|
||||
{{- end }}
|
||||
34
helm/gitea-mirror/templates/service.yaml
Normal file
34
helm/gitea-mirror/templates/service.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "gitea-mirror.fullname" . }}
|
||||
labels:
|
||||
{{- include "gitea-mirror.labels" . | nindent 4 }}
|
||||
{{- if .Values.service.labels }}
|
||||
{{- toYaml .Values.service.labels | nindent 4 }}
|
||||
{{- end }}
|
||||
annotations:
|
||||
{{- toYaml .Values.service.annotations | nindent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
{{- if eq .Values.service.type "LoadBalancer" }}
|
||||
{{- if .Values.service.loadBalancerClass }}
|
||||
loadBalancerClass: {{ .Values.service.loadBalancerClass }}
|
||||
{{- end }}
|
||||
{{- if and .Values.service.loadBalancerIP }}
|
||||
loadBalancerIP: {{ .Values.service.loadBalancerIP }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.service.externalTrafficPolicy }}
|
||||
externalTrafficPolicy: {{ .Values.service.externalTrafficPolicy }}
|
||||
{{- end }}
|
||||
{{- if and .Values.service.clusterIP (eq .Values.service.type "ClusterIP") }}
|
||||
clusterIP: {{ .Values.service.clusterIP }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.service.port }}
|
||||
protocol: TCP
|
||||
targetPort: http
|
||||
selector:
|
||||
{{- include "gitea-mirror.selectorLabels" . | nindent 4 }}
|
||||
17
helm/gitea-mirror/templates/serviceaccount.yaml
Normal file
17
helm/gitea-mirror/templates/serviceaccount.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "gitea-mirror.serviceAccountName" . }}
|
||||
labels:
|
||||
{{- include "gitea-mirror.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceAccount.labels }}
|
||||
{{- . | toYaml | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with .Values.serviceAccount.annotations }}
|
||||
annotations:
|
||||
{{- . | toYaml | nindent 4 }}
|
||||
{{- end }}
|
||||
automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }}
|
||||
{{- end }}
|
||||
|
||||
151
helm/gitea-mirror/values.yaml
Normal file
151
helm/gitea-mirror/values.yaml
Normal file
@@ -0,0 +1,151 @@
|
||||
image:
|
||||
registry: ghcr.io
|
||||
repository: raylabshq/gitea-mirror
|
||||
# Leave blank to use the Appversion tag
|
||||
tag: ""
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
imagePullSecrets: []
|
||||
podSecurityContext:
|
||||
runAsUser: 1001
|
||||
runAsGroup: 1001
|
||||
fsGroup: 1001
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
annotations: {}
|
||||
hosts:
|
||||
- host: mirror.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls: []
|
||||
# - secretName: chart-example-tls
|
||||
# hosts:
|
||||
# - mirror.example.com
|
||||
|
||||
route:
|
||||
enabled: false
|
||||
forceHTTPS: true
|
||||
domain: ["mirror.example.com"]
|
||||
gateway: ""
|
||||
gatewayNamespace: ""
|
||||
http:
|
||||
gatewaySection: ""
|
||||
filters: []
|
||||
https:
|
||||
gatewaySection: ""
|
||||
filters:
|
||||
- type: ResponseHeaderModifier
|
||||
responseHeaderModifier:
|
||||
add:
|
||||
- name: Strict-Transport-Security
|
||||
value: "max-age=31536000; includeSubDomains; preload"
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8080
|
||||
clusterIP: None
|
||||
annotations: {}
|
||||
externalTrafficPolicy:
|
||||
labels: {}
|
||||
loadBalancerIP:
|
||||
loadBalancerClass:
|
||||
|
||||
deployment:
|
||||
port: 8080
|
||||
strategy:
|
||||
type: Recreate
|
||||
env: []
|
||||
terminationGracePeriodSeconds: 60
|
||||
labels: {}
|
||||
annotations: {}
|
||||
resources: {}
|
||||
|
||||
livenessProbe:
|
||||
enabled: true
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 6
|
||||
successThreshold: 1
|
||||
readinessProbe:
|
||||
enabled: true
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 6
|
||||
successThreshold: 1
|
||||
startupProbe:
|
||||
enabled: true
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 6
|
||||
successThreshold: 1
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
create: true
|
||||
claimName: gitea-mirror-storage
|
||||
storageClass: ""
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
size: 1Gi
|
||||
|
||||
affinity: {}
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
topologySpreadConstraints: []
|
||||
extraVolumes: []
|
||||
extraVolumeMounts: []
|
||||
|
||||
serviceAccount:
|
||||
create: false
|
||||
name: ""
|
||||
annotations: {}
|
||||
labels: {}
|
||||
automountServiceAccountToken: false
|
||||
|
||||
gitea-mirror:
|
||||
existingSecret: ""
|
||||
nodeEnv: production
|
||||
core:
|
||||
databaseUrl: file:data/gitea-mirror.db
|
||||
encryptionSecret: ""
|
||||
betterAuthSecret: ""
|
||||
betterAuthUrl: "http://localhost:4321"
|
||||
betterAuthTrustedOrigins: "http://localhost:4321"
|
||||
|
||||
github:
|
||||
username: ""
|
||||
token: ""
|
||||
type: personal
|
||||
privateRepositories: true
|
||||
mirrorStarred: false
|
||||
skipForks: false
|
||||
starredCodeOnly: false
|
||||
|
||||
gitea:
|
||||
url: ""
|
||||
token: ""
|
||||
username: ""
|
||||
organization: "github-mirrors"
|
||||
visibility: "public"
|
||||
|
||||
mirror:
|
||||
releases: true
|
||||
wiki: true
|
||||
metadata: true
|
||||
issues: true
|
||||
pullRequests: true
|
||||
|
||||
automation:
|
||||
schedule_enabled: true
|
||||
schedule_interval: 3600
|
||||
|
||||
cleanup:
|
||||
enabled: true
|
||||
retentionDays: 30
|
||||
46
package.json
46
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.4.0",
|
||||
"version": "3.8.4",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
@@ -43,10 +43,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/mdx": "4.3.4",
|
||||
"@astrojs/node": "9.4.3",
|
||||
"@astrojs/react": "^4.3.0",
|
||||
"@better-auth/sso": "^1.3.7",
|
||||
"@astrojs/mdx": "4.3.6",
|
||||
"@astrojs/node": "9.4.4",
|
||||
"@astrojs/react": "^4.3.1",
|
||||
"@better-auth/sso": "^1.3.24",
|
||||
"@octokit/plugin-throttling": "^11.0.2",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
@@ -57,6 +58,7 @@
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
@@ -65,48 +67,48 @@
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react": "^19.1.16",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"astro": "^5.13.4",
|
||||
"astro": "^5.14.1",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-auth": "^1.3.7",
|
||||
"better-auth": "^1.3.24",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dotenv": "^17.2.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"fuse.js": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.542.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"tw-animate-css": "^1.3.7",
|
||||
"typescript": "^5.9.2",
|
||||
"uuid": "^11.1.0",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"uuid": "^13.0.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.1.5"
|
||||
"zod": "^4.1.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/jest-dom": "^6.9.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/bun": "^1.2.21",
|
||||
"@types/bun": "^1.2.23",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"drizzle-kit": "^0.31.5",
|
||||
"jsdom": "^26.1.0",
|
||||
"tsx": "^4.20.5",
|
||||
"tsx": "^4.20.6",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"packageManager": "bun@1.2.21"
|
||||
"packageManager": "bun@1.2.23"
|
||||
}
|
||||
|
||||
@@ -67,21 +67,21 @@ export function AdvancedOptionsForm({
|
||||
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="skip-starred-issues"
|
||||
checked={config.skipStarredIssues}
|
||||
id="starred-code-only"
|
||||
checked={config.starredCodeOnly}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange("skipStarredIssues", Boolean(checked))
|
||||
handleChange("starredCodeOnly", Boolean(checked))
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="skip-starred-issues"
|
||||
htmlFor="starred-code-only"
|
||||
className="ml-2 text-sm select-none"
|
||||
>
|
||||
Don't fetch issues for starred repos
|
||||
Code-only mode for starred repos
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-6">
|
||||
Skip mirroring issues and pull requests for starred repositories
|
||||
Mirror only source code for starred repositories, skipping all metadata (issues, PRs, labels, milestones, wiki, releases)
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -122,12 +122,12 @@ export function AutomationSettings({
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Automatic Mirroring Section */}
|
||||
{/* Automatic Syncing 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
|
||||
Automatic Syncing
|
||||
</h3>
|
||||
{isAutoSavingSchedule && (
|
||||
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
|
||||
@@ -50,12 +50,12 @@ export function ConfigTabs() {
|
||||
preserveOrgStructure: false,
|
||||
},
|
||||
scheduleConfig: {
|
||||
enabled: true, // Default to enabled
|
||||
interval: 86400, // Default to daily (24 hours)
|
||||
enabled: false, // Don't set defaults here - will be loaded from API
|
||||
interval: 0, // Will be replaced with actual value from API
|
||||
},
|
||||
cleanupConfig: {
|
||||
enabled: true, // Default to enabled
|
||||
retentionDays: 604800, // 7 days in seconds - Default retention period
|
||||
enabled: false, // Don't set defaults here - will be loaded from API
|
||||
retentionDays: 0, // Will be replaced with actual value from API
|
||||
},
|
||||
mirrorOptions: {
|
||||
mirrorReleases: false,
|
||||
@@ -71,7 +71,7 @@ export function ConfigTabs() {
|
||||
},
|
||||
advancedOptions: {
|
||||
skipForks: false,
|
||||
skipStarredIssues: false,
|
||||
starredCodeOnly: false,
|
||||
},
|
||||
});
|
||||
const { user } = useAuth();
|
||||
|
||||
@@ -15,11 +15,11 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Info,
|
||||
GitBranch,
|
||||
Star,
|
||||
Lock,
|
||||
import {
|
||||
Info,
|
||||
GitBranch,
|
||||
Star,
|
||||
Lock,
|
||||
Archive,
|
||||
GitPullRequest,
|
||||
Tag,
|
||||
@@ -30,9 +30,17 @@ import {
|
||||
GitFork,
|
||||
ChevronDown,
|
||||
Funnel,
|
||||
HardDrive
|
||||
HardDrive,
|
||||
FileCode2
|
||||
} from "lucide-react";
|
||||
import type { GitHubConfig, MirrorOptions, AdvancedOptions } from "@/types/config";
|
||||
import type { GitHubConfig, MirrorOptions, AdvancedOptions, DuplicateNameStrategy } from "@/types/config";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface GitHubMirrorSettingsProps {
|
||||
@@ -53,7 +61,7 @@ export function GitHubMirrorSettings({
|
||||
onAdvancedOptionsChange,
|
||||
}: GitHubMirrorSettingsProps) {
|
||||
|
||||
const handleGitHubChange = (field: keyof GitHubConfig, value: boolean) => {
|
||||
const handleGitHubChange = (field: keyof GitHubConfig, value: boolean | string) => {
|
||||
onGitHubConfigChange({ ...githubConfig, [field]: value });
|
||||
};
|
||||
|
||||
@@ -81,10 +89,10 @@ export function GitHubMirrorSettings({
|
||||
// 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,
|
||||
releases: !advancedOptions.starredCodeOnly && mirrorOptions.mirrorReleases,
|
||||
issues: !advancedOptions.starredCodeOnly && mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.issues,
|
||||
pullRequests: !advancedOptions.starredCodeOnly && mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.pullRequests,
|
||||
wiki: !advancedOptions.starredCodeOnly && mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.wiki,
|
||||
};
|
||||
|
||||
const starredContentCount = Object.entries(starredRepoContent).filter(([key, value]) => key !== 'code' && value).length;
|
||||
@@ -160,7 +168,7 @@ export function GitHubMirrorSettings({
|
||||
className="h-8 text-xs font-normal min-w-[140px] md:min-w-[140px] justify-between"
|
||||
>
|
||||
<span>
|
||||
{advancedOptions.skipStarredIssues ? (
|
||||
{advancedOptions.starredCodeOnly ? (
|
||||
"Code only"
|
||||
) : starredContentCount === 0 ? (
|
||||
"Code only"
|
||||
@@ -198,8 +206,8 @@ export function GitHubMirrorSettings({
|
||||
<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)}
|
||||
checked={advancedOptions.starredCodeOnly}
|
||||
onCheckedChange={(checked) => handleAdvancedChange('starredCodeOnly', !!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="starred-lightweight"
|
||||
@@ -214,7 +222,7 @@ export function GitHubMirrorSettings({
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{!advancedOptions.skipStarredIssues && (
|
||||
{!advancedOptions.starredCodeOnly && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-2">
|
||||
@@ -278,6 +286,40 @@ export function GitHubMirrorSettings({
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duplicate name handling for starred repos */}
|
||||
{githubConfig.mirrorStarred && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
Duplicate name handling
|
||||
</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileCode2 className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm">Name collision strategy</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How to handle repos with the same name from different owners
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={githubConfig.starredDuplicateStrategy || "suffix"}
|
||||
onValueChange={(value) => handleGitHubChange('starredDuplicateStrategy', value as DuplicateNameStrategy)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] h-8 text-xs">
|
||||
<SelectValue placeholder="Select strategy" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="suffix" className="text-xs">
|
||||
<span className="font-mono">repo-owner</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="prefix" className="text-xs">
|
||||
<span className="font-mono">owner-repo</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -596,4 +638,4 @@ export function GitHubMirrorSettings({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,8 +372,8 @@ export function SSOSettings() {
|
||||
Add Provider
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] md:max-h-[85vh] lg:max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>{editingProvider ? 'Edit SSO Provider' : 'Add SSO Provider'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingProvider
|
||||
@@ -381,14 +381,15 @@ export function SSOSettings() {
|
||||
: 'Configure an external identity provider for user authentication'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Tabs value={providerType} onValueChange={(value) => setProviderType(value as 'oidc' | 'saml')}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="oidc">OIDC / OAuth2</TabsTrigger>
|
||||
<TabsTrigger value="saml">SAML 2.0</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Common Fields */}
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex-1 overflow-y-auto px-1 -mx-1">
|
||||
<Tabs value={providerType} onValueChange={(value) => setProviderType(value as 'oidc' | 'saml')}>
|
||||
<TabsList className="grid w-full grid-cols-2 sticky top-0 z-10 bg-background">
|
||||
<TabsTrigger value="oidc">OIDC / OAuth2</TabsTrigger>
|
||||
<TabsTrigger value="saml">SAML 2.0</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Common Fields */}
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="providerId">Provider ID</Label>
|
||||
@@ -569,7 +570,8 @@ export function SSOSettings() {
|
||||
</Alert>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<DialogFooter>
|
||||
</div>
|
||||
<DialogFooter className="flex-shrink-0 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
|
||||
@@ -83,7 +83,7 @@ export function ScheduleConfigForm({
|
||||
htmlFor="enabled"
|
||||
className="select-none ml-2 block text-sm font-medium"
|
||||
>
|
||||
Enable Automatic Mirroring
|
||||
Enable Automatic Syncing
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -93,7 +93,7 @@ export function ScheduleConfigForm({
|
||||
htmlFor="interval"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
Mirroring Interval
|
||||
Sync Interval
|
||||
</label>
|
||||
|
||||
<Select
|
||||
@@ -122,7 +122,7 @@ export function ScheduleConfigForm({
|
||||
</Select>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
How often the mirroring process should run.
|
||||
How often the sync process should run.
|
||||
</p>
|
||||
<div className="mt-2 p-2 bg-muted/50 rounded-md">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -9,6 +9,7 @@ import { apiRequest, showErrorToast } from "@/lib/utils";
|
||||
import type { DashboardApiResponse } from "@/types/dashboard";
|
||||
import { useSSE } from "@/hooks/useSEE";
|
||||
import { toast } from "sonner";
|
||||
import { useEffect as useEffectForToasts } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
@@ -16,6 +17,46 @@ import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { useNavigation } from "@/components/layout/MainLayout";
|
||||
|
||||
// Helper function to format last sync time
|
||||
function formatLastSyncTime(date: Date | null): string {
|
||||
if (!date) return "Never";
|
||||
|
||||
const now = new Date();
|
||||
const syncDate = new Date(date);
|
||||
const diffMs = now.getTime() - syncDate.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
// Show relative time for recent syncs
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins} min ago`;
|
||||
if (diffHours < 24) return `${diffHours} hr${diffHours === 1 ? '' : 's'} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
||||
|
||||
// For older syncs, show week count
|
||||
const diffWeeks = Math.floor(diffDays / 7);
|
||||
if (diffWeeks < 4) return `${diffWeeks} week${diffWeeks === 1 ? '' : 's'} ago`;
|
||||
|
||||
// For even older, show month count
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
// Helper function to format full timestamp
|
||||
function formatFullTimestamp(date: Date | null): string {
|
||||
if (!date) return "";
|
||||
|
||||
return new Date(date).toLocaleString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: true
|
||||
}).replace(',', '');
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const { user } = useAuth();
|
||||
const { registerRefreshCallback } = useLiveRefresh();
|
||||
@@ -65,6 +106,51 @@ export function Dashboard() {
|
||||
onMessage: handleNewMessage,
|
||||
});
|
||||
|
||||
// Setup rate limit event listener for toast notifications
|
||||
useEffectForToasts(() => {
|
||||
if (!user?.id) return;
|
||||
|
||||
const eventSource = new EventSource(`/api/events?userId=${user.id}`);
|
||||
|
||||
eventSource.addEventListener("rate-limit", (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
switch (data.type) {
|
||||
case "warning":
|
||||
// 80% threshold warning
|
||||
toast.warning("GitHub API Rate Limit Warning", {
|
||||
description: data.message,
|
||||
duration: 8000,
|
||||
});
|
||||
break;
|
||||
|
||||
case "exceeded":
|
||||
// 100% rate limit exceeded
|
||||
toast.error("GitHub API Rate Limit Exceeded", {
|
||||
description: data.message,
|
||||
duration: 10000,
|
||||
});
|
||||
break;
|
||||
|
||||
case "resumed":
|
||||
// Rate limit reset notification
|
||||
toast.success("Rate Limit Reset", {
|
||||
description: "API operations have resumed.",
|
||||
duration: 5000,
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing rate limit event:", error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
// Extract fetchDashboardData as a stable callback
|
||||
const fetchDashboardData = useCallback(async (showToast = false) => {
|
||||
try {
|
||||
@@ -236,19 +322,9 @@ export function Dashboard() {
|
||||
/>
|
||||
<StatusCard
|
||||
title="Last Sync"
|
||||
value={
|
||||
lastSync
|
||||
? new Date(lastSync).toLocaleString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "N/A"
|
||||
}
|
||||
value={formatLastSyncTime(lastSync)}
|
||||
icon={<Clock className="h-4 w-4" />}
|
||||
description="Last successful sync"
|
||||
description={formatFullTimestamp(lastSync)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { Menu, LogOut } from "lucide-react";
|
||||
import { Menu, LogOut, PanelRightOpen, PanelRightClose } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -19,9 +19,12 @@ interface HeaderProps {
|
||||
currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log";
|
||||
onNavigate?: (page: string) => void;
|
||||
onMenuClick: () => void;
|
||||
onToggleCollapse?: () => void;
|
||||
isSidebarCollapsed?: boolean;
|
||||
isSidebarOpen?: boolean;
|
||||
}
|
||||
|
||||
export function Header({ currentPage, onNavigate, onMenuClick }: HeaderProps) {
|
||||
export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse, isSidebarCollapsed, isSidebarOpen }: HeaderProps) {
|
||||
const { user, logout, isLoading } = useAuth();
|
||||
const { isLiveEnabled, toggleLive } = useLiveRefresh();
|
||||
const { isFullyConfigured, isLoading: configLoading } = useConfigStatus();
|
||||
@@ -63,18 +66,38 @@ export function Header({ currentPage, onNavigate, onMenuClick }: HeaderProps) {
|
||||
return (
|
||||
<header className="border-b bg-background">
|
||||
<div className="flex h-[4.5rem] items-center justify-between px-4 sm:px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Hamburger Menu Button - Mobile Only */}
|
||||
<div className="flex items-center lg:gap-12 md:gap-6 gap-4">
|
||||
{/* Sidebar Toggle - Mobile uses slide-in, Medium uses collapse */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="lg:hidden"
|
||||
size="icon"
|
||||
className="md:hidden h-10 w-10"
|
||||
onClick={onMenuClick}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
{isSidebarOpen ? (
|
||||
<PanelRightOpen className="h-5 w-5" />
|
||||
) : (
|
||||
<PanelRightClose className="h-5 w-5" />
|
||||
)}
|
||||
<span className="sr-only">Toggle menu</span>
|
||||
</Button>
|
||||
|
||||
{/* Sidebar Collapse Toggle - Only on medium screens (768px - 1280px) */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hidden md:flex xl:hidden h-10 w-10"
|
||||
onClick={onToggleCollapse}
|
||||
title={isSidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{isSidebarCollapsed ? (
|
||||
<PanelRightClose className="h-5 w-5" />
|
||||
) : (
|
||||
<PanelRightOpen className="h-5 w-5" />
|
||||
)}
|
||||
<span className="sr-only">Toggle sidebar</span>
|
||||
</Button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (currentPage !== 'dashboard') {
|
||||
|
||||
@@ -45,6 +45,13 @@ function AppWithProviders({ page: initialPage }: AppProps) {
|
||||
const [currentPage, setCurrentPage] = useState<AppProps['page']>(initialPage);
|
||||
const [navigationKey, setNavigationKey] = useState(0);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||
// Check if we're on medium screens (768px - 1280px)
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.innerWidth >= 768 && window.innerWidth < 1280;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
useRepoSync({
|
||||
userId: user?.id,
|
||||
@@ -83,6 +90,23 @@ function AppWithProviders({ page: initialPage }: AppProps) {
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []);
|
||||
|
||||
// Handle window resize to auto-collapse sidebar on medium screens
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const width = window.innerWidth;
|
||||
// Auto-collapse on medium screens (768px - 1280px)
|
||||
if (width >= 768 && width < 1280) {
|
||||
setSidebarCollapsed(true);
|
||||
} else if (width >= 1280) {
|
||||
// Expand on large screens
|
||||
setSidebarCollapsed(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Show loading state only during initial auth/config loading
|
||||
const isInitialLoading = authLoading || (configLoading && !user);
|
||||
|
||||
@@ -113,14 +137,21 @@ function AppWithProviders({ page: initialPage }: AppProps) {
|
||||
currentPage={currentPage}
|
||||
onNavigate={handleNavigation}
|
||||
onMenuClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
isSidebarCollapsed={sidebarCollapsed}
|
||||
isSidebarOpen={sidebarOpen}
|
||||
/>
|
||||
<div className="flex flex-1 relative">
|
||||
<Sidebar
|
||||
onNavigate={handleNavigation}
|
||||
isOpen={sidebarOpen}
|
||||
isCollapsed={sidebarCollapsed}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
/>
|
||||
<section className="flex-1 p-4 sm:p-6 overflow-y-auto h-[calc(100dvh-4.55rem)] w-full lg:w-[calc(100%-16rem)]">
|
||||
<section className={`flex-1 p-4 sm:p-6 overflow-y-auto h-[calc(100dvh-4.55rem)] w-full transition-all duration-200 ${
|
||||
sidebarCollapsed ? 'md:w-[calc(100%-5rem)] xl:w-[calc(100%-16rem)]' : 'md:w-[calc(100%-16rem)]'
|
||||
}`}>
|
||||
{currentPage === "dashboard" && <Dashboard />}
|
||||
{currentPage === "repositories" && <Repository />}
|
||||
{currentPage === "organizations" && <Organization />}
|
||||
|
||||
@@ -3,15 +3,23 @@ import { cn } from "@/lib/utils";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { links } from "@/data/Sidebar";
|
||||
import { VersionInfo } from "./VersionInfo";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface SidebarProps {
|
||||
className?: string;
|
||||
onNavigate?: (page: string) => void;
|
||||
isOpen: boolean;
|
||||
isCollapsed?: boolean;
|
||||
onClose: () => void;
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ className, onNavigate, isOpen, onClose }: SidebarProps) {
|
||||
export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, onClose, onToggleCollapse }: SidebarProps) {
|
||||
const [currentPath, setCurrentPath] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -53,7 +61,7 @@ export function Sidebar({ className, onNavigate, isOpen, onClose }: SidebarProps
|
||||
onNavigate?.(pageName);
|
||||
|
||||
// Close sidebar on mobile after navigation
|
||||
if (window.innerWidth < 1024) {
|
||||
if (window.innerWidth < 768) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
@@ -63,7 +71,7 @@ export function Sidebar({ className, onNavigate, isOpen, onClose }: SidebarProps
|
||||
{/* Mobile Backdrop */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 backdrop-blur-sm z-40 lg:hidden"
|
||||
className="fixed inset-0 backdrop-blur-sm z-40 md:hidden"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
@@ -71,54 +79,126 @@ export function Sidebar({ className, onNavigate, isOpen, onClose }: SidebarProps
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed lg:static inset-y-0 left-0 z-50 w-64 bg-background border-r flex flex-col h-full lg:h-[calc(100vh-4.5rem)] transition-transform duration-200 ease-in-out lg:translate-x-0",
|
||||
"fixed md:static inset-y-0 left-0 z-50 bg-background border-r flex flex-col h-full md:h-[calc(100vh-4.5rem)] transition-all duration-200 ease-in-out md:translate-x-0",
|
||||
isOpen ? "translate-x-0" : "-translate-x-full",
|
||||
isCollapsed ? "md:w-20 xl:w-64" : "w-64",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
<nav className="flex flex-col gap-y-1 lg:gap-y-1 pl-2 pr-3 pt-4 flex-shrink-0">
|
||||
<nav className={cn(
|
||||
"flex flex-col pt-4 flex-shrink-0",
|
||||
isCollapsed
|
||||
? "md:gap-y-2 md:items-center md:px-2 xl:gap-y-1 xl:items-stretch xl:pl-2 xl:pr-3 gap-y-1 pl-2 pr-3"
|
||||
: "gap-y-1 pl-2 pr-3"
|
||||
)}>
|
||||
{links.map((link, index) => {
|
||||
const isActive = currentPath === link.href;
|
||||
const Icon = link.icon;
|
||||
|
||||
return (
|
||||
|
||||
const button = (
|
||||
<button
|
||||
key={index}
|
||||
onClick={(e) => handleNavigation(link.href, e)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-3 lg:py-2 text-sm lg:text-sm font-medium transition-colors w-full text-left",
|
||||
"flex items-center rounded-md text-sm font-medium transition-colors w-full",
|
||||
isCollapsed
|
||||
? "md:h-12 md:w-12 md:justify-center md:p-0 xl:h-auto xl:w-full xl:justify-start xl:px-3 xl:py-2 h-auto px-3 py-3"
|
||||
: "px-3 py-3 md:py-2",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5 lg:h-4 lg:w-4" />
|
||||
{link.label}
|
||||
<Icon className={cn(
|
||||
"flex-shrink-0",
|
||||
isCollapsed
|
||||
? "md:h-5 md:w-5 md:mr-0 xl:h-4 xl:w-4 xl:mr-3 h-5 w-5 mr-3"
|
||||
: "h-5 w-5 md:h-4 md:w-4 mr-3"
|
||||
)} />
|
||||
<span className={cn(
|
||||
"transition-all duration-200",
|
||||
isCollapsed ? "md:hidden xl:inline" : "inline"
|
||||
)}>
|
||||
{link.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
// Wrap in tooltip when collapsed on medium screens
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<TooltipProvider key={index}>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
{button}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="hidden md:block xl:hidden">
|
||||
{link.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex-1 min-h-0" />
|
||||
|
||||
<div className="px-4 py-4 flex-shrink-0">
|
||||
<div className="rounded-md bg-muted p-3 lg:p-3">
|
||||
<h4 className="text-sm font-medium mb-2">Need Help?</h4>
|
||||
<p className="text-xs text-muted-foreground mb-3 lg:mb-2">
|
||||
Check out the documentation for help with setup and configuration.
|
||||
</p>
|
||||
<a
|
||||
href="/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs lg:text-xs text-primary hover:underline py-2 lg:py-0"
|
||||
>
|
||||
Documentation
|
||||
<ExternalLink className="h-3.5 w-3.5 lg:h-3 lg:w-3" />
|
||||
</a>
|
||||
<div className={cn(
|
||||
"py-4 flex-shrink-0",
|
||||
isCollapsed ? "md:px-2 xl:px-4 px-4" : "px-4"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"rounded-md bg-muted transition-all duration-200",
|
||||
isCollapsed ? "md:p-0 xl:p-3 p-3" : "p-3"
|
||||
)}>
|
||||
<div className={cn(
|
||||
isCollapsed ? "md:hidden xl:block" : "block"
|
||||
)}>
|
||||
<h4 className="text-sm font-medium mb-2">Need Help?</h4>
|
||||
<p className="text-xs text-muted-foreground mb-3 md:mb-2">
|
||||
Check out the documentation for help with setup and configuration.
|
||||
</p>
|
||||
<a
|
||||
href="/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs md:text-xs text-primary hover:underline py-2 md:py-0"
|
||||
>
|
||||
Documentation
|
||||
<ExternalLink className="h-3.5 w-3.5 md:h-3 md:w-3" />
|
||||
</a>
|
||||
</div>
|
||||
{/* Icon-only help button for collapsed state on medium screens */}
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href="/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md hover:bg-accent transition-colors",
|
||||
isCollapsed ? "md:h-12 md:w-12 xl:hidden hidden" : "hidden"
|
||||
)}
|
||||
>
|
||||
<ExternalLink className="h-5 w-5" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
Documentation
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className={cn(
|
||||
isCollapsed ? "md:hidden xl:block" : "block"
|
||||
)}>
|
||||
<VersionInfo />
|
||||
</div>
|
||||
<VersionInfo />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -228,17 +228,17 @@ export function OrganizationList({
|
||||
{(() => {
|
||||
const parts = [];
|
||||
if (org.publicRepositoryCount && org.publicRepositoryCount > 0) {
|
||||
parts.push(`${org.publicRepositoryCount}pub`);
|
||||
parts.push(`${org.publicRepositoryCount} pub`);
|
||||
}
|
||||
if (org.privateRepositoryCount && org.privateRepositoryCount > 0) {
|
||||
parts.push(`${org.privateRepositoryCount}priv`);
|
||||
parts.push(`${org.privateRepositoryCount} priv`);
|
||||
}
|
||||
if (org.forkRepositoryCount && org.forkRepositoryCount > 0) {
|
||||
parts.push(`${org.forkRepositoryCount}fork`);
|
||||
parts.push(`${org.forkRepositoryCount} fork`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? (
|
||||
<span className="ml-1">({parts.join('/')})</span>
|
||||
<span className="ml-1">({parts.join(' | ')})</span>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock, Ban, Check,
|
||||
import { SiGithub, SiGitea } from "react-icons/si";
|
||||
import type { Repository } from "@/lib/db/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate, getStatusColor } from "@/lib/utils";
|
||||
import { formatDate, formatLastSyncTime, getStatusColor } from "@/lib/utils";
|
||||
import type { FilterParams } from "@/types/filter";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||
@@ -242,7 +242,7 @@ export default function RepositoryTable({
|
||||
{repo.status}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{repo.lastMirrored ? formatDate(repo.lastMirrored) : "Never mirrored"}
|
||||
{formatLastSyncTime(repo.lastMirrored)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -410,7 +410,7 @@ export default function RepositoryTable({
|
||||
<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]">
|
||||
<div className="h-full py-3 text-sm font-medium flex-[2.3]">
|
||||
Repository
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
|
||||
@@ -437,7 +437,7 @@ export default function RepositoryTable({
|
||||
<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 flex-[2.5]">
|
||||
<div className="h-full p-3 flex-[2.3]">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-3 w-24 mt-1" />
|
||||
</div>
|
||||
@@ -530,7 +530,7 @@ export default function RepositoryTable({
|
||||
aria-label="Select all repositories"
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
|
||||
<div className="h-full py-3 text-sm font-medium flex-[2.3]">
|
||||
Repository
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
|
||||
@@ -588,7 +588,7 @@ export default function RepositoryTable({
|
||||
</div>
|
||||
|
||||
{/* Repository */}
|
||||
<div className="h-full py-3 flex items-center gap-2 flex-[2.5]">
|
||||
<div className="h-full py-3 flex items-center gap-2 flex-[2.3]">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium flex items-center gap-1">
|
||||
{repo.name}
|
||||
@@ -629,9 +629,7 @@ export default function RepositoryTable({
|
||||
{/* Last Mirrored */}
|
||||
<div className="h-full p-3 flex items-center flex-[1]">
|
||||
<p className="text-sm">
|
||||
{repo.lastMirrored
|
||||
? formatDate(new Date(repo.lastMirrored))
|
||||
: "Never"}
|
||||
{formatLastSyncTime(repo.lastMirrored)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
30
src/components/ui/progress.tsx
Normal file
30
src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ProgressProps extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
|
||||
indicatorClassName?: string
|
||||
}
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
ProgressProps
|
||||
>(({ className, value, indicatorClassName, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
@@ -4,9 +4,35 @@ import { ssoClient } from "@better-auth/sso/client";
|
||||
import type { Session as BetterAuthSession, User as BetterAuthUser } from "better-auth";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
// The base URL is optional when running on the same domain
|
||||
// Better Auth will use the current domain by default
|
||||
baseURL: typeof window !== 'undefined' ? window.location.origin : 'http://localhost:4321',
|
||||
// Use PUBLIC_BETTER_AUTH_URL if set (for multi-origin access), otherwise use current origin
|
||||
// This allows the client to connect to the auth server even when accessed from different origins
|
||||
baseURL: (() => {
|
||||
let url: string | undefined;
|
||||
|
||||
// Check for public environment variable first (for client-side access)
|
||||
if (typeof import.meta !== 'undefined' && import.meta.env?.PUBLIC_BETTER_AUTH_URL) {
|
||||
url = import.meta.env.PUBLIC_BETTER_AUTH_URL;
|
||||
}
|
||||
|
||||
// Validate and clean the URL if provided
|
||||
if (url && typeof url === 'string' && url.trim() !== '') {
|
||||
try {
|
||||
// Validate URL format and remove trailing slash
|
||||
const validatedUrl = new URL(url.trim());
|
||||
return validatedUrl.origin; // Use origin to ensure clean URL without path
|
||||
} catch (e) {
|
||||
console.warn(`Invalid PUBLIC_BETTER_AUTH_URL: ${url}, falling back to default`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to current origin if running in browser
|
||||
if (typeof window !== 'undefined' && window.location?.origin) {
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
// Default for SSR - always return a valid URL
|
||||
return 'http://localhost:4321';
|
||||
})(),
|
||||
basePath: '/api/auth', // Explicitly set the base path
|
||||
plugins: [
|
||||
oidcClient(),
|
||||
|
||||
@@ -19,42 +19,71 @@ export const auth = betterAuth({
|
||||
|
||||
// Base URL configuration - use the primary URL (Better Auth only supports single baseURL)
|
||||
baseURL: (() => {
|
||||
const url = process.env.BETTER_AUTH_URL || "http://localhost:4321";
|
||||
const url = process.env.BETTER_AUTH_URL;
|
||||
const defaultUrl = "http://localhost:4321";
|
||||
|
||||
// Check if URL is provided and not empty
|
||||
if (!url || typeof url !== 'string' || url.trim() === '') {
|
||||
console.info('BETTER_AUTH_URL not set, using default:', defaultUrl);
|
||||
return defaultUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate URL format
|
||||
new URL(url);
|
||||
return url;
|
||||
} catch {
|
||||
console.warn(`Invalid BETTER_AUTH_URL: ${url}, falling back to localhost`);
|
||||
return "http://localhost:4321";
|
||||
// Validate URL format and ensure it's a proper origin
|
||||
const validatedUrl = new URL(url.trim());
|
||||
const cleanUrl = validatedUrl.origin; // Use origin to ensure no trailing paths
|
||||
console.info('Using BETTER_AUTH_URL:', cleanUrl);
|
||||
return cleanUrl;
|
||||
} catch (e) {
|
||||
console.error(`Invalid BETTER_AUTH_URL format: "${url}"`);
|
||||
console.error('Error:', e);
|
||||
console.info('Falling back to default:', defaultUrl);
|
||||
return defaultUrl;
|
||||
}
|
||||
})(),
|
||||
basePath: "/api/auth", // Specify the base path for auth endpoints
|
||||
|
||||
// Trusted origins - this is how we support multiple access URLs
|
||||
trustedOrigins: (() => {
|
||||
const origins = [
|
||||
const origins: string[] = [
|
||||
"http://localhost:4321",
|
||||
"http://localhost:8080", // Keycloak
|
||||
];
|
||||
|
||||
// Add the primary URL from BETTER_AUTH_URL
|
||||
const primaryUrl = process.env.BETTER_AUTH_URL || "http://localhost:4321";
|
||||
try {
|
||||
new URL(primaryUrl);
|
||||
origins.push(primaryUrl);
|
||||
} catch {
|
||||
// Skip if invalid
|
||||
const primaryUrl = process.env.BETTER_AUTH_URL;
|
||||
if (primaryUrl && typeof primaryUrl === 'string' && primaryUrl.trim() !== '') {
|
||||
try {
|
||||
const validatedUrl = new URL(primaryUrl.trim());
|
||||
origins.push(validatedUrl.origin);
|
||||
} catch {
|
||||
// Skip if invalid
|
||||
}
|
||||
}
|
||||
|
||||
// Add additional trusted origins from environment
|
||||
// This is where users can specify multiple access URLs
|
||||
if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) {
|
||||
origins.push(...process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(',').map(o => o.trim()));
|
||||
const additionalOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
||||
.split(',')
|
||||
.map(o => o.trim())
|
||||
.filter(o => o !== '');
|
||||
|
||||
// Validate each additional origin
|
||||
for (const origin of additionalOrigins) {
|
||||
try {
|
||||
const validatedUrl = new URL(origin);
|
||||
origins.push(validatedUrl.origin);
|
||||
} catch {
|
||||
console.warn(`Invalid trusted origin: ${origin}, skipping`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates and return
|
||||
return [...new Set(origins.filter(Boolean))];
|
||||
// Remove duplicates and empty strings, then return
|
||||
const uniqueOrigins = [...new Set(origins.filter(Boolean))];
|
||||
console.info('Trusted origins:', uniqueOrigins);
|
||||
return uniqueOrigins;
|
||||
})(),
|
||||
|
||||
// Authentication methods
|
||||
|
||||
@@ -82,5 +82,6 @@ export {
|
||||
oauthApplications,
|
||||
oauthAccessTokens,
|
||||
oauthConsent,
|
||||
ssoProviders
|
||||
ssoProviders,
|
||||
rateLimits
|
||||
} from "./schema";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
|
||||
import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
// ===== Zod Validation Schemas =====
|
||||
@@ -27,7 +27,9 @@ export const githubConfigSchema = z.object({
|
||||
starredReposOrg: z.string().optional(),
|
||||
mirrorStrategy: z.enum(["preserve", "single-org", "flat-user", "mixed"]).default("preserve"),
|
||||
defaultOrg: z.string().optional(),
|
||||
skipStarredIssues: z.boolean().default(false),
|
||||
starredCodeOnly: z.boolean().default(false),
|
||||
skipStarredIssues: z.boolean().optional(), // Deprecated: kept for backward compatibility, use starredCodeOnly instead
|
||||
starredDuplicateStrategy: z.enum(["suffix", "prefix", "owner-org"]).default("suffix").optional(),
|
||||
});
|
||||
|
||||
export const giteaConfigSchema = z.object({
|
||||
@@ -80,6 +82,8 @@ export const scheduleConfigSchema = z.object({
|
||||
updateInterval: z.number().default(86400000),
|
||||
skipRecentlyMirrored: z.boolean().default(true),
|
||||
recentThreshold: z.number().default(3600000),
|
||||
autoImport: z.boolean().default(true),
|
||||
autoMirror: z.boolean().default(false),
|
||||
lastRun: z.coerce.date().optional(),
|
||||
nextRun: z.coerce.date().optional(),
|
||||
});
|
||||
@@ -151,6 +155,7 @@ export const repositorySchema = z.object({
|
||||
"deleted",
|
||||
"syncing",
|
||||
"synced",
|
||||
"archived",
|
||||
])
|
||||
.default("imported"),
|
||||
lastMirrored: z.coerce.date().optional().nullable(),
|
||||
@@ -180,6 +185,7 @@ export const mirrorJobSchema = z.object({
|
||||
"deleted",
|
||||
"syncing",
|
||||
"synced",
|
||||
"archived",
|
||||
])
|
||||
.default("imported"),
|
||||
message: z.string(),
|
||||
@@ -202,7 +208,7 @@ export const organizationSchema = z.object({
|
||||
configId: z.string(),
|
||||
name: z.string(),
|
||||
avatarUrl: z.string(),
|
||||
membershipRole: z.enum(["admin", "member", "owner"]).default("member"),
|
||||
membershipRole: z.enum(["member", "admin", "owner", "billing_manager"]).default("member"),
|
||||
isIncluded: z.boolean().default(true),
|
||||
destinationOrg: z.string().optional().nullable(),
|
||||
status: z
|
||||
@@ -379,6 +385,7 @@ export const repositories = sqliteTable("repositories", {
|
||||
index("idx_repositories_organization").on(table.organization),
|
||||
index("idx_repositories_is_fork").on(table.isForked),
|
||||
index("idx_repositories_is_starred").on(table.isStarred),
|
||||
uniqueIndex("uniq_repositories_user_full_name").on(table.userId, table.fullName),
|
||||
]);
|
||||
|
||||
export const mirrorJobs = sqliteTable("mirror_jobs", {
|
||||
@@ -445,6 +452,9 @@ export const organizations = sqliteTable("organizations", {
|
||||
errorMessage: text("error_message"),
|
||||
|
||||
repositoryCount: integer("repository_count").notNull().default(0),
|
||||
publicRepositoryCount: integer("public_repository_count"),
|
||||
privateRepositoryCount: integer("private_repository_count"),
|
||||
forkRepositoryCount: integer("fork_repository_count"),
|
||||
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
@@ -623,10 +633,52 @@ export const ssoProviders = sqliteTable("sso_providers", {
|
||||
index("idx_sso_providers_issuer").on(table.issuer),
|
||||
]);
|
||||
|
||||
// ===== Rate Limit Tracking =====
|
||||
|
||||
export const rateLimitSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
provider: z.enum(["github", "gitea"]).default("github"),
|
||||
limit: z.number(),
|
||||
remaining: z.number(),
|
||||
used: z.number(),
|
||||
reset: z.coerce.date(),
|
||||
retryAfter: z.number().optional(), // seconds to wait
|
||||
status: z.enum(["ok", "warning", "limited", "exceeded"]).default("ok"),
|
||||
lastChecked: z.coerce.date(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
});
|
||||
|
||||
export const rateLimits = sqliteTable("rate_limits", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
provider: text("provider").notNull().default("github"),
|
||||
limit: integer("limit").notNull(),
|
||||
remaining: integer("remaining").notNull(),
|
||||
used: integer("used").notNull(),
|
||||
reset: integer("reset", { mode: "timestamp" }).notNull(),
|
||||
retryAfter: integer("retry_after"), // seconds to wait
|
||||
status: text("status").notNull().default("ok"),
|
||||
lastChecked: integer("last_checked", { mode: "timestamp" }).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
}, (table) => [
|
||||
index("idx_rate_limits_user_provider").on(table.userId, table.provider),
|
||||
index("idx_rate_limits_status").on(table.status),
|
||||
]);
|
||||
|
||||
// Export type definitions
|
||||
export type User = z.infer<typeof userSchema>;
|
||||
export type Config = z.infer<typeof configSchema>;
|
||||
export type Repository = z.infer<typeof repositorySchema>;
|
||||
export type MirrorJob = z.infer<typeof mirrorJobSchema>;
|
||||
export type Organization = z.infer<typeof organizationSchema>;
|
||||
export type Event = z.infer<typeof eventSchema>;
|
||||
export type Event = z.infer<typeof eventSchema>;
|
||||
export type RateLimit = z.infer<typeof rateLimitSchema>;
|
||||
|
||||
@@ -21,7 +21,7 @@ interface EnvConfig {
|
||||
mirrorOrganizations?: boolean;
|
||||
preserveOrgStructure?: boolean;
|
||||
onlyMirrorOrgs?: boolean;
|
||||
skipStarredIssues?: boolean;
|
||||
starredCodeOnly?: boolean;
|
||||
starredReposOrg?: string;
|
||||
mirrorStrategy?: 'preserve' | 'single-org' | 'flat-user' | 'mixed';
|
||||
};
|
||||
@@ -69,6 +69,8 @@ interface EnvConfig {
|
||||
updateInterval?: number;
|
||||
skipRecentlyMirrored?: boolean;
|
||||
recentThreshold?: number;
|
||||
autoImport?: boolean;
|
||||
autoMirror?: boolean;
|
||||
};
|
||||
cleanup: {
|
||||
enabled?: boolean;
|
||||
@@ -105,7 +107,7 @@ function parseEnvConfig(): EnvConfig {
|
||||
mirrorOrganizations: process.env.MIRROR_ORGANIZATIONS === 'true',
|
||||
preserveOrgStructure: process.env.PRESERVE_ORG_STRUCTURE === 'true',
|
||||
onlyMirrorOrgs: process.env.ONLY_MIRROR_ORGS === 'true',
|
||||
skipStarredIssues: process.env.SKIP_STARRED_ISSUES === 'true',
|
||||
starredCodeOnly: process.env.SKIP_STARRED_ISSUES === 'true',
|
||||
starredReposOrg: process.env.STARRED_REPOS_ORG,
|
||||
mirrorStrategy: process.env.MIRROR_STRATEGY as 'preserve' | 'single-org' | 'flat-user' | 'mixed',
|
||||
},
|
||||
@@ -133,6 +135,7 @@ function parseEnvConfig(): EnvConfig {
|
||||
mirrorLabels: process.env.MIRROR_LABELS === 'true',
|
||||
mirrorMilestones: process.env.MIRROR_MILESTONES === 'true',
|
||||
mirrorMetadata: process.env.MIRROR_METADATA === 'true',
|
||||
releaseLimit: process.env.RELEASE_LIMIT ? parseInt(process.env.RELEASE_LIMIT, 10) : undefined,
|
||||
},
|
||||
schedule: {
|
||||
enabled: process.env.SCHEDULE_ENABLED === 'true' ||
|
||||
@@ -156,6 +159,8 @@ function parseEnvConfig(): EnvConfig {
|
||||
updateInterval: process.env.SCHEDULE_UPDATE_INTERVAL ? parseInt(process.env.SCHEDULE_UPDATE_INTERVAL, 10) : undefined,
|
||||
skipRecentlyMirrored: process.env.SCHEDULE_SKIP_RECENTLY_MIRRORED === 'true',
|
||||
recentThreshold: process.env.SCHEDULE_RECENT_THRESHOLD ? parseInt(process.env.SCHEDULE_RECENT_THRESHOLD, 10) : undefined,
|
||||
autoImport: process.env.AUTO_IMPORT_REPOS !== 'false',
|
||||
autoMirror: process.env.AUTO_MIRROR_REPOS === 'true',
|
||||
},
|
||||
cleanup: {
|
||||
enabled: process.env.CLEANUP_ENABLED === 'true' ||
|
||||
@@ -248,7 +253,7 @@ export async function initializeConfigFromEnv(): Promise<void> {
|
||||
starredReposOrg: envConfig.github.starredReposOrg || existingConfig?.[0]?.githubConfig?.starredReposOrg || 'starred',
|
||||
mirrorStrategy,
|
||||
defaultOrg: envConfig.gitea.organization || existingConfig?.[0]?.githubConfig?.defaultOrg || 'github-mirrors',
|
||||
skipStarredIssues: envConfig.github.skipStarredIssues ?? existingConfig?.[0]?.githubConfig?.skipStarredIssues ?? false,
|
||||
starredCodeOnly: envConfig.github.starredCodeOnly ?? existingConfig?.[0]?.githubConfig?.starredCodeOnly ?? false,
|
||||
};
|
||||
|
||||
// Build Gitea config
|
||||
@@ -271,6 +276,7 @@ export async function initializeConfigFromEnv(): Promise<void> {
|
||||
forkStrategy: envConfig.gitea.forkStrategy || existingConfig?.[0]?.giteaConfig?.forkStrategy || 'reference',
|
||||
// Mirror metadata options
|
||||
mirrorReleases: envConfig.mirror.mirrorReleases ?? existingConfig?.[0]?.giteaConfig?.mirrorReleases ?? false,
|
||||
releaseLimit: envConfig.mirror.releaseLimit ?? existingConfig?.[0]?.giteaConfig?.releaseLimit ?? 10,
|
||||
mirrorMetadata: envConfig.mirror.mirrorMetadata ?? (envConfig.mirror.mirrorIssues || envConfig.mirror.mirrorPullRequests || envConfig.mirror.mirrorLabels || envConfig.mirror.mirrorMilestones) ?? existingConfig?.[0]?.giteaConfig?.mirrorMetadata ?? false,
|
||||
mirrorIssues: envConfig.mirror.mirrorIssues ?? existingConfig?.[0]?.giteaConfig?.mirrorIssues ?? false,
|
||||
mirrorPullRequests: envConfig.mirror.mirrorPullRequests ?? existingConfig?.[0]?.giteaConfig?.mirrorPullRequests ?? false,
|
||||
@@ -299,6 +305,8 @@ export async function initializeConfigFromEnv(): Promise<void> {
|
||||
updateInterval: envConfig.schedule.updateInterval ?? existingConfig?.[0]?.scheduleConfig?.updateInterval ?? 86400000,
|
||||
skipRecentlyMirrored: envConfig.schedule.skipRecentlyMirrored ?? existingConfig?.[0]?.scheduleConfig?.skipRecentlyMirrored ?? true,
|
||||
recentThreshold: envConfig.schedule.recentThreshold ?? existingConfig?.[0]?.scheduleConfig?.recentThreshold ?? 3600000,
|
||||
autoImport: envConfig.schedule.autoImport ?? existingConfig?.[0]?.scheduleConfig?.autoImport ?? true,
|
||||
autoMirror: envConfig.schedule.autoMirror ?? existingConfig?.[0]?.scheduleConfig?.autoMirror ?? false,
|
||||
lastRun: existingConfig?.[0]?.scheduleConfig?.lastRun || undefined,
|
||||
nextRun: existingConfig?.[0]?.scheduleConfig?.nextRun || undefined,
|
||||
};
|
||||
@@ -356,4 +364,4 @@ export async function initializeConfigFromEnv(): Promise<void> {
|
||||
console.error('[ENV Config Loader] Failed to initialize configuration from environment:', error);
|
||||
// Don't throw - this is a non-critical initialization
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { Config } from "@/types/config";
|
||||
import type { Repository } from "./db/schema";
|
||||
import { createMirrorJob } from "./helpers";
|
||||
import { decryptConfigTokens } from "./utils/config-encryption";
|
||||
import { httpPost, httpGet, HttpError } from "./http-client";
|
||||
import { httpPost, httpGet, httpPatch, HttpError } from "./http-client";
|
||||
import { db, repositories } from "./db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { repoStatusEnum } from "@/types/Repository";
|
||||
@@ -299,6 +299,23 @@ export async function syncGiteaRepoEnhanced({
|
||||
throw new Error(`Repository ${repository.name} is not a mirror. Cannot sync.`);
|
||||
}
|
||||
|
||||
// Update mirror interval if needed
|
||||
if (config.giteaConfig?.mirrorInterval) {
|
||||
try {
|
||||
console.log(`[Sync] Updating mirror interval for ${repository.name} to ${config.giteaConfig.mirrorInterval}`);
|
||||
const updateUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repository.name}`;
|
||||
await httpPatch(updateUrl, {
|
||||
mirror_interval: config.giteaConfig.mirrorInterval,
|
||||
}, {
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
});
|
||||
console.log(`[Sync] Successfully updated mirror interval for ${repository.name}`);
|
||||
} catch (updateError) {
|
||||
console.warn(`[Sync] Failed to update mirror interval for ${repository.name}:`, updateError);
|
||||
// Continue with sync even if interval update fails
|
||||
}
|
||||
}
|
||||
|
||||
// Perform the sync
|
||||
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repository.name}/mirror-sync`;
|
||||
|
||||
|
||||
@@ -8,10 +8,19 @@ import { createMockResponse, mockFetch } from "@/tests/mock-fetch";
|
||||
// Mock the isRepoPresentInGitea function
|
||||
const mockIsRepoPresentInGitea = mock(() => Promise.resolve(false));
|
||||
|
||||
let mockDbSelectResult: any[] = [];
|
||||
|
||||
// Mock the database module
|
||||
mock.module("@/lib/db", () => {
|
||||
return {
|
||||
db: {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: () => Promise.resolve(mockDbSelectResult)
|
||||
})
|
||||
})
|
||||
}),
|
||||
update: () => ({
|
||||
set: () => ({
|
||||
where: () => Promise.resolve()
|
||||
@@ -63,6 +72,7 @@ describe("Gitea Repository Mirroring", () => {
|
||||
originalConsoleError = console.error;
|
||||
console.log = mock(() => {});
|
||||
console.error = mock(() => {});
|
||||
mockDbSelectResult = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -331,7 +341,7 @@ describe("getGiteaRepoOwner - Organization Override Tests", () => {
|
||||
excludeOrgs: [],
|
||||
mirrorPublicOrgs: false,
|
||||
publicOrgs: [],
|
||||
skipStarredIssues: false,
|
||||
starredCodeOnly: false,
|
||||
mirrorStrategy: "preserve"
|
||||
},
|
||||
giteaConfig: {
|
||||
@@ -449,4 +459,37 @@ describe("getGiteaRepoOwner - Organization Override Tests", () => {
|
||||
const result = getGiteaRepoOwner({ config: configWithFlatUser, repository: repo });
|
||||
expect(result).toBe("giteauser");
|
||||
});
|
||||
|
||||
test("getGiteaRepoOwnerAsync honors organization override for owner role", async () => {
|
||||
mockDbSelectResult = [
|
||||
{
|
||||
id: "org-id",
|
||||
userId: "user-id",
|
||||
configId: "config-id",
|
||||
name: "myorg",
|
||||
membershipRole: "owner",
|
||||
status: "imported",
|
||||
destinationOrg: "custom-org",
|
||||
avatarUrl: "https://example.com/avatar.png",
|
||||
isIncluded: true,
|
||||
repositoryCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
];
|
||||
|
||||
const configWithUser: Partial<Config> = {
|
||||
...baseConfig,
|
||||
userId: "user-id"
|
||||
};
|
||||
|
||||
const repo = { ...baseRepo, organization: "myorg" };
|
||||
|
||||
const result = await getGiteaRepoOwnerAsync({
|
||||
config: configWithUser,
|
||||
repository: repo
|
||||
});
|
||||
|
||||
expect(result).toBe("custom-org");
|
||||
});
|
||||
});
|
||||
|
||||
863
src/lib/gitea.ts
863
src/lib/gitea.ts
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,179 @@
|
||||
import type { GitOrg, MembershipRole } from "@/types/organizations";
|
||||
import type { GitRepo, RepoStatus } from "@/types/Repository";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { throttling } from "@octokit/plugin-throttling";
|
||||
import type { Config } from "@/types/config";
|
||||
// Conditionally import rate limit manager (not available in test environment)
|
||||
let RateLimitManager: any = null;
|
||||
let publishEvent: any = null;
|
||||
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
try {
|
||||
const rateLimitModule = await import("@/lib/rate-limit-manager");
|
||||
RateLimitManager = rateLimitModule.RateLimitManager;
|
||||
const eventsModule = await import("@/lib/events");
|
||||
publishEvent = eventsModule.publishEvent;
|
||||
} catch (error) {
|
||||
console.warn("Rate limit manager not available:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Extend Octokit with throttling plugin when available (tests may stub Octokit)
|
||||
// Fallback to base Octokit if .plugin is not present
|
||||
const MyOctokit: any = (Octokit as any)?.plugin?.call
|
||||
? (Octokit as any).plugin(throttling)
|
||||
: Octokit as any;
|
||||
|
||||
/**
|
||||
* Creates an authenticated Octokit instance
|
||||
* Creates an authenticated Octokit instance with rate limit tracking and throttling
|
||||
*/
|
||||
export function createGitHubClient(token: string): Octokit {
|
||||
return new Octokit({
|
||||
auth: token,
|
||||
export function createGitHubClient(token: string, userId?: string, username?: string): Octokit {
|
||||
// Create a proper User-Agent to identify our application
|
||||
// This helps GitHub understand our traffic patterns and can provide better rate limits
|
||||
const userAgent = username
|
||||
? `gitea-mirror/3.5.4 (user:${username})`
|
||||
: "gitea-mirror/3.5.4";
|
||||
|
||||
const octokit = new MyOctokit({
|
||||
auth: token, // Always use token for authentication (5000 req/hr vs 60 for unauthenticated)
|
||||
userAgent, // Identify our application and user
|
||||
baseUrl: "https://api.github.com", // Explicitly set the API endpoint
|
||||
log: {
|
||||
debug: () => {},
|
||||
info: console.log,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
},
|
||||
request: {
|
||||
// Add default headers for better identification
|
||||
headers: {
|
||||
accept: "application/vnd.github.v3+json",
|
||||
"x-github-api-version": "2022-11-28", // Use a stable API version
|
||||
},
|
||||
},
|
||||
throttle: {
|
||||
onRateLimit: async (retryAfter: number, options: any, octokit: any, retryCount: number) => {
|
||||
const isSearch = options.url.includes("/search/");
|
||||
const maxRetries = isSearch ? 5 : 3; // Search endpoints get more retries
|
||||
|
||||
console.warn(
|
||||
`[GitHub] Rate limit hit for ${options.method} ${options.url}. Retry ${retryCount + 1}/${maxRetries}`
|
||||
);
|
||||
|
||||
// Update rate limit status and notify UI (if available)
|
||||
if (userId && RateLimitManager) {
|
||||
await RateLimitManager.updateFromResponse(userId, {
|
||||
"retry-after": retryAfter.toString(),
|
||||
"x-ratelimit-remaining": "0",
|
||||
"x-ratelimit-reset": (Date.now() / 1000 + retryAfter).toString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (userId && publishEvent) {
|
||||
await publishEvent({
|
||||
userId,
|
||||
channel: "rate-limit",
|
||||
payload: {
|
||||
type: "rate-limited",
|
||||
provider: "github",
|
||||
retryAfter,
|
||||
retryCount,
|
||||
endpoint: options.url,
|
||||
message: `Rate limit hit. Waiting ${retryAfter}s before retry ${retryCount + 1}/${maxRetries}...`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Retry with exponential backoff
|
||||
if (retryCount < maxRetries) {
|
||||
console.log(`[GitHub] Waiting ${retryAfter}s before retry...`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Max retries reached
|
||||
console.error(`[GitHub] Max retries (${maxRetries}) reached for ${options.url}`);
|
||||
return false;
|
||||
},
|
||||
onSecondaryRateLimit: async (retryAfter: number, options: any, octokit: any, retryCount: number) => {
|
||||
console.warn(
|
||||
`[GitHub] Secondary rate limit hit for ${options.method} ${options.url}`
|
||||
);
|
||||
|
||||
// Update status and notify UI (if available)
|
||||
if (userId && publishEvent) {
|
||||
await publishEvent({
|
||||
userId,
|
||||
channel: "rate-limit",
|
||||
payload: {
|
||||
type: "secondary-limited",
|
||||
provider: "github",
|
||||
retryAfter,
|
||||
retryCount,
|
||||
endpoint: options.url,
|
||||
message: `Secondary rate limit hit. Waiting ${retryAfter}s...`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Retry up to 2 times for secondary rate limits
|
||||
if (retryCount < 2) {
|
||||
console.log(`[GitHub] Waiting ${retryAfter}s for secondary rate limit...`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
// Throttle options to prevent hitting limits
|
||||
fallbackSecondaryRateRetryAfter: 60, // Wait 60s on secondary rate limit
|
||||
minimumSecondaryRateRetryAfter: 5, // Min 5s wait
|
||||
retryAfterBaseValue: 1000, // Base retry in ms
|
||||
},
|
||||
});
|
||||
|
||||
// Add additional rate limit tracking if userId is provided and RateLimitManager is available
|
||||
if (userId && RateLimitManager) {
|
||||
octokit.hook.after("request", async (response: any, options: any) => {
|
||||
// Update rate limit from response headers
|
||||
if (response.headers) {
|
||||
await RateLimitManager.updateFromResponse(userId, response.headers);
|
||||
}
|
||||
});
|
||||
|
||||
octokit.hook.error("request", async (error: any, options: any) => {
|
||||
// Handle rate limit errors
|
||||
if (error.status === 403 || error.status === 429) {
|
||||
const message = error.message || "";
|
||||
|
||||
if (message.includes("rate limit") || message.includes("API rate limit")) {
|
||||
console.error(`[GitHub] Rate limit error for user ${userId}: ${message}`);
|
||||
|
||||
// Update rate limit status from error response (if available)
|
||||
if (error.response?.headers && RateLimitManager) {
|
||||
await RateLimitManager.updateFromResponse(userId, error.response.headers);
|
||||
}
|
||||
|
||||
// Create error event for UI (if available)
|
||||
if (publishEvent) {
|
||||
await publishEvent({
|
||||
userId,
|
||||
channel: "rate-limit",
|
||||
payload: {
|
||||
type: "error",
|
||||
provider: "github",
|
||||
error: message,
|
||||
endpoint: options.url,
|
||||
message: `Rate limit exceeded: ${message}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return octokit;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,6 +232,8 @@ export async function getGithubRepositories({
|
||||
owner: repo.owner.login,
|
||||
organization:
|
||||
repo.owner.type === "Organization" ? repo.owner.login : undefined,
|
||||
mirroredLocation: "",
|
||||
destinationOrg: null,
|
||||
|
||||
isPrivate: repo.private,
|
||||
isForked: repo.fork,
|
||||
@@ -82,6 +248,8 @@ export async function getGithubRepositories({
|
||||
hasLFS: false,
|
||||
hasSubmodules: false,
|
||||
|
||||
language: repo.language,
|
||||
description: repo.description,
|
||||
defaultBranch: repo.default_branch,
|
||||
visibility: (repo.visibility ?? "public") as GitRepo["visibility"],
|
||||
|
||||
@@ -125,6 +293,8 @@ export async function getGithubStarredRepositories({
|
||||
owner: repo.owner.login,
|
||||
organization:
|
||||
repo.owner.type === "Organization" ? repo.owner.login : undefined,
|
||||
mirroredLocation: "",
|
||||
destinationOrg: null,
|
||||
|
||||
isPrivate: repo.private,
|
||||
isForked: repo.fork,
|
||||
@@ -138,6 +308,8 @@ export async function getGithubStarredRepositories({
|
||||
hasLFS: false, // Placeholder
|
||||
hasSubmodules: false, // Placeholder
|
||||
|
||||
language: repo.language,
|
||||
description: repo.description,
|
||||
defaultBranch: repo.default_branch,
|
||||
visibility: (repo.visibility ?? "public") as GitRepo["visibility"],
|
||||
|
||||
@@ -244,6 +416,8 @@ export async function getGithubOrganizationRepositories({
|
||||
|
||||
owner: repo.owner.login,
|
||||
organization: repo.owner.login,
|
||||
mirroredLocation: "",
|
||||
destinationOrg: null,
|
||||
|
||||
isPrivate: repo.private,
|
||||
isForked: repo.fork,
|
||||
@@ -258,6 +432,8 @@ export async function getGithubOrganizationRepositories({
|
||||
hasLFS: false,
|
||||
hasSubmodules: false,
|
||||
|
||||
language: repo.language,
|
||||
description: repo.description,
|
||||
defaultBranch: repo.default_branch ?? "main",
|
||||
visibility: (repo.visibility ?? "public") as GitRepo["visibility"],
|
||||
|
||||
|
||||
@@ -178,6 +178,21 @@ export async function httpPut<T = any>(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH request
|
||||
*/
|
||||
export async function httpPatch<T = any>(
|
||||
url: string,
|
||||
body?: any,
|
||||
headers?: Record<string, string>
|
||||
): Promise<HttpResponse<T>> {
|
||||
return httpRequest<T>(url, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request
|
||||
*/
|
||||
@@ -220,6 +235,10 @@ export class GiteaHttpClient {
|
||||
return httpPut<T>(`${this.baseUrl}${endpoint}`, body, this.getHeaders());
|
||||
}
|
||||
|
||||
async patch<T = any>(endpoint: string, body?: any): Promise<HttpResponse<T>> {
|
||||
return httpPatch<T>(`${this.baseUrl}${endpoint}`, body, this.getHeaders());
|
||||
}
|
||||
|
||||
async delete<T = any>(endpoint: string): Promise<HttpResponse<T>> {
|
||||
return httpDelete<T>(`${this.baseUrl}${endpoint}`, this.getHeaders());
|
||||
}
|
||||
|
||||
422
src/lib/rate-limit-manager.ts
Normal file
422
src/lib/rate-limit-manager.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import { db, rateLimits } from "@/lib/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import { publishEvent } from "@/lib/events";
|
||||
|
||||
type RateLimitStatus = "ok" | "warning" | "limited" | "exceeded";
|
||||
|
||||
interface RateLimitInfo {
|
||||
limit: number;
|
||||
remaining: number;
|
||||
used: number;
|
||||
reset: Date;
|
||||
retryAfter?: number;
|
||||
status: RateLimitStatus;
|
||||
}
|
||||
|
||||
interface RateLimitHeaders {
|
||||
"x-ratelimit-limit"?: string;
|
||||
"x-ratelimit-remaining"?: string;
|
||||
"x-ratelimit-used"?: string;
|
||||
"x-ratelimit-reset"?: string;
|
||||
"retry-after"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit manager for GitHub API
|
||||
*
|
||||
* GitHub API Limits for authenticated users:
|
||||
* - Primary: 5,000 requests per hour
|
||||
* - Secondary: 900 points per minute (GET = 1 point, mutations = more)
|
||||
* - Concurrent: Maximum 100 concurrent requests (recommended: 5-20)
|
||||
*
|
||||
* For repositories with many issues/PRs:
|
||||
* - Each issue = 1 request to fetch
|
||||
* - Each PR = 1 request to fetch
|
||||
* - Comments = Additional requests per issue/PR
|
||||
* - Better to limit by total requests rather than repositories
|
||||
*/
|
||||
export class RateLimitManager {
|
||||
private static readonly WARNING_THRESHOLD = 0.2; // Warn when 20% remaining (80% used)
|
||||
private static readonly PAUSE_THRESHOLD = 0.05; // Pause when 5% remaining
|
||||
private static readonly MIN_REQUESTS_BUFFER = 100; // Keep at least 100 requests as buffer
|
||||
private static lastNotifiedThreshold: Map<string, number> = new Map(); // Track last notification per user
|
||||
|
||||
/**
|
||||
* Check current rate limit status from GitHub
|
||||
*/
|
||||
static async checkGitHubRateLimit(octokit: Octokit, userId: string): Promise<RateLimitInfo> {
|
||||
try {
|
||||
const { data } = await octokit.rateLimit.get();
|
||||
const core = data.rate;
|
||||
|
||||
const info: RateLimitInfo = {
|
||||
limit: core.limit,
|
||||
remaining: core.remaining,
|
||||
used: core.used,
|
||||
reset: new Date(core.reset * 1000),
|
||||
status: this.calculateStatus(core.remaining, core.limit),
|
||||
};
|
||||
|
||||
// Update database
|
||||
await this.updateRateLimit(userId, "github", info);
|
||||
|
||||
return info;
|
||||
} catch (error) {
|
||||
console.error("Failed to check GitHub rate limit:", error);
|
||||
// Return last known status from database if API check fails
|
||||
return await this.getLastKnownStatus(userId, "github");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract rate limit info from response headers
|
||||
*/
|
||||
static parseRateLimitHeaders(headers: RateLimitHeaders): Partial<RateLimitInfo> {
|
||||
const info: Partial<RateLimitInfo> = {};
|
||||
|
||||
if (headers["x-ratelimit-limit"]) {
|
||||
info.limit = parseInt(headers["x-ratelimit-limit"], 10);
|
||||
}
|
||||
if (headers["x-ratelimit-remaining"]) {
|
||||
info.remaining = parseInt(headers["x-ratelimit-remaining"], 10);
|
||||
}
|
||||
if (headers["x-ratelimit-used"]) {
|
||||
info.used = parseInt(headers["x-ratelimit-used"], 10);
|
||||
}
|
||||
if (headers["x-ratelimit-reset"]) {
|
||||
info.reset = new Date(parseInt(headers["x-ratelimit-reset"], 10) * 1000);
|
||||
}
|
||||
if (headers["retry-after"]) {
|
||||
info.retryAfter = parseInt(headers["retry-after"], 10);
|
||||
}
|
||||
|
||||
if (info.remaining !== undefined && info.limit !== undefined) {
|
||||
info.status = this.calculateStatus(info.remaining, info.limit);
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update rate limit info from API response
|
||||
*/
|
||||
static async updateFromResponse(userId: string, headers: RateLimitHeaders): Promise<void> {
|
||||
const info = this.parseRateLimitHeaders(headers);
|
||||
if (Object.keys(info).length > 0) {
|
||||
await this.updateRateLimit(userId, "github", info as RateLimitInfo);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate rate limit status based on remaining requests
|
||||
*/
|
||||
static calculateStatus(remaining: number, limit: number): RateLimitStatus {
|
||||
const ratio = remaining / limit;
|
||||
|
||||
if (remaining === 0) return "exceeded";
|
||||
if (remaining < this.MIN_REQUESTS_BUFFER || ratio < this.PAUSE_THRESHOLD) return "limited";
|
||||
if (ratio < this.WARNING_THRESHOLD) return "warning";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should pause operations
|
||||
*/
|
||||
static async shouldPause(userId: string, provider: "github" | "gitea" = "github"): Promise<boolean> {
|
||||
const status = await this.getLastKnownStatus(userId, provider);
|
||||
return status.status === "limited" || status.status === "exceeded";
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate wait time until rate limit resets
|
||||
*/
|
||||
static calculateWaitTime(reset: Date, retryAfter?: number): number {
|
||||
if (retryAfter) {
|
||||
return retryAfter * 1000; // Convert to milliseconds
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const waitTime = reset.getTime() - now.getTime();
|
||||
return Math.max(0, waitTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until rate limit resets
|
||||
*/
|
||||
static async waitForReset(userId: string, provider: "github" | "gitea" = "github"): Promise<void> {
|
||||
const status = await this.getLastKnownStatus(userId, provider);
|
||||
|
||||
if (status.status === "ok" || status.status === "warning") {
|
||||
return; // No need to wait
|
||||
}
|
||||
|
||||
const waitTime = this.calculateWaitTime(status.reset, status.retryAfter);
|
||||
|
||||
if (waitTime > 0) {
|
||||
console.log(`[RateLimit] Waiting ${Math.ceil(waitTime / 1000)}s for rate limit reset...`);
|
||||
|
||||
// Create event for UI notification
|
||||
await publishEvent({
|
||||
userId,
|
||||
channel: "rate-limit",
|
||||
payload: {
|
||||
type: "waiting",
|
||||
provider,
|
||||
waitTime,
|
||||
resetAt: status.reset,
|
||||
message: `API rate limit reached. Waiting ${Math.ceil(waitTime / 1000)} seconds before resuming...`,
|
||||
},
|
||||
});
|
||||
|
||||
// Wait
|
||||
await new Promise(resolve => setTimeout(resolve, waitTime));
|
||||
|
||||
// Update status after waiting
|
||||
await this.updateRateLimit(userId, provider, {
|
||||
...status,
|
||||
status: "ok",
|
||||
remaining: status.limit,
|
||||
used: 0,
|
||||
});
|
||||
|
||||
// Notify that we've resumed
|
||||
await publishEvent({
|
||||
userId,
|
||||
channel: "rate-limit",
|
||||
payload: {
|
||||
type: "resumed",
|
||||
provider,
|
||||
message: "Rate limit reset. Resuming operations...",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update rate limit info in database
|
||||
*/
|
||||
private static async updateRateLimit(
|
||||
userId: string,
|
||||
provider: "github" | "gitea",
|
||||
info: RateLimitInfo
|
||||
): Promise<void> {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(rateLimits)
|
||||
.where(and(eq(rateLimits.userId, userId), eq(rateLimits.provider, provider)))
|
||||
.limit(1);
|
||||
|
||||
const data = {
|
||||
userId,
|
||||
provider,
|
||||
limit: info.limit,
|
||||
remaining: info.remaining,
|
||||
used: info.used,
|
||||
reset: info.reset,
|
||||
retryAfter: info.retryAfter,
|
||||
status: info.status,
|
||||
lastChecked: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(rateLimits)
|
||||
.set(data)
|
||||
.where(eq(rateLimits.id, existing[0].id));
|
||||
} else {
|
||||
await db.insert(rateLimits).values({
|
||||
id: uuidv4(),
|
||||
...data,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
// Only send notifications at specific thresholds to avoid spam
|
||||
const usedPercentage = ((info.limit - info.remaining) / info.limit) * 100;
|
||||
const userKey = `${userId}-${provider}`;
|
||||
const lastNotified = this.lastNotifiedThreshold.get(userKey) || 0;
|
||||
|
||||
// Notify at 80% usage (20% remaining)
|
||||
if (usedPercentage >= 80 && usedPercentage < 100 && lastNotified < 80) {
|
||||
this.lastNotifiedThreshold.set(userKey, 80);
|
||||
await publishEvent({
|
||||
userId,
|
||||
channel: "rate-limit",
|
||||
payload: {
|
||||
type: "warning",
|
||||
provider,
|
||||
status: info.status,
|
||||
remaining: info.remaining,
|
||||
limit: info.limit,
|
||||
usedPercentage: Math.round(usedPercentage),
|
||||
message: `GitHub API rate limit at ${Math.round(usedPercentage)}%. ${info.remaining} requests remaining.`,
|
||||
},
|
||||
});
|
||||
console.log(`[RateLimit] 80% threshold reached for user ${userId}: ${info.remaining}/${info.limit} requests remaining`);
|
||||
}
|
||||
|
||||
// Notify at 100% usage (0 remaining)
|
||||
if (info.remaining === 0 && lastNotified < 100) {
|
||||
this.lastNotifiedThreshold.set(userKey, 100);
|
||||
const resetTime = new Date(info.reset);
|
||||
const minutesUntilReset = Math.ceil((resetTime.getTime() - Date.now()) / 60000);
|
||||
await publishEvent({
|
||||
userId,
|
||||
channel: "rate-limit",
|
||||
payload: {
|
||||
type: "exceeded",
|
||||
provider,
|
||||
status: "exceeded",
|
||||
remaining: 0,
|
||||
limit: info.limit,
|
||||
usedPercentage: 100,
|
||||
reset: info.reset,
|
||||
message: `GitHub API rate limit exceeded. Will automatically resume in ${minutesUntilReset} minutes.`,
|
||||
},
|
||||
});
|
||||
console.log(`[RateLimit] 100% rate limit exceeded for user ${userId}. Resets at ${resetTime.toLocaleTimeString()}`);
|
||||
}
|
||||
|
||||
// Reset notification threshold when rate limit resets
|
||||
if (info.remaining > info.limit * 0.5 && lastNotified > 0) {
|
||||
this.lastNotifiedThreshold.delete(userKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last known rate limit status from database
|
||||
*/
|
||||
private static async getLastKnownStatus(
|
||||
userId: string,
|
||||
provider: "github" | "gitea"
|
||||
): Promise<RateLimitInfo> {
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(rateLimits)
|
||||
.where(and(eq(rateLimits.userId, userId), eq(rateLimits.provider, provider)))
|
||||
.limit(1);
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
limit: result.limit,
|
||||
remaining: result.remaining,
|
||||
used: result.used,
|
||||
reset: result.reset,
|
||||
retryAfter: result.retryAfter ?? undefined,
|
||||
status: result.status as RateLimitStatus,
|
||||
};
|
||||
}
|
||||
|
||||
// Return default if no data
|
||||
return {
|
||||
limit: 5000,
|
||||
remaining: 5000,
|
||||
used: 0,
|
||||
reset: new Date(Date.now() + 3600000), // 1 hour from now
|
||||
status: "ok",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable status message
|
||||
*/
|
||||
private static getStatusMessage(info: RateLimitInfo): string {
|
||||
const percentage = Math.round((info.remaining / info.limit) * 100);
|
||||
|
||||
switch (info.status) {
|
||||
case "exceeded":
|
||||
return `API rate limit exceeded. Resets at ${info.reset.toLocaleTimeString()}.`;
|
||||
case "limited":
|
||||
return `API rate limit critical: Only ${info.remaining} requests remaining (${percentage}%). Pausing operations...`;
|
||||
case "warning":
|
||||
return `API rate limit warning: ${info.remaining} requests remaining (${percentage}%).`;
|
||||
default:
|
||||
return `API rate limit healthy: ${info.remaining}/${info.limit} requests remaining.`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart retry with exponential backoff for rate-limited requests
|
||||
*/
|
||||
static async retryWithBackoff<T>(
|
||||
fn: () => Promise<T>,
|
||||
userId: string,
|
||||
maxRetries: number = 3
|
||||
): Promise<T> {
|
||||
let lastError: any;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
// Check if we should pause before attempting
|
||||
if (await this.shouldPause(userId)) {
|
||||
await this.waitForReset(userId);
|
||||
}
|
||||
|
||||
return await fn();
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
|
||||
// Check if it's a rate limit error
|
||||
if (error.status === 403 && error.message?.includes("rate limit")) {
|
||||
console.log(`[RateLimit] Rate limit hit on attempt ${attempt + 1}/${maxRetries}`);
|
||||
|
||||
// Parse rate limit headers from error response if available
|
||||
if (error.response?.headers) {
|
||||
await this.updateFromResponse(userId, error.response.headers);
|
||||
}
|
||||
|
||||
// Wait for reset
|
||||
await this.waitForReset(userId);
|
||||
} else if (error.status === 429) {
|
||||
// Too Many Requests - use exponential backoff
|
||||
const backoffTime = Math.min(1000 * Math.pow(2, attempt), 30000); // Max 30s
|
||||
console.log(`[RateLimit] Too many requests, backing off ${backoffTime}ms`);
|
||||
await new Promise(resolve => setTimeout(resolve, backoffTime));
|
||||
} else {
|
||||
// Not a rate limit error, throw immediately
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to check rate limits before making API calls
|
||||
*/
|
||||
export async function withRateLimitCheck<T>(
|
||||
userId: string,
|
||||
operation: () => Promise<T>,
|
||||
operationName: string = "API call"
|
||||
): Promise<T> {
|
||||
// Check if we should pause
|
||||
if (await RateLimitManager.shouldPause(userId)) {
|
||||
console.log(`[RateLimit] Pausing ${operationName} due to rate limit`);
|
||||
await RateLimitManager.waitForReset(userId);
|
||||
}
|
||||
|
||||
// Execute with retry logic
|
||||
return await RateLimitManager.retryWithBackoff(operation, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to update rate limits from Octokit responses
|
||||
*/
|
||||
export function createOctokitRateLimitPlugin(userId: string) {
|
||||
return {
|
||||
hook: (request: any, options: any) => {
|
||||
return request(options).then((response: any) => {
|
||||
// Update rate limit from response headers
|
||||
if (response.headers) {
|
||||
RateLimitManager.updateFromResponse(userId, response.headers).catch(console.error);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -260,11 +260,13 @@ async function recoverMirrorJob(job: any, remainingItemIds: string[]) {
|
||||
throw new Error('GitHub token not found in configuration');
|
||||
}
|
||||
|
||||
// Create GitHub client with error handling
|
||||
// Create GitHub client with error handling and rate limit tracking
|
||||
let octokit;
|
||||
try {
|
||||
const decryptedToken = getDecryptedGitHubToken(config);
|
||||
octokit = createGitHubClient(decryptedToken);
|
||||
const githubUsername = config.githubConfig?.owner || undefined;
|
||||
const userId = config.userId || undefined;
|
||||
octokit = createGitHubClient(decryptedToken, userId, githubUsername);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create GitHub client: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
75
src/lib/repo-utils.test.ts
Normal file
75
src/lib/repo-utils.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { mergeGitReposPreferStarred, normalizeGitRepoToInsert, calcBatchSizeForInsert } from '@/lib/repo-utils';
|
||||
import type { GitRepo } from '@/types/Repository';
|
||||
|
||||
function sampleRepo(overrides: Partial<GitRepo> = {}): GitRepo {
|
||||
const base: GitRepo = {
|
||||
name: 'repo',
|
||||
fullName: 'owner/repo',
|
||||
url: 'https://github.com/owner/repo',
|
||||
cloneUrl: 'https://github.com/owner/repo.git',
|
||||
owner: 'owner',
|
||||
organization: undefined,
|
||||
mirroredLocation: '',
|
||||
destinationOrg: null,
|
||||
isPrivate: false,
|
||||
isForked: false,
|
||||
forkedFrom: undefined,
|
||||
hasIssues: true,
|
||||
isStarred: false,
|
||||
isArchived: false,
|
||||
size: 1,
|
||||
hasLFS: false,
|
||||
hasSubmodules: false,
|
||||
language: null,
|
||||
description: null,
|
||||
defaultBranch: 'main',
|
||||
visibility: 'public',
|
||||
status: 'imported',
|
||||
lastMirrored: undefined,
|
||||
errorMessage: undefined,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
describe('mergeGitReposPreferStarred', () => {
|
||||
it('keeps unique repos', () => {
|
||||
const basic = [sampleRepo({ fullName: 'a/x', name: 'x' })];
|
||||
const starred: GitRepo[] = [];
|
||||
const merged = mergeGitReposPreferStarred(basic, starred);
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].fullName).toBe('a/x');
|
||||
});
|
||||
|
||||
it('prefers starred when duplicate exists', () => {
|
||||
const basic = [sampleRepo({ fullName: 'a/x', name: 'x', isStarred: false })];
|
||||
const starred = [sampleRepo({ fullName: 'a/x', name: 'x', isStarred: true })];
|
||||
const merged = mergeGitReposPreferStarred(basic, starred);
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].isStarred).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeGitRepoToInsert', () => {
|
||||
it('sets undefined optional fields to null', () => {
|
||||
const repo = sampleRepo({ organization: undefined, forkedFrom: undefined, language: undefined, description: undefined, lastMirrored: undefined, errorMessage: undefined });
|
||||
const insert = normalizeGitRepoToInsert(repo, { userId: 'u', configId: 'c' });
|
||||
expect(insert.organization).toBeNull();
|
||||
expect(insert.forkedFrom).toBeNull();
|
||||
expect(insert.language).toBeNull();
|
||||
expect(insert.description).toBeNull();
|
||||
expect(insert.lastMirrored).toBeNull();
|
||||
expect(insert.errorMessage).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('calcBatchSizeForInsert', () => {
|
||||
it('respects 999 parameter limit', () => {
|
||||
const batch = calcBatchSizeForInsert(29);
|
||||
expect(batch).toBeGreaterThan(0);
|
||||
expect(batch * 29).toBeLessThanOrEqual(999);
|
||||
});
|
||||
});
|
||||
|
||||
71
src/lib/repo-utils.ts
Normal file
71
src/lib/repo-utils.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { GitRepo } from '@/types/Repository';
|
||||
import { repositories } from '@/lib/db/schema';
|
||||
|
||||
export type RepoInsert = typeof repositories.$inferInsert;
|
||||
|
||||
// Merge lists and de-duplicate by fullName, preferring starred variant when present
|
||||
export function mergeGitReposPreferStarred(
|
||||
basicAndForked: GitRepo[],
|
||||
starred: GitRepo[]
|
||||
): GitRepo[] {
|
||||
const map = new Map<string, GitRepo>();
|
||||
for (const r of [...basicAndForked, ...starred]) {
|
||||
const existing = map.get(r.fullName);
|
||||
if (!existing || (!existing.isStarred && r.isStarred)) {
|
||||
map.set(r.fullName, r);
|
||||
}
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
// Convert a GitRepo to a normalized DB insert object with all nullable fields set
|
||||
export function normalizeGitRepoToInsert(
|
||||
repo: GitRepo,
|
||||
{
|
||||
userId,
|
||||
configId,
|
||||
}: { userId: string; configId: string }
|
||||
): RepoInsert {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
userId,
|
||||
configId,
|
||||
name: repo.name,
|
||||
fullName: repo.fullName,
|
||||
url: repo.url,
|
||||
cloneUrl: repo.cloneUrl,
|
||||
owner: repo.owner,
|
||||
organization: repo.organization ?? null,
|
||||
mirroredLocation: repo.mirroredLocation || '',
|
||||
destinationOrg: repo.destinationOrg || null,
|
||||
isPrivate: repo.isPrivate,
|
||||
isForked: repo.isForked,
|
||||
forkedFrom: repo.forkedFrom ?? null,
|
||||
hasIssues: repo.hasIssues,
|
||||
isStarred: repo.isStarred,
|
||||
isArchived: repo.isArchived,
|
||||
size: repo.size,
|
||||
hasLFS: repo.hasLFS,
|
||||
hasSubmodules: repo.hasSubmodules,
|
||||
language: repo.language ?? null,
|
||||
description: repo.description ?? null,
|
||||
defaultBranch: repo.defaultBranch,
|
||||
visibility: repo.visibility,
|
||||
status: 'imported',
|
||||
lastMirrored: repo.lastMirrored ?? null,
|
||||
errorMessage: repo.errorMessage ?? null,
|
||||
createdAt: repo.createdAt || new Date(),
|
||||
updatedAt: repo.updatedAt || new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
// Compute a safe batch size based on SQLite 999-parameter limit
|
||||
export function calcBatchSizeForInsert(columnCount: number, maxParams = 999): number {
|
||||
if (columnCount <= 0) return 1;
|
||||
// Reserve a little headroom in case column count drifts
|
||||
const safety = 0;
|
||||
const effectiveMax = Math.max(1, maxParams - safety);
|
||||
return Math.max(1, Math.floor(effectiveMax / columnCount));
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { db, configs, repositories } from '@/lib/db';
|
||||
import { eq, and, or, sql, not, inArray } from 'drizzle-orm';
|
||||
import { createGitHubClient, getGithubRepositories, getGithubStarredRepositories } from '@/lib/github';
|
||||
import { createGiteaClient, deleteGiteaRepo, archiveGiteaRepo } from '@/lib/gitea';
|
||||
import { createGiteaClient, deleteGiteaRepo, archiveGiteaRepo, getGiteaRepoOwnerAsync, checkRepoLocation } from '@/lib/gitea';
|
||||
import { getDecryptedGitHubToken, getDecryptedGiteaToken } from '@/lib/utils/config-encryption';
|
||||
import { publishEvent } from '@/lib/events';
|
||||
|
||||
@@ -23,19 +23,42 @@ async function identifyOrphanedRepositories(config: any): Promise<any[]> {
|
||||
const userId = config.userId;
|
||||
|
||||
try {
|
||||
// Get current GitHub repositories
|
||||
// Get current GitHub repositories with rate limit tracking
|
||||
const decryptedToken = getDecryptedGitHubToken(config);
|
||||
const octokit = createGitHubClient(decryptedToken);
|
||||
const githubUsername = config.githubConfig?.owner || undefined;
|
||||
const octokit = createGitHubClient(decryptedToken, userId, githubUsername);
|
||||
|
||||
// Fetch GitHub data
|
||||
const [basicAndForkedRepos, starredRepos] = await Promise.all([
|
||||
getGithubRepositories({ octokit, config }),
|
||||
config.githubConfig?.includeStarred
|
||||
? getGithubStarredRepositories({ octokit, config })
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
let allGithubRepos = [];
|
||||
let githubApiAccessible = true;
|
||||
|
||||
try {
|
||||
// Fetch GitHub data
|
||||
const [basicAndForkedRepos, starredRepos] = await Promise.all([
|
||||
getGithubRepositories({ octokit, config }),
|
||||
config.githubConfig?.includeStarred
|
||||
? getGithubStarredRepositories({ octokit, config })
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
allGithubRepos = [...basicAndForkedRepos, ...starredRepos];
|
||||
} catch (githubError: any) {
|
||||
// Handle GitHub API errors gracefully
|
||||
console.warn(`[Repository Cleanup] GitHub API error for user ${userId}: ${githubError.message}`);
|
||||
|
||||
// Check if it's a critical error (like account deleted/banned)
|
||||
if (githubError.status === 404 || githubError.status === 403) {
|
||||
console.error(`[Repository Cleanup] CRITICAL: GitHub account may be deleted/banned. Skipping cleanup to prevent data loss.`);
|
||||
console.error(`[Repository Cleanup] Consider using CLEANUP_ORPHANED_REPO_ACTION=archive instead of delete for safety.`);
|
||||
|
||||
// Return empty array to skip cleanup entirely when GitHub account is inaccessible
|
||||
return [];
|
||||
}
|
||||
|
||||
// For other errors, also skip cleanup to be safe
|
||||
console.error(`[Repository Cleanup] Skipping cleanup due to GitHub API error. This prevents accidental deletion of backups.`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const allGithubRepos = [...basicAndForkedRepos, ...starredRepos];
|
||||
const githubRepoFullNames = new Set(allGithubRepos.map(repo => repo.fullName));
|
||||
|
||||
// Get all repositories from our database
|
||||
@@ -44,13 +67,19 @@ async function identifyOrphanedRepositories(config: any): Promise<any[]> {
|
||||
.from(repositories)
|
||||
.where(eq(repositories.userId, userId));
|
||||
|
||||
// Identify orphaned repositories
|
||||
// Only identify repositories as orphaned if we successfully accessed GitHub
|
||||
// This prevents false positives when GitHub is down or account is inaccessible
|
||||
const orphanedRepos = dbRepos.filter(repo => !githubRepoFullNames.has(repo.fullName));
|
||||
|
||||
if (orphanedRepos.length > 0) {
|
||||
console.log(`[Repository Cleanup] Found ${orphanedRepos.length} orphaned repositories for user ${userId}`);
|
||||
}
|
||||
|
||||
return orphanedRepos;
|
||||
} catch (error) {
|
||||
console.error(`[Repository Cleanup] Error identifying orphaned repositories for user ${userId}:`, error);
|
||||
throw error;
|
||||
// Return empty array on error to prevent accidental deletions
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,26 +109,46 @@ async function handleOrphanedRepository(
|
||||
const giteaToken = getDecryptedGiteaToken(config);
|
||||
const giteaClient = createGiteaClient(config.giteaConfig.url, giteaToken);
|
||||
|
||||
// Determine the Gitea owner and repo name
|
||||
const mirroredLocation = repo.mirroredLocation || '';
|
||||
let giteaOwner = repo.owner;
|
||||
let giteaRepoName = repo.name;
|
||||
|
||||
if (mirroredLocation) {
|
||||
const parts = mirroredLocation.split('/');
|
||||
if (parts.length >= 2) {
|
||||
giteaOwner = parts[parts.length - 2];
|
||||
giteaRepoName = parts[parts.length - 1];
|
||||
}
|
||||
// Determine the Gitea owner and repo name more robustly
|
||||
const mirroredLocation = (repo.mirroredLocation || '').trim();
|
||||
let giteaOwner: string;
|
||||
let giteaRepoName: string;
|
||||
|
||||
if (mirroredLocation && mirroredLocation.includes('/')) {
|
||||
const [ownerPart, namePart] = mirroredLocation.split('/');
|
||||
giteaOwner = ownerPart;
|
||||
giteaRepoName = namePart;
|
||||
} else {
|
||||
// Fall back to expected owner based on config and repo flags (starred/org overrides)
|
||||
giteaOwner = await getGiteaRepoOwnerAsync({ config, repository: repo });
|
||||
giteaRepoName = repo.name;
|
||||
}
|
||||
|
||||
// Normalize owner casing to avoid GetUserByName issues on some Gitea setups
|
||||
giteaOwner = giteaOwner.trim();
|
||||
|
||||
if (action === 'archive') {
|
||||
console.log(`[Repository Cleanup] Archiving orphaned repository ${repoFullName} in Gitea`);
|
||||
// Best-effort check to validate actual location; falls back gracefully
|
||||
try {
|
||||
const { present, actualOwner } = await checkRepoLocation({
|
||||
config,
|
||||
repository: repo,
|
||||
expectedOwner: giteaOwner,
|
||||
});
|
||||
if (present) {
|
||||
giteaOwner = actualOwner;
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal; continue with best guess
|
||||
}
|
||||
|
||||
await archiveGiteaRepo(giteaClient, giteaOwner, giteaRepoName);
|
||||
|
||||
// Update database status
|
||||
await db.update(repositories).set({
|
||||
status: 'archived',
|
||||
isArchived: true,
|
||||
errorMessage: 'Repository archived - no longer in GitHub',
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(repositories.id, repo.id));
|
||||
@@ -348,6 +397,9 @@ export function isRepositoryCleanupServiceRunning(): boolean {
|
||||
return cleanupInterval !== null;
|
||||
}
|
||||
|
||||
// Export functions for use by scheduler
|
||||
export { identifyOrphanedRepositories, handleOrphanedRepository };
|
||||
|
||||
/**
|
||||
* Manually trigger repository cleanup for a specific user
|
||||
*/
|
||||
@@ -370,4 +422,4 @@ export async function triggerRepositoryCleanup(userId: string): Promise<{
|
||||
}
|
||||
|
||||
return runRepositoryCleanup(config);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,17 @@
|
||||
*/
|
||||
|
||||
import { db, configs, repositories } from '@/lib/db';
|
||||
import { eq, and, or, lt, gte } from 'drizzle-orm';
|
||||
import { syncGiteaRepo } from '@/lib/gitea';
|
||||
import { createGitHubClient } from '@/lib/github';
|
||||
import { eq, and, or } from 'drizzle-orm';
|
||||
import { syncGiteaRepo, mirrorGithubRepoToGitea } from '@/lib/gitea';
|
||||
import { getDecryptedGitHubToken } from '@/lib/utils/config-encryption';
|
||||
import { parseInterval, formatDuration } from '@/lib/utils/duration-parser';
|
||||
import type { Repository } from '@/lib/db/schema';
|
||||
import { repoStatusEnum, repositoryVisibilityEnum } from '@/types/Repository';
|
||||
import { mergeGitReposPreferStarred, normalizeGitRepoToInsert, calcBatchSizeForInsert } from '@/lib/repo-utils';
|
||||
|
||||
let schedulerInterval: NodeJS.Timeout | null = null;
|
||||
let isSchedulerRunning = false;
|
||||
let hasPerformedAutoStart = false; // Track if we've already done auto-start
|
||||
|
||||
/**
|
||||
* Parse schedule interval with enhanced support for duration strings, cron, and numbers
|
||||
@@ -41,6 +42,12 @@ async function runScheduledSync(config: any): Promise<void> {
|
||||
console.log(`[Scheduler] Running scheduled sync for user ${userId}`);
|
||||
|
||||
try {
|
||||
// Check if tokens are configured before proceeding
|
||||
if (!config.githubConfig?.token || !config.giteaConfig?.token) {
|
||||
console.log(`[Scheduler] Skipping sync for user ${userId}: GitHub or Gitea tokens not configured`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update lastRun timestamp
|
||||
const currentTime = new Date();
|
||||
const scheduleConfig = config.scheduleConfig || {};
|
||||
@@ -68,6 +75,166 @@ async function runScheduledSync(config: any): Promise<void> {
|
||||
updatedAt: currentTime,
|
||||
}).where(eq(configs.id, config.id));
|
||||
|
||||
// Auto-discovery: Check for new GitHub repositories
|
||||
if (scheduleConfig.autoImport !== false) {
|
||||
console.log(`[Scheduler] Checking for new GitHub repositories for user ${userId}...`);
|
||||
try {
|
||||
const { getGithubRepositories, getGithubStarredRepositories } = await import('@/lib/github');
|
||||
const { v4: uuidv4 } = await import('uuid');
|
||||
const { getDecryptedGitHubToken } = await import('@/lib/utils/config-encryption');
|
||||
|
||||
// Create GitHub client
|
||||
const decryptedToken = getDecryptedGitHubToken(config);
|
||||
const { Octokit } = await import('@octokit/rest');
|
||||
const octokit = new Octokit({ auth: decryptedToken });
|
||||
|
||||
// Fetch GitHub data
|
||||
const [basicAndForkedRepos, starredRepos] = await Promise.all([
|
||||
getGithubRepositories({ octokit, config }),
|
||||
config.githubConfig?.includeStarred
|
||||
? getGithubStarredRepositories({ octokit, config })
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const allGithubRepos = mergeGitReposPreferStarred(basicAndForkedRepos, starredRepos);
|
||||
|
||||
// Check for new repositories
|
||||
const existingRepos = await db
|
||||
.select({ fullName: repositories.fullName })
|
||||
.from(repositories)
|
||||
.where(eq(repositories.userId, userId));
|
||||
|
||||
const existingRepoNames = new Set(existingRepos.map(r => r.fullName));
|
||||
const newRepos = allGithubRepos.filter(r => !existingRepoNames.has(r.fullName));
|
||||
|
||||
if (newRepos.length > 0) {
|
||||
console.log(`[Scheduler] Found ${newRepos.length} new repositories for user ${userId}`);
|
||||
|
||||
// Insert new repositories
|
||||
const reposToInsert = newRepos.map(repo =>
|
||||
normalizeGitRepoToInsert(repo, { userId, configId: config.id })
|
||||
);
|
||||
|
||||
// Batch insert to avoid SQLite parameter limit
|
||||
const sample = reposToInsert[0];
|
||||
const columnCount = Object.keys(sample ?? {}).length || 1;
|
||||
const BATCH_SIZE = calcBatchSizeForInsert(columnCount);
|
||||
for (let i = 0; i < reposToInsert.length; i += BATCH_SIZE) {
|
||||
const batch = reposToInsert.slice(i, i + BATCH_SIZE);
|
||||
await db
|
||||
.insert(repositories)
|
||||
.values(batch)
|
||||
.onConflictDoNothing({ target: [repositories.userId, repositories.fullName] });
|
||||
}
|
||||
console.log(`[Scheduler] Successfully imported ${newRepos.length} new repositories for user ${userId}`);
|
||||
} else {
|
||||
console.log(`[Scheduler] No new repositories found for user ${userId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Scheduler] Failed to auto-import repositories for user ${userId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-cleanup: Remove orphaned repositories (repos that no longer exist in GitHub)
|
||||
if (config.cleanupConfig?.deleteIfNotInGitHub) {
|
||||
console.log(`[Scheduler] Checking for orphaned repositories to cleanup for user ${userId}...`);
|
||||
try {
|
||||
const { identifyOrphanedRepositories, handleOrphanedRepository } = await import('@/lib/repository-cleanup-service');
|
||||
|
||||
const orphanedRepos = await identifyOrphanedRepositories(config);
|
||||
|
||||
if (orphanedRepos.length > 0) {
|
||||
console.log(`[Scheduler] Found ${orphanedRepos.length} orphaned repositories for cleanup`);
|
||||
|
||||
for (const repo of orphanedRepos) {
|
||||
try {
|
||||
await handleOrphanedRepository(
|
||||
config,
|
||||
repo,
|
||||
config.cleanupConfig.orphanedRepoAction || 'archive',
|
||||
config.cleanupConfig.dryRun ?? false
|
||||
);
|
||||
console.log(`[Scheduler] Handled orphaned repository: ${repo.fullName}`);
|
||||
} catch (error) {
|
||||
console.error(`[Scheduler] Failed to handle orphaned repository ${repo.fullName}:`, error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`[Scheduler] No orphaned repositories found for cleanup`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Scheduler] Failed to cleanup orphaned repositories for user ${userId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-mirror: Mirror imported/pending/failed repositories if enabled
|
||||
if (scheduleConfig.autoMirror) {
|
||||
try {
|
||||
console.log(`[Scheduler] Auto-mirror enabled - checking for repositories to mirror for user ${userId}...`);
|
||||
const reposNeedingMirror = await db
|
||||
.select()
|
||||
.from(repositories)
|
||||
.where(
|
||||
and(
|
||||
eq(repositories.userId, userId),
|
||||
or(
|
||||
eq(repositories.status, 'imported'),
|
||||
eq(repositories.status, 'pending'),
|
||||
eq(repositories.status, 'failed')
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
if (reposNeedingMirror.length > 0) {
|
||||
console.log(`[Scheduler] Found ${reposNeedingMirror.length} repositories that need initial mirroring`);
|
||||
|
||||
// Prepare Octokit client
|
||||
const decryptedToken = getDecryptedGitHubToken(config);
|
||||
const { Octokit } = await import('@octokit/rest');
|
||||
const octokit = new Octokit({ auth: decryptedToken });
|
||||
|
||||
// Process repositories in batches
|
||||
const batchSize = scheduleConfig.batchSize || 10;
|
||||
const pauseBetweenBatches = scheduleConfig.pauseBetweenBatches || 2000;
|
||||
for (let i = 0; i < reposNeedingMirror.length; i += batchSize) {
|
||||
const batch = reposNeedingMirror.slice(i, Math.min(i + batchSize, reposNeedingMirror.length));
|
||||
console.log(`[Scheduler] Auto-mirror batch ${Math.floor(i / batchSize) + 1} of ${Math.ceil(reposNeedingMirror.length / batchSize)} (${batch.length} repos)`);
|
||||
|
||||
await Promise.all(
|
||||
batch.map(async (repo) => {
|
||||
try {
|
||||
const repository: Repository = {
|
||||
...repo,
|
||||
status: repoStatusEnum.parse(repo.status),
|
||||
organization: repo.organization ?? undefined,
|
||||
lastMirrored: repo.lastMirrored ?? undefined,
|
||||
errorMessage: repo.errorMessage ?? undefined,
|
||||
mirroredLocation: repo.mirroredLocation || '',
|
||||
forkedFrom: repo.forkedFrom ?? undefined,
|
||||
visibility: repositoryVisibilityEnum.parse(repo.visibility),
|
||||
};
|
||||
|
||||
await mirrorGithubRepoToGitea({ octokit, repository, config });
|
||||
console.log(`[Scheduler] Auto-mirrored repository: ${repo.fullName}`);
|
||||
} catch (error) {
|
||||
console.error(`[Scheduler] Failed to auto-mirror repository ${repo.fullName}:`, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Pause between batches if configured
|
||||
if (i + batchSize < reposNeedingMirror.length) {
|
||||
console.log(`[Scheduler] Pausing for ${pauseBetweenBatches}ms before next auto-mirror batch...`);
|
||||
await new Promise(resolve => setTimeout(resolve, pauseBetweenBatches));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`[Scheduler] No repositories need initial mirroring`);
|
||||
}
|
||||
} catch (mirrorError) {
|
||||
console.error(`[Scheduler] Error during auto-mirror phase for user ${userId}:`, mirrorError);
|
||||
}
|
||||
}
|
||||
|
||||
// Get repositories to sync
|
||||
let reposToSync = await db
|
||||
.select()
|
||||
@@ -176,6 +343,265 @@ async function syncSingleRepository(config: any, repo: any): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should auto-start based on environment configuration
|
||||
*/
|
||||
async function checkAutoStartConfiguration(): Promise<boolean> {
|
||||
// Don't auto-start more than once
|
||||
if (hasPerformedAutoStart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if any configuration has scheduling enabled or mirror interval set
|
||||
const activeConfigs = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.isActive, true));
|
||||
|
||||
for (const config of activeConfigs) {
|
||||
// Check if scheduling is enabled via environment
|
||||
const scheduleEnabled = config.scheduleConfig?.enabled === true;
|
||||
const hasMirrorInterval = !!config.giteaConfig?.mirrorInterval;
|
||||
|
||||
// If either SCHEDULE_ENABLED=true or GITEA_MIRROR_INTERVAL is set, we should auto-start
|
||||
if (scheduleEnabled || hasMirrorInterval) {
|
||||
console.log(`[Scheduler] Auto-start conditions met for user ${config.userId} (scheduleEnabled=${scheduleEnabled}, hasMirrorInterval=${hasMirrorInterval})`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('[Scheduler] Error checking auto-start configuration:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform initial auto-start: import repositories and trigger mirror
|
||||
*/
|
||||
async function performInitialAutoStart(): Promise<void> {
|
||||
hasPerformedAutoStart = true;
|
||||
|
||||
try {
|
||||
console.log('[Scheduler] Performing initial auto-start...');
|
||||
|
||||
// Get all active configurations
|
||||
const activeConfigs = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.isActive, true));
|
||||
|
||||
for (const config of activeConfigs) {
|
||||
// Skip if tokens are not configured
|
||||
if (!config.githubConfig?.token || !config.giteaConfig?.token) {
|
||||
console.log(`[Scheduler] Skipping auto-start for user ${config.userId}: tokens not configured`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const scheduleEnabled = config.scheduleConfig?.enabled === true;
|
||||
const hasMirrorInterval = !!config.giteaConfig?.mirrorInterval;
|
||||
|
||||
// Only process configs that have scheduling or mirror interval configured
|
||||
if (!scheduleEnabled && !hasMirrorInterval) {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[Scheduler] Auto-starting for user ${config.userId}...`);
|
||||
|
||||
try {
|
||||
// Step 1: Import repositories from GitHub
|
||||
console.log(`[Scheduler] Step 1: Importing repositories from GitHub for user ${config.userId}...`);
|
||||
const { getGithubRepositories, getGithubStarredRepositories } = await import('@/lib/github');
|
||||
const { v4: uuidv4 } = await import('uuid');
|
||||
|
||||
// Create GitHub client
|
||||
const decryptedToken = getDecryptedGitHubToken(config);
|
||||
const { Octokit } = await import('@octokit/rest');
|
||||
const octokit = new Octokit({ auth: decryptedToken });
|
||||
|
||||
// Fetch GitHub data
|
||||
const [basicAndForkedRepos, starredRepos] = await Promise.all([
|
||||
getGithubRepositories({ octokit, config }),
|
||||
config.githubConfig?.includeStarred
|
||||
? getGithubStarredRepositories({ octokit, config })
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const allGithubRepos = mergeGitReposPreferStarred(basicAndForkedRepos, starredRepos);
|
||||
|
||||
// Check for new repositories
|
||||
const existingRepos = await db
|
||||
.select({ fullName: repositories.fullName })
|
||||
.from(repositories)
|
||||
.where(eq(repositories.userId, config.userId));
|
||||
|
||||
const existingRepoNames = new Set(existingRepos.map(r => r.fullName));
|
||||
const reposToImport = allGithubRepos.filter(r => !existingRepoNames.has(r.fullName));
|
||||
|
||||
if (reposToImport.length > 0) {
|
||||
console.log(`[Scheduler] Importing ${reposToImport.length} repositories for user ${config.userId}...`);
|
||||
|
||||
// Insert new repositories
|
||||
const reposToInsert = reposToImport.map(repo =>
|
||||
normalizeGitRepoToInsert(repo, { userId: config.userId, configId: config.id })
|
||||
);
|
||||
|
||||
// Batch insert to avoid SQLite parameter limit
|
||||
const sample = reposToInsert[0];
|
||||
const columnCount = Object.keys(sample ?? {}).length || 1;
|
||||
const BATCH_SIZE = calcBatchSizeForInsert(columnCount);
|
||||
for (let i = 0; i < reposToInsert.length; i += BATCH_SIZE) {
|
||||
const batch = reposToInsert.slice(i, i + BATCH_SIZE);
|
||||
await db
|
||||
.insert(repositories)
|
||||
.values(batch)
|
||||
.onConflictDoNothing({ target: [repositories.userId, repositories.fullName] });
|
||||
}
|
||||
console.log(`[Scheduler] Successfully imported ${reposToImport.length} repositories`);
|
||||
} else {
|
||||
console.log(`[Scheduler] No new repositories to import for user ${config.userId}`);
|
||||
}
|
||||
|
||||
// Check if we already have mirrored repositories (indicating this isn't first run)
|
||||
const mirroredRepos = await db
|
||||
.select()
|
||||
.from(repositories)
|
||||
.where(
|
||||
and(
|
||||
eq(repositories.userId, config.userId),
|
||||
or(
|
||||
eq(repositories.status, 'mirrored'),
|
||||
eq(repositories.status, 'synced')
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
// If we already have mirrored repos, skip the initial mirror (let regular sync handle it)
|
||||
if (mirroredRepos.length > 0) {
|
||||
console.log(`[Scheduler] User ${config.userId} already has mirrored repositories, skipping initial mirror (let regular sync handle updates)`);
|
||||
|
||||
// Still update the schedule config to indicate scheduling is active
|
||||
const currentTime = new Date();
|
||||
const intervalSource = config.scheduleConfig?.interval ||
|
||||
config.giteaConfig?.mirrorInterval ||
|
||||
'8h';
|
||||
const interval = parseScheduleInterval(intervalSource);
|
||||
const nextRun = new Date(currentTime.getTime() + interval);
|
||||
|
||||
await db.update(configs).set({
|
||||
scheduleConfig: {
|
||||
...config.scheduleConfig,
|
||||
enabled: true,
|
||||
lastRun: currentTime,
|
||||
nextRun: nextRun,
|
||||
},
|
||||
updatedAt: currentTime,
|
||||
}).where(eq(configs.id, config.id));
|
||||
|
||||
console.log(`[Scheduler] Scheduling enabled for user ${config.userId}, next sync at ${nextRun.toISOString()}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Step 2: Trigger mirror for all repositories that need mirroring
|
||||
console.log(`[Scheduler] Step 2: Triggering mirror for repositories that need mirroring...`);
|
||||
const reposNeedingMirror = await db
|
||||
.select()
|
||||
.from(repositories)
|
||||
.where(
|
||||
and(
|
||||
eq(repositories.userId, config.userId),
|
||||
or(
|
||||
eq(repositories.status, 'imported'),
|
||||
eq(repositories.status, 'pending'),
|
||||
eq(repositories.status, 'failed')
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
if (reposNeedingMirror.length > 0) {
|
||||
console.log(`[Scheduler] Found ${reposNeedingMirror.length} repositories that need mirroring`);
|
||||
|
||||
// Reuse the octokit instance from above
|
||||
// (octokit was already created in the import phase)
|
||||
|
||||
// Process repositories in batches
|
||||
const batchSize = config.scheduleConfig?.batchSize || 5;
|
||||
for (let i = 0; i < reposNeedingMirror.length; i += batchSize) {
|
||||
const batch = reposNeedingMirror.slice(i, Math.min(i + batchSize, reposNeedingMirror.length));
|
||||
console.log(`[Scheduler] Processing batch ${Math.floor(i / batchSize) + 1} of ${Math.ceil(reposNeedingMirror.length / batchSize)} (${batch.length} repos)`);
|
||||
|
||||
await Promise.all(
|
||||
batch.map(async (repo) => {
|
||||
try {
|
||||
const repository: Repository = {
|
||||
...repo,
|
||||
status: repoStatusEnum.parse(repo.status),
|
||||
organization: repo.organization ?? undefined,
|
||||
lastMirrored: repo.lastMirrored ?? undefined,
|
||||
errorMessage: repo.errorMessage ?? undefined,
|
||||
mirroredLocation: repo.mirroredLocation || '',
|
||||
forkedFrom: repo.forkedFrom ?? undefined,
|
||||
visibility: repositoryVisibilityEnum.parse(repo.visibility),
|
||||
};
|
||||
|
||||
await mirrorGithubRepoToGitea({
|
||||
octokit,
|
||||
repository,
|
||||
config
|
||||
});
|
||||
console.log(`[Scheduler] Successfully mirrored repository: ${repo.fullName}`);
|
||||
} catch (error) {
|
||||
console.error(`[Scheduler] Failed to mirror repository ${repo.fullName}:`, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Pause between batches if configured
|
||||
if (i + batchSize < reposNeedingMirror.length) {
|
||||
const pauseTime = config.scheduleConfig?.pauseBetweenBatches || 2000;
|
||||
console.log(`[Scheduler] Pausing for ${pauseTime}ms before next batch...`);
|
||||
await new Promise(resolve => setTimeout(resolve, pauseTime));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Scheduler] Completed initial mirror for ${reposNeedingMirror.length} repositories`);
|
||||
} else {
|
||||
console.log(`[Scheduler] No repositories need mirroring`);
|
||||
}
|
||||
|
||||
// Update the schedule config to indicate we've run
|
||||
const currentTime = new Date();
|
||||
const intervalSource = config.scheduleConfig?.interval ||
|
||||
config.giteaConfig?.mirrorInterval ||
|
||||
'8h';
|
||||
const interval = parseScheduleInterval(intervalSource);
|
||||
const nextRun = new Date(currentTime.getTime() + interval);
|
||||
|
||||
await db.update(configs).set({
|
||||
scheduleConfig: {
|
||||
...config.scheduleConfig,
|
||||
enabled: true, // Ensure scheduling is enabled
|
||||
lastRun: currentTime,
|
||||
nextRun: nextRun,
|
||||
},
|
||||
updatedAt: currentTime,
|
||||
}).where(eq(configs.id, config.id));
|
||||
|
||||
console.log(`[Scheduler] Auto-start completed for user ${config.userId}, next sync at ${nextRun.toISOString()}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[Scheduler] Failed to auto-start for user ${config.userId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Scheduler] Initial auto-start completed');
|
||||
} catch (error) {
|
||||
console.error('[Scheduler] Failed to perform initial auto-start:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main scheduler loop
|
||||
*/
|
||||
@@ -202,25 +628,41 @@ async function schedulerLoop(): Promise<void> {
|
||||
config.scheduleConfig?.enabled === true
|
||||
);
|
||||
|
||||
if (enabledConfigs.length === 0) {
|
||||
console.log(`[Scheduler] No configurations with scheduling enabled (found ${activeConfigs.length} active configs)`);
|
||||
// Further filter configs that have valid tokens
|
||||
const validConfigs = enabledConfigs.filter(config => {
|
||||
const hasGitHubToken = !!config.githubConfig?.token;
|
||||
const hasGiteaToken = !!config.giteaConfig?.token;
|
||||
|
||||
// Show details about why configs are not enabled
|
||||
activeConfigs.forEach(config => {
|
||||
const scheduleEnabled = config.scheduleConfig?.enabled;
|
||||
const mirrorInterval = config.giteaConfig?.mirrorInterval;
|
||||
console.log(`[Scheduler] User ${config.userId}: scheduleEnabled=${scheduleEnabled}, mirrorInterval=${mirrorInterval}`);
|
||||
});
|
||||
if (!hasGitHubToken || !hasGiteaToken) {
|
||||
console.log(`[Scheduler] User ${config.userId}: Scheduling enabled but tokens missing (GitHub: ${hasGitHubToken}, Gitea: ${hasGiteaToken})`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (validConfigs.length === 0) {
|
||||
if (enabledConfigs.length > 0) {
|
||||
console.log(`[Scheduler] ${enabledConfigs.length} config(s) have scheduling enabled but lack required tokens`);
|
||||
} else {
|
||||
console.log(`[Scheduler] No configurations with scheduling enabled (found ${activeConfigs.length} active configs)`);
|
||||
|
||||
// Show details about why configs are not enabled
|
||||
activeConfigs.forEach(config => {
|
||||
const scheduleEnabled = config.scheduleConfig?.enabled;
|
||||
const mirrorInterval = config.giteaConfig?.mirrorInterval;
|
||||
console.log(`[Scheduler] User ${config.userId}: scheduleEnabled=${scheduleEnabled}, mirrorInterval=${mirrorInterval}`);
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Scheduler] Processing ${enabledConfigs.length} configurations with scheduling enabled (out of ${activeConfigs.length} total active configs)`);
|
||||
console.log(`[Scheduler] Processing ${validConfigs.length} valid configurations (out of ${enabledConfigs.length} with scheduling enabled)`);
|
||||
|
||||
// Check each configuration to see if it's time to run
|
||||
const currentTime = new Date();
|
||||
|
||||
for (const config of enabledConfigs) {
|
||||
for (const config of validConfigs) {
|
||||
const scheduleConfig = config.scheduleConfig || {};
|
||||
|
||||
// Check if it's time to run based on nextRun
|
||||
@@ -242,7 +684,7 @@ async function schedulerLoop(): Promise<void> {
|
||||
/**
|
||||
* Start the scheduler service
|
||||
*/
|
||||
export function startSchedulerService(): void {
|
||||
export async function startSchedulerService(): Promise<void> {
|
||||
if (schedulerInterval) {
|
||||
console.log('[Scheduler] Scheduler service is already running');
|
||||
return;
|
||||
@@ -250,6 +692,14 @@ export function startSchedulerService(): void {
|
||||
|
||||
console.log('[Scheduler] Starting scheduler service');
|
||||
|
||||
// Check if we should auto-start mirroring based on environment variables
|
||||
const shouldAutoStart = await checkAutoStartConfiguration();
|
||||
|
||||
if (shouldAutoStart) {
|
||||
console.log('[Scheduler] Auto-start detected from environment variables, triggering initial import and mirror...');
|
||||
await performInitialAutoStart();
|
||||
}
|
||||
|
||||
// Run immediately on start
|
||||
schedulerLoop().catch(error => {
|
||||
console.error('[Scheduler] Error during initial scheduler run:', error);
|
||||
@@ -283,4 +733,4 @@ export function stopSchedulerService(): void {
|
||||
*/
|
||||
export function isSchedulerServiceRunning(): boolean {
|
||||
return schedulerInterval !== null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,31 @@ export function formatDate(date?: Date | string | null): string {
|
||||
}).format(new Date(date));
|
||||
}
|
||||
|
||||
export function formatLastSyncTime(date: Date | string | null): string {
|
||||
if (!date) return "Never";
|
||||
|
||||
const now = new Date();
|
||||
const syncDate = new Date(date);
|
||||
const diffMs = now.getTime() - syncDate.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
// Show relative time for recent syncs
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins} min ago`;
|
||||
if (diffHours < 24) return `${diffHours} hr${diffHours === 1 ? '' : 's'} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
||||
|
||||
// For older syncs, show week count
|
||||
const diffWeeks = Math.floor(diffDays / 7);
|
||||
if (diffWeeks < 4) return `${diffWeeks} week${diffWeeks === 1 ? '' : 's'} ago`;
|
||||
|
||||
// For even older, show month count
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
export function truncate(str: string, length: number): string {
|
||||
if (str.length <= length) return str;
|
||||
return str.slice(0, length) + "...";
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
export async function processInParallel<T, R>(
|
||||
items: T[],
|
||||
processItem: (item: T) => Promise<R>,
|
||||
concurrencyLimit: number = 5,
|
||||
concurrencyLimit: number = 5, // Safe default for GitHub API (max 100 concurrent, but 5-10 recommended)
|
||||
onProgress?: (completed: number, total: number, result?: R) => void
|
||||
): Promise<R[]> {
|
||||
const results: R[] = [];
|
||||
|
||||
@@ -93,7 +93,7 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
|
||||
enabled: scheduleEnabled,
|
||||
interval: scheduleInterval,
|
||||
concurrent: false,
|
||||
batchSize: 10,
|
||||
batchSize: 5, // Reduced from 10 to be more conservative with GitHub API limits
|
||||
lastRun: null,
|
||||
nextRun: scheduleEnabled ? new Date(Date.now() + scheduleInterval * 1000) : null,
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from "@/types/config";
|
||||
import { z } from "zod";
|
||||
import { githubConfigSchema, giteaConfigSchema, scheduleConfigSchema, cleanupConfigSchema } from "@/lib/db/schema";
|
||||
import { parseInterval } from "@/lib/utils/duration-parser";
|
||||
|
||||
// Use the actual database schema types
|
||||
type DbGitHubConfig = z.infer<typeof githubConfigSchema>;
|
||||
@@ -53,7 +54,7 @@ export function mapUiToDbConfig(
|
||||
defaultOrg: giteaConfig.organization,
|
||||
|
||||
// Advanced options
|
||||
skipStarredIssues: advancedOptions.skipStarredIssues,
|
||||
starredCodeOnly: advancedOptions.starredCodeOnly,
|
||||
};
|
||||
|
||||
// Map Gitea config to match database schema
|
||||
@@ -151,7 +152,8 @@ export function mapDbToUiConfig(dbConfig: any): {
|
||||
// Map advanced options
|
||||
const advancedOptions: AdvancedOptions = {
|
||||
skipForks: !(dbConfig.githubConfig?.includeForks ?? true), // Invert includeForks to get skipForks
|
||||
skipStarredIssues: dbConfig.githubConfig?.skipStarredIssues || false,
|
||||
// Support both old (skipStarredIssues) and new (starredCodeOnly) field names for backward compatibility
|
||||
starredCodeOnly: dbConfig.githubConfig?.starredCodeOnly ?? (dbConfig.githubConfig as any)?.skipStarredIssues ?? false,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -165,27 +167,22 @@ export function mapDbToUiConfig(dbConfig: any): {
|
||||
/**
|
||||
* Maps UI schedule config to database schema
|
||||
*/
|
||||
export function mapUiScheduleToDb(uiSchedule: any): DbScheduleConfig {
|
||||
export function mapUiScheduleToDb(uiSchedule: any, existing?: DbScheduleConfig): DbScheduleConfig {
|
||||
// Preserve existing schedule config and only update fields controlled by the UI
|
||||
const base: DbScheduleConfig = existing
|
||||
? { ...(existing as unknown as DbScheduleConfig) }
|
||||
: (scheduleConfigSchema.parse({}) as unknown as DbScheduleConfig);
|
||||
|
||||
// Store interval as seconds string to avoid lossy cron conversion
|
||||
const intervalSeconds = typeof uiSchedule.interval === 'number' && uiSchedule.interval > 0
|
||||
? String(uiSchedule.interval)
|
||||
: (typeof base.interval === 'string' ? base.interval : String(86400));
|
||||
|
||||
return {
|
||||
enabled: uiSchedule.enabled || false,
|
||||
interval: uiSchedule.interval ? `0 */${Math.floor(uiSchedule.interval / 3600)} * * *` : "0 2 * * *", // Convert seconds to cron expression
|
||||
concurrent: false,
|
||||
batchSize: 10,
|
||||
pauseBetweenBatches: 5000,
|
||||
retryAttempts: 3,
|
||||
retryDelay: 60000,
|
||||
timeout: 3600000,
|
||||
autoRetry: true,
|
||||
cleanupBeforeMirror: false,
|
||||
notifyOnFailure: true,
|
||||
notifyOnSuccess: false,
|
||||
logLevel: "info",
|
||||
timezone: "UTC",
|
||||
onlyMirrorUpdated: false,
|
||||
updateInterval: 86400000,
|
||||
skipRecentlyMirrored: true,
|
||||
recentThreshold: 3600000,
|
||||
};
|
||||
...base,
|
||||
enabled: !!uiSchedule.enabled,
|
||||
interval: intervalSeconds,
|
||||
} as DbScheduleConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,23 +199,18 @@ export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any {
|
||||
};
|
||||
}
|
||||
|
||||
// Extract hours from cron expression if possible
|
||||
// Parse interval supporting numbers (seconds), duration strings, and cron
|
||||
let intervalSeconds = 86400; // Default to daily (24 hours)
|
||||
|
||||
if (dbSchedule.interval) {
|
||||
// Check if it's already a number (seconds), use it directly
|
||||
if (typeof dbSchedule.interval === 'number') {
|
||||
intervalSeconds = dbSchedule.interval;
|
||||
} else if (typeof dbSchedule.interval === 'string') {
|
||||
// Check if it's a cron expression
|
||||
const cronMatch = dbSchedule.interval.match(/0 \*\/(\d+) \* \* \*/);
|
||||
if (cronMatch) {
|
||||
intervalSeconds = parseInt(cronMatch[1]) * 3600;
|
||||
} else if (dbSchedule.interval === "0 2 * * *") {
|
||||
// Daily at 2 AM
|
||||
intervalSeconds = 86400;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const ms = parseInterval(
|
||||
typeof dbSchedule.interval === 'number'
|
||||
? dbSchedule.interval
|
||||
: (dbSchedule.interval as unknown as string)
|
||||
);
|
||||
intervalSeconds = Math.max(1, Math.floor(ms / 1000));
|
||||
} catch (_e) {
|
||||
// Fallback to default if unparsable
|
||||
intervalSeconds = 86400;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -266,4 +258,4 @@ export function mapDbCleanupToUi(dbCleanup: DbCleanupConfig): any {
|
||||
lastRun: dbCleanup.lastRun || null,
|
||||
nextRun: dbCleanup.nextRun || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { setupSignalHandlers } from './lib/signal-handlers';
|
||||
import { auth } from './lib/auth';
|
||||
import { isHeaderAuthEnabled, authenticateWithHeaders } from './lib/auth-header';
|
||||
import { initializeConfigFromEnv } from './lib/env-config-loader';
|
||||
import { db, users } from './lib/db';
|
||||
|
||||
// Flag to track if recovery has been initialized
|
||||
let recoveryInitialized = false;
|
||||
@@ -17,6 +18,7 @@ let schedulerServiceStarted = false;
|
||||
let repositoryCleanupServiceStarted = false;
|
||||
let shutdownManagerInitialized = false;
|
||||
let envConfigInitialized = false;
|
||||
let envConfigCheckCount = 0; // Track attempts to avoid excessive checking
|
||||
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
// First, try Better Auth session (cookie-based)
|
||||
@@ -79,14 +81,31 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize configuration from environment variables (only once)
|
||||
if (!envConfigInitialized) {
|
||||
envConfigInitialized = true;
|
||||
try {
|
||||
await initializeConfigFromEnv();
|
||||
} catch (error) {
|
||||
console.error('⚠️ Failed to initialize configuration from environment:', error);
|
||||
// Continue anyway - environment config is optional
|
||||
// Initialize configuration from environment variables
|
||||
// Optimized to minimize performance impact:
|
||||
// - Once initialized, no checks are performed (envConfigInitialized = true)
|
||||
// - Limits checks to first 100 requests to avoid DB queries on every request if no users exist
|
||||
// - After user creation, env vars load on next request and flag is set permanently
|
||||
if (!envConfigInitialized && envConfigCheckCount < 100) {
|
||||
envConfigCheckCount++;
|
||||
|
||||
// Only check every 10th request after the first 10 to reduce DB load
|
||||
const shouldCheck = envConfigCheckCount <= 10 || envConfigCheckCount % 10 === 0;
|
||||
|
||||
if (shouldCheck) {
|
||||
try {
|
||||
const hasUsers = await db.select().from(users).limit(1).then(u => u.length > 0);
|
||||
|
||||
if (hasUsers) {
|
||||
// We have users now, try to initialize config
|
||||
await initializeConfigFromEnv();
|
||||
envConfigInitialized = true; // This ensures we never check again
|
||||
console.log('✅ Environment configuration loaded after user creation');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('⚠️ Failed to initialize configuration from environment:', error);
|
||||
// Continue anyway - environment config is optional
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +179,10 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
if (recoveryInitialized && !schedulerServiceStarted) {
|
||||
try {
|
||||
console.log('Starting automatic mirror scheduler service...');
|
||||
startSchedulerService();
|
||||
// Start the scheduler service (now async)
|
||||
startSchedulerService().catch(error => {
|
||||
console.error('Error in scheduler service startup:', error);
|
||||
});
|
||||
|
||||
// Register scheduler service shutdown callback
|
||||
registerShutdownCallback(async () => {
|
||||
|
||||
@@ -25,9 +25,34 @@ export async function POST(context: APIContext) {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate issuer URL format
|
||||
let validatedIssuer = issuer;
|
||||
if (issuer && typeof issuer === 'string' && issuer.trim() !== '') {
|
||||
try {
|
||||
const issuerUrl = new URL(issuer.trim());
|
||||
validatedIssuer = issuerUrl.toString().replace(/\/$/, ''); // Remove trailing slash
|
||||
} catch (e) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Invalid issuer URL format: ${issuer}` }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Issuer URL cannot be empty" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let registrationBody: any = {
|
||||
providerId,
|
||||
issuer,
|
||||
issuer: validatedIssuer,
|
||||
domain,
|
||||
organizationId,
|
||||
};
|
||||
@@ -91,14 +116,27 @@ export async function POST(context: APIContext) {
|
||||
// Use provided scopes or default if not specified
|
||||
const finalScopes = scopes || ["openid", "email", "profile"];
|
||||
|
||||
// Validate endpoint URLs if provided
|
||||
const validateUrl = (url: string | undefined, name: string): string | undefined => {
|
||||
if (!url) return undefined;
|
||||
if (typeof url !== 'string' || url.trim() === '') return undefined;
|
||||
try {
|
||||
const validatedUrl = new URL(url.trim());
|
||||
return validatedUrl.toString();
|
||||
} catch (e) {
|
||||
console.warn(`Invalid ${name} URL: ${url}, skipping`);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
registrationBody.oidcConfig = {
|
||||
clientId,
|
||||
clientSecret,
|
||||
authorizationEndpoint,
|
||||
tokenEndpoint,
|
||||
jwksEndpoint,
|
||||
discoveryEndpoint,
|
||||
userInfoEndpoint,
|
||||
clientId: clientId || undefined,
|
||||
clientSecret: clientSecret || undefined,
|
||||
authorizationEndpoint: validateUrl(authorizationEndpoint, 'authorization endpoint'),
|
||||
tokenEndpoint: validateUrl(tokenEndpoint, 'token endpoint'),
|
||||
jwksEndpoint: validateUrl(jwksEndpoint, 'JWKS endpoint'),
|
||||
discoveryEndpoint: validateUrl(discoveryEndpoint, 'discovery endpoint'),
|
||||
userInfoEndpoint: validateUrl(userInfoEndpoint, 'userinfo endpoint'),
|
||||
scopes: finalScopes,
|
||||
pkce,
|
||||
};
|
||||
|
||||
@@ -87,7 +87,10 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
}
|
||||
|
||||
// Map schedule and cleanup configs to database schema
|
||||
const processedScheduleConfig = mapUiScheduleToDb(scheduleConfig);
|
||||
const processedScheduleConfig = mapUiScheduleToDb(
|
||||
scheduleConfig,
|
||||
existingConfig ? existingConfig.scheduleConfig : undefined
|
||||
);
|
||||
const processedCleanupConfig = mapUiCleanupToDb(cleanupConfig);
|
||||
|
||||
if (existingConfig) {
|
||||
|
||||
69
src/pages/api/events/index.ts
Normal file
69
src/pages/api/events/index.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { getNewEvents } from "@/lib/events";
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const userId = url.searchParams.get("userId");
|
||||
|
||||
if (!userId) {
|
||||
return new Response("Missing userId", { status: 400 });
|
||||
}
|
||||
|
||||
// Create a new ReadableStream for SSE
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
let lastEventTime = new Date();
|
||||
|
||||
// Send initial connection message
|
||||
controller.enqueue(encoder.encode(": connected\n\n"));
|
||||
|
||||
// Poll for new events every 2 seconds
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
// Get new rate limit events
|
||||
const newEvents = await getNewEvents({
|
||||
userId,
|
||||
channel: "rate-limit",
|
||||
lastEventTime,
|
||||
});
|
||||
|
||||
// Send each new event
|
||||
for (const event of newEvents) {
|
||||
const message = `event: rate-limit\ndata: ${JSON.stringify(event.payload)}\n\n`;
|
||||
controller.enqueue(encoder.encode(message));
|
||||
lastEventTime = new Date(event.createdAt);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error polling for events:", error);
|
||||
}
|
||||
}, 2000); // Poll every 2 seconds
|
||||
|
||||
// Send heartbeat every 30 seconds to keep connection alive
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": heartbeat\n\n"));
|
||||
} catch (error) {
|
||||
clearInterval(heartbeatInterval);
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Cleanup on close
|
||||
request.signal.addEventListener("abort", () => {
|
||||
clearInterval(pollInterval);
|
||||
clearInterval(heartbeatInterval);
|
||||
controller.close();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no", // Disable nginx buffering
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -71,9 +71,10 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
throw new Error("GitHub token is missing in config.");
|
||||
}
|
||||
|
||||
// Create a single Octokit instance to be reused
|
||||
// Create a single Octokit instance to be reused with rate limit tracking
|
||||
const decryptedToken = getDecryptedGitHubToken(config);
|
||||
const octokit = createGitHubClient(decryptedToken);
|
||||
const githubUsername = config.githubConfig?.owner || undefined;
|
||||
const octokit = createGitHubClient(decryptedToken, userId, githubUsername);
|
||||
|
||||
// Define the concurrency limit - adjust based on API rate limits
|
||||
// Using a lower concurrency for organizations since each org might contain many repos
|
||||
|
||||
@@ -73,9 +73,10 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
throw new Error("GitHub token is missing.");
|
||||
}
|
||||
|
||||
// Create a single Octokit instance to be reused
|
||||
// Create a single Octokit instance to be reused with rate limit tracking
|
||||
const decryptedToken = getDecryptedGitHubToken(config);
|
||||
const octokit = createGitHubClient(decryptedToken);
|
||||
const githubUsername = config.githubConfig?.owner || undefined;
|
||||
const octokit = createGitHubClient(decryptedToken, userId, githubUsername);
|
||||
|
||||
// Define the concurrency limit - adjust based on API rate limits
|
||||
const CONCURRENCY_LIMIT = 3;
|
||||
|
||||
@@ -71,12 +71,13 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
|
||||
// Start background retry with parallel processing
|
||||
setTimeout(async () => {
|
||||
// Create a single Octokit instance to be reused if needed
|
||||
// Create a single Octokit instance to be reused if needed with rate limit tracking
|
||||
const decryptedToken = config.githubConfig.token
|
||||
? getDecryptedGitHubToken(config)
|
||||
: null;
|
||||
const githubUsername = config.githubConfig?.owner || undefined;
|
||||
const octokit = decryptedToken
|
||||
? createGitHubClient(decryptedToken)
|
||||
? createGitHubClient(decryptedToken, userId, githubUsername)
|
||||
: null;
|
||||
|
||||
// Define the concurrency limit - adjust based on API rate limits
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ScheduleSyncRepoResponse,
|
||||
} from "@/types/sync";
|
||||
import { createSecureErrorResponse } from "@/lib/utils";
|
||||
import { parseInterval } from "@/lib/utils/duration-parser";
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
@@ -72,8 +73,17 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
|
||||
// Calculate nextRun and update lastRun and nextRun in the config
|
||||
const currentTime = new Date();
|
||||
const interval = config.scheduleConfig?.interval ?? 3600;
|
||||
const nextRun = new Date(currentTime.getTime() + interval * 1000);
|
||||
let intervalMs = 3600 * 1000;
|
||||
try {
|
||||
intervalMs = parseInterval(
|
||||
typeof config.scheduleConfig?.interval === 'number'
|
||||
? config.scheduleConfig.interval
|
||||
: (config.scheduleConfig?.interval as unknown as string) || '3600'
|
||||
);
|
||||
} catch {
|
||||
intervalMs = 3600 * 1000;
|
||||
}
|
||||
const nextRun = new Date(currentTime.getTime() + intervalMs);
|
||||
|
||||
// Update the full giteaConfig object
|
||||
await db
|
||||
|
||||
104
src/pages/api/rate-limit/index.ts
Normal file
104
src/pages/api/rate-limit/index.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db, rateLimits } from "@/lib/db";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
|
||||
import { RateLimitManager } from "@/lib/rate-limit-manager";
|
||||
import { createGitHubClient } from "@/lib/github";
|
||||
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
|
||||
import { configs } from "@/lib/db";
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const userId = url.searchParams.get("userId");
|
||||
const refresh = url.searchParams.get("refresh") === "true";
|
||||
|
||||
if (!userId) {
|
||||
return jsonResponse({
|
||||
data: { error: "Missing userId" },
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// If refresh is requested, fetch current rate limit from GitHub
|
||||
if (refresh) {
|
||||
const [config] = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (config && config.githubConfig?.token) {
|
||||
const decryptedToken = getDecryptedGitHubToken(config);
|
||||
const githubUsername = config.githubConfig?.owner || undefined;
|
||||
const octokit = createGitHubClient(decryptedToken, userId, githubUsername);
|
||||
|
||||
// This will update the rate limit in the database
|
||||
await RateLimitManager.checkGitHubRateLimit(octokit, userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Get rate limit status from database
|
||||
const [rateLimit] = await db
|
||||
.select()
|
||||
.from(rateLimits)
|
||||
.where(and(eq(rateLimits.userId, userId), eq(rateLimits.provider, "github")))
|
||||
.orderBy(desc(rateLimits.updatedAt))
|
||||
.limit(1);
|
||||
|
||||
if (!rateLimit) {
|
||||
return jsonResponse({
|
||||
data: {
|
||||
limit: 5000,
|
||||
remaining: 5000,
|
||||
used: 0,
|
||||
reset: new Date(Date.now() + 3600000), // 1 hour from now
|
||||
status: "ok",
|
||||
lastChecked: new Date(),
|
||||
message: "No rate limit data available yet",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate percentage
|
||||
const percentage = Math.round((rateLimit.remaining / rateLimit.limit) * 100);
|
||||
|
||||
// Calculate time until reset
|
||||
const now = new Date();
|
||||
const resetTime = new Date(rateLimit.reset);
|
||||
const timeUntilReset = Math.max(0, resetTime.getTime() - now.getTime());
|
||||
const minutesUntilReset = Math.ceil(timeUntilReset / 60000);
|
||||
|
||||
let message = "";
|
||||
switch (rateLimit.status) {
|
||||
case "exceeded":
|
||||
message = `Rate limit exceeded. Resets in ${minutesUntilReset} minutes.`;
|
||||
break;
|
||||
case "limited":
|
||||
message = `Rate limit critical: ${rateLimit.remaining}/${rateLimit.limit} (${percentage}%)`;
|
||||
break;
|
||||
case "warning":
|
||||
message = `Rate limit warning: ${rateLimit.remaining}/${rateLimit.limit} (${percentage}%)`;
|
||||
break;
|
||||
default:
|
||||
message = `Rate limit healthy: ${rateLimit.remaining}/${rateLimit.limit} (${percentage}%)`;
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
data: {
|
||||
limit: rateLimit.limit,
|
||||
remaining: rateLimit.remaining,
|
||||
used: rateLimit.used,
|
||||
reset: rateLimit.reset,
|
||||
retryAfter: rateLimit.retryAfter,
|
||||
status: rateLimit.status,
|
||||
lastChecked: rateLimit.lastChecked,
|
||||
percentage,
|
||||
minutesUntilReset,
|
||||
message,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return createSecureErrorResponse(error, "rate limit check", 500);
|
||||
}
|
||||
};
|
||||
@@ -10,26 +10,71 @@ export async function POST(context: APIContext) {
|
||||
|
||||
const { issuer } = await context.request.json();
|
||||
|
||||
if (!issuer) {
|
||||
return new Response(JSON.stringify({ error: "Issuer URL is required" }), {
|
||||
if (!issuer || typeof issuer !== 'string' || issuer.trim() === '') {
|
||||
return new Response(JSON.stringify({ error: "Issuer URL is required and must be a valid string" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure issuer URL ends without trailing slash for well-known discovery
|
||||
const cleanIssuer = issuer.replace(/\/$/, "");
|
||||
// Validate issuer URL format
|
||||
let cleanIssuer: string;
|
||||
try {
|
||||
const issuerUrl = new URL(issuer.trim());
|
||||
cleanIssuer = issuerUrl.toString().replace(/\/$/, ""); // Remove trailing slash
|
||||
} catch (e) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Invalid issuer URL format",
|
||||
details: `The provided URL "${issuer}" is not a valid URL. For Authentik, use format: https://your-authentik-domain/application/o/<app-slug>/`
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const discoveryUrl = `${cleanIssuer}/.well-known/openid-configuration`;
|
||||
|
||||
try {
|
||||
// Fetch OIDC discovery document
|
||||
const response = await fetch(discoveryUrl);
|
||||
// Fetch OIDC discovery document with timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(discoveryUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
});
|
||||
} catch (fetchError) {
|
||||
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
|
||||
throw new Error(`Request timeout: The OIDC provider at ${cleanIssuer} did not respond within 10 seconds`);
|
||||
}
|
||||
throw new Error(`Network error: Could not connect to ${cleanIssuer}. Please verify the URL is correct and accessible.`);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch discovery document: ${response.status}`);
|
||||
if (response.status === 404) {
|
||||
throw new Error(`OIDC discovery document not found at ${discoveryUrl}. For Authentik, ensure you're using the correct application slug in the URL.`);
|
||||
} else if (response.status >= 500) {
|
||||
throw new Error(`OIDC provider error (${response.status}): The server at ${cleanIssuer} returned an error.`);
|
||||
} else {
|
||||
throw new Error(`Failed to fetch discovery document (${response.status}): ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
const config = await response.json();
|
||||
let config: any;
|
||||
try {
|
||||
config = await response.json();
|
||||
} catch (parseError) {
|
||||
throw new Error(`Invalid response: The discovery document from ${cleanIssuer} is not valid JSON.`);
|
||||
}
|
||||
|
||||
// Extract the essential endpoints
|
||||
const discoveredConfig = {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getGithubStarredRepositories,
|
||||
} from "@/lib/github";
|
||||
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
|
||||
import { mergeGitReposPreferStarred, calcBatchSizeForInsert } from "@/lib/repo-utils";
|
||||
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
@@ -43,7 +44,8 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
|
||||
// Decrypt the GitHub token before using it
|
||||
const decryptedToken = getDecryptedGitHubToken(config);
|
||||
const octokit = createGitHubClient(decryptedToken);
|
||||
const githubUsername = config.githubConfig?.owner || undefined;
|
||||
const octokit = createGitHubClient(decryptedToken, userId, githubUsername);
|
||||
|
||||
// Fetch GitHub data in parallel
|
||||
const [basicAndForkedRepos, starredRepos, gitOrgs] = await Promise.all([
|
||||
@@ -54,7 +56,8 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
getGithubOrganizations({ octokit, config }),
|
||||
]);
|
||||
|
||||
const allGithubRepos = [...basicAndForkedRepos, ...starredRepos];
|
||||
// Merge and de-duplicate by fullName, preferring starred variant when duplicated
|
||||
const allGithubRepos = mergeGitReposPreferStarred(basicAndForkedRepos, starredRepos);
|
||||
|
||||
// Prepare full list of repos and orgs
|
||||
const newRepos = allGithubRepos.map((repo) => ({
|
||||
@@ -66,21 +69,25 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
url: repo.url,
|
||||
cloneUrl: repo.cloneUrl,
|
||||
owner: repo.owner,
|
||||
organization: repo.organization,
|
||||
organization: repo.organization ?? null,
|
||||
mirroredLocation: repo.mirroredLocation || "",
|
||||
destinationOrg: repo.destinationOrg || null,
|
||||
isPrivate: repo.isPrivate,
|
||||
isForked: repo.isForked,
|
||||
forkedFrom: repo.forkedFrom,
|
||||
forkedFrom: repo.forkedFrom ?? null,
|
||||
hasIssues: repo.hasIssues,
|
||||
isStarred: repo.isStarred,
|
||||
isArchived: repo.isArchived,
|
||||
size: repo.size,
|
||||
hasLFS: repo.hasLFS,
|
||||
hasSubmodules: repo.hasSubmodules,
|
||||
language: repo.language ?? null,
|
||||
description: repo.description ?? null,
|
||||
defaultBranch: repo.defaultBranch,
|
||||
visibility: repo.visibility,
|
||||
status: repo.status,
|
||||
lastMirrored: repo.lastMirrored,
|
||||
errorMessage: repo.errorMessage,
|
||||
lastMirrored: repo.lastMirrored ?? null,
|
||||
errorMessage: repo.errorMessage ?? null,
|
||||
createdAt: repo.createdAt,
|
||||
updatedAt: repo.updatedAt,
|
||||
}));
|
||||
@@ -123,12 +130,27 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
);
|
||||
insertedOrgs = newOrgs.filter((o) => !existingOrgNames.has(o.name));
|
||||
|
||||
// Batch insert repositories to avoid SQLite parameter limit (dynamic by column count)
|
||||
const sample = newRepos[0];
|
||||
const columnCount = Object.keys(sample ?? {}).length || 1;
|
||||
const REPO_BATCH_SIZE = calcBatchSizeForInsert(columnCount);
|
||||
if (insertedRepos.length > 0) {
|
||||
await tx.insert(repositories).values(insertedRepos);
|
||||
for (let i = 0; i < insertedRepos.length; i += REPO_BATCH_SIZE) {
|
||||
const batch = insertedRepos.slice(i, i + REPO_BATCH_SIZE);
|
||||
await tx
|
||||
.insert(repositories)
|
||||
.values(batch)
|
||||
.onConflictDoNothing({ target: [repositories.userId, repositories.fullName] });
|
||||
}
|
||||
}
|
||||
|
||||
// Batch insert organizations (they have fewer fields, so we can use larger batches)
|
||||
const ORG_BATCH_SIZE = 100;
|
||||
if (insertedOrgs.length > 0) {
|
||||
await tx.insert(organizations).values(insertedOrgs);
|
||||
for (let i = 0; i < insertedOrgs.length; i += ORG_BATCH_SIZE) {
|
||||
const batch = insertedOrgs.slice(i, i + ORG_BATCH_SIZE);
|
||||
await tx.insert(organizations).values(batch);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -69,8 +69,9 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Create authenticated Octokit instance
|
||||
const octokit = createGitHubClient(decryptedConfig.githubConfig.token);
|
||||
// Create authenticated Octokit instance with rate limit tracking
|
||||
const githubUsername = decryptedConfig.githubConfig?.owner || undefined;
|
||||
const octokit = createGitHubClient(decryptedConfig.githubConfig.token, userId, githubUsername);
|
||||
|
||||
// Fetch org metadata
|
||||
const { data: orgData } = await octokit.orgs.get({ org });
|
||||
@@ -117,25 +118,40 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
owner: repo.owner.login,
|
||||
organization:
|
||||
repo.owner.type === "Organization" ? repo.owner.login : null,
|
||||
mirroredLocation: "",
|
||||
destinationOrg: null,
|
||||
isPrivate: repo.private,
|
||||
isForked: repo.fork,
|
||||
forkedFrom: undefined,
|
||||
forkedFrom: null,
|
||||
hasIssues: repo.has_issues,
|
||||
isStarred: false,
|
||||
isArchived: repo.archived,
|
||||
size: repo.size,
|
||||
hasLFS: false,
|
||||
hasSubmodules: false,
|
||||
language: repo.language ?? null,
|
||||
description: repo.description ?? null,
|
||||
defaultBranch: repo.default_branch ?? "main",
|
||||
visibility: (repo.visibility ?? "public") as RepositoryVisibility,
|
||||
status: "imported" as RepoStatus,
|
||||
lastMirrored: undefined,
|
||||
errorMessage: undefined,
|
||||
lastMirrored: null,
|
||||
errorMessage: null,
|
||||
createdAt: repo.created_at ? new Date(repo.created_at) : new Date(),
|
||||
updatedAt: repo.updated_at ? new Date(repo.updated_at) : new Date(),
|
||||
}));
|
||||
|
||||
await db.insert(repositories).values(repoRecords);
|
||||
// Batch insert repositories to avoid SQLite parameter limit
|
||||
// Compute batch size based on column count
|
||||
const sample = repoRecords[0];
|
||||
const columnCount = Object.keys(sample ?? {}).length || 1;
|
||||
const BATCH_SIZE = Math.max(1, Math.floor(999 / columnCount));
|
||||
for (let i = 0; i < repoRecords.length; i += BATCH_SIZE) {
|
||||
const batch = repoRecords.slice(i, i + BATCH_SIZE);
|
||||
await db
|
||||
.insert(repositories)
|
||||
.values(batch)
|
||||
.onConflictDoNothing({ target: [repositories.userId, repositories.fullName] });
|
||||
}
|
||||
|
||||
// Insert organization metadata
|
||||
const organizationRecord = {
|
||||
|
||||
@@ -80,24 +80,25 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
cloneUrl: repoData.clone_url,
|
||||
owner: repoData.owner.login,
|
||||
organization:
|
||||
repoData.owner.type === "Organization"
|
||||
? repoData.owner.login
|
||||
: undefined,
|
||||
repoData.owner.type === "Organization" ? repoData.owner.login : null,
|
||||
isPrivate: repoData.private,
|
||||
isForked: repoData.fork,
|
||||
forkedFrom: undefined,
|
||||
forkedFrom: null,
|
||||
hasIssues: repoData.has_issues,
|
||||
isStarred: false,
|
||||
isArchived: repoData.archived,
|
||||
size: repoData.size,
|
||||
hasLFS: false,
|
||||
hasSubmodules: false,
|
||||
language: repoData.language ?? null,
|
||||
description: repoData.description ?? null,
|
||||
defaultBranch: repoData.default_branch,
|
||||
visibility: (repoData.visibility ?? "public") as RepositoryVisibility,
|
||||
status: "imported" as Repository["status"],
|
||||
lastMirrored: undefined,
|
||||
errorMessage: undefined,
|
||||
lastMirrored: null,
|
||||
errorMessage: null,
|
||||
mirroredLocation: "",
|
||||
destinationOrg: null,
|
||||
createdAt: repoData.created_at
|
||||
? new Date(repoData.created_at)
|
||||
: new Date(),
|
||||
@@ -106,7 +107,10 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
: new Date(),
|
||||
};
|
||||
|
||||
await db.insert(repositories).values(metadata);
|
||||
await db
|
||||
.insert(repositories)
|
||||
.values(metadata)
|
||||
.onConflictDoNothing({ target: [repositories.userId, repositories.fullName] });
|
||||
|
||||
createMirrorJob({
|
||||
userId,
|
||||
|
||||
@@ -12,6 +12,7 @@ export const repoStatusEnum = z.enum([
|
||||
"deleted",
|
||||
"syncing",
|
||||
"synced",
|
||||
"archived",
|
||||
]);
|
||||
|
||||
export type RepoStatus = z.infer<typeof repoStatusEnum>;
|
||||
@@ -48,6 +49,8 @@ export interface GitRepo {
|
||||
|
||||
owner: string;
|
||||
organization?: string;
|
||||
mirroredLocation?: string;
|
||||
destinationOrg?: string | null;
|
||||
|
||||
isPrivate: boolean;
|
||||
isForked: boolean;
|
||||
@@ -61,6 +64,8 @@ export interface GitRepo {
|
||||
hasLFS: boolean;
|
||||
hasSubmodules: boolean;
|
||||
|
||||
language?: string | null;
|
||||
description?: string | null;
|
||||
defaultBranch: string;
|
||||
visibility: RepositoryVisibility;
|
||||
|
||||
|
||||
@@ -29,11 +29,14 @@ export interface DatabaseCleanupConfig {
|
||||
nextRun?: Date;
|
||||
}
|
||||
|
||||
export type DuplicateNameStrategy = "suffix" | "prefix" | "owner-org";
|
||||
|
||||
export interface GitHubConfig {
|
||||
username: string;
|
||||
token: string;
|
||||
privateRepositories: boolean;
|
||||
mirrorStarred: boolean;
|
||||
starredDuplicateStrategy?: DuplicateNameStrategy;
|
||||
}
|
||||
|
||||
export interface MirrorOptions {
|
||||
@@ -52,7 +55,7 @@ export interface MirrorOptions {
|
||||
|
||||
export interface AdvancedOptions {
|
||||
skipForks: boolean;
|
||||
skipStarredIssues: boolean;
|
||||
starredCodeOnly: boolean;
|
||||
}
|
||||
|
||||
export interface SaveConfigApiRequest {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { RepoStatus } from "./Repository";
|
||||
export const membershipRoleEnum = z.enum([
|
||||
"member",
|
||||
"admin",
|
||||
"owner",
|
||||
"billing_manager",
|
||||
]);
|
||||
|
||||
|
||||
10
src/types/repository-status.test.ts
Normal file
10
src/types/repository-status.test.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { repoStatusEnum } from "@/types/Repository";
|
||||
|
||||
describe("repoStatusEnum", () => {
|
||||
it("includes archived status", () => {
|
||||
const res = repoStatusEnum.safeParse("archived");
|
||||
expect(res.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,28 +9,28 @@
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.3.4",
|
||||
"@astrojs/react": "^4.3.0",
|
||||
"@astrojs/mdx": "^4.3.6",
|
||||
"@astrojs/react": "^4.4.0",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@splinetool/react-spline": "^4.1.0",
|
||||
"@splinetool/runtime": "^1.10.52",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@splinetool/runtime": "^1.10.73",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"astro": "^5.13.4",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"astro": "^5.14.3",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.542.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"lucide-react": "^0.544.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.12"
|
||||
"tailwindcss": "^4.1.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tw-animate-css": "^1.3.7"
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.15.0"
|
||||
"packageManager": "pnpm@10.18.0"
|
||||
}
|
||||
1626
www/pnpm-lock.yaml
generated
1626
www/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -67,8 +67,35 @@ export function Hero() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Hunt Badge */}
|
||||
<div className="mt-6 sm:mt-8 flex items-center justify-center px-4 z-20">
|
||||
<a
|
||||
href="https://www.producthunt.com/products/gitea-mirror?embed=true&utm_source=badge-featured&utm_medium=badge&utm_source=badge-gitea-mirror"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block transition-transform hover:scale-105"
|
||||
>
|
||||
<img
|
||||
src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1013721&theme=light&t=1757620787136"
|
||||
alt="Gitea Mirror - Automated github to gitea repository mirroring & backup | Product Hunt"
|
||||
style={{ width: '250px', height: '54px' }}
|
||||
width="250"
|
||||
height="54"
|
||||
className="dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1013721&theme=dark&t=1757620890723"
|
||||
alt="Gitea Mirror - Automated github to gitea repository mirroring & backup | Product Hunt"
|
||||
style={{ width: '250px', height: '54px' }}
|
||||
width="250"
|
||||
height="54"
|
||||
className="hidden dark:block"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Call to action buttons */}
|
||||
<div className="mt-8 sm:mt-10 flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4 px-4 z-20">
|
||||
{/* <div className="mt-8 sm:mt-10 flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4 px-4 z-20">
|
||||
<Button
|
||||
size="lg"
|
||||
className="relative group w-full sm:w-auto min-h-[48px] text-base bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 shadow-lg shadow-primary/25 hover:shadow-xl hover:shadow-primary/30 transition-all duration-300"
|
||||
@@ -91,7 +118,7 @@ export function Hero() {
|
||||
>
|
||||
<a href="#features">View Features</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user