diff --git a/.gitignore b/.gitignore index 1a63b77..77846ab 100644 --- a/.gitignore +++ b/.gitignore @@ -50,7 +50,6 @@ Thumbs.db # Frontend frontend/node_modules/ frontend/dist/ -frontend/package-lock.json frontend/yarn.lock frontend/pnpm-lock.yaml @@ -60,8 +59,11 @@ logs/ # Testing .pytest_cache/ +.mypy_cache/ +.ruff_cache/ .coverage htmlcov/ # Backup tool specific backups/ +.pi-subagents/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..7377d13 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.17.1 diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..7eebfaf --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12.11 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..dea06c4 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +PYTHON ?= .venv/bin/python +PIP ?= .venv/bin/pip +NPM ?= npm + +.PHONY: install test-fast lint typecheck frontend-build check-v1-absent check + +install: + $(PIP) install -e 'backend[dev]' + $(NPM) --prefix frontend ci + +test-fast: + $(PYTHON) -m pytest tests/unit tests/contract -q + +lint: + $(PYTHON) -m ruff check --config backend/pyproject.toml backend/src tests tools + $(PYTHON) -m ruff format --check --config backend/pyproject.toml backend/src tests tools + +typecheck: + $(PYTHON) -m mypy --config-file backend/pyproject.toml + $(NPM) --prefix frontend run typecheck + +frontend-build: + $(NPM) --prefix frontend run build + +check-v1-absent: + $(PYTHON) tools/forbidden_v1_scan.py . + +check: check-v1-absent test-fast lint typecheck frontend-build diff --git a/backend/alembic.ini b/backend/alembic.ini deleted file mode 100644 index cac063f..0000000 --- a/backend/alembic.ini +++ /dev/null @@ -1,5 +0,0 @@ -[alembic] -script_location = alembic -prepend_sys_path = . -version_path_separator = os -sqlalchemy.url = sqlite+aiosqlite:///./backup_tool.db diff --git a/backend/alembic/README b/backend/alembic/README deleted file mode 100644 index 98e4f9c..0000000 --- a/backend/alembic/README +++ /dev/null @@ -1 +0,0 @@ -Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py deleted file mode 100644 index 58b16a0..0000000 --- a/backend/alembic/env.py +++ /dev/null @@ -1,47 +0,0 @@ -import asyncio -from logging.config import fileConfig -from sqlalchemy import pool -from sqlalchemy.engine import Connection -from sqlalchemy.ext.asyncio import async_engine_from_config -from alembic import context -from app.models import Base - -config = context.config -if config.config_file_name is not None: - fileConfig(config.config_file_name) - -target_metadata = Base.metadata - -def run_migrations_offline() -> None: - url = config.get_main_option("sqlalchemy.url") - context.configure( - url=url, - target_metadata=target_metadata, - literal_binds=True, - dialect_opts={"paramstyle": "named"}, - ) - with context.begin_transaction(): - context.run_migrations() - -def do_run_migrations(connection: Connection) -> None: - context.configure(connection=connection, target_metadata=target_metadata) - with context.begin_transaction(): - context.run_migrations() - -async def run_async_migrations() -> None: - connectable = async_engine_from_config( - config.get_section(config.config_ini_section, {}), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) - async with connectable.connect() as connection: - await connection.run_sync(do_run_migrations) - await connectable.dispose() - -def run_migrations_online() -> None: - asyncio.run(run_async_migrations()) - -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako deleted file mode 100644 index 1101630..0000000 --- a/backend/alembic/script.py.mako +++ /dev/null @@ -1,28 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision: str = ${repr(up_revision)} -down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} -branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} -depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} - - -def upgrade() -> None: - """Upgrade schema.""" - ${upgrades if upgrades else "pass"} - - -def downgrade() -> None: - """Downgrade schema.""" - ${downgrades if downgrades else "pass"} diff --git a/backend/app/__pycache__/database.cpython-314.pyc b/backend/app/__pycache__/database.cpython-314.pyc deleted file mode 100644 index 13e9307..0000000 Binary files a/backend/app/__pycache__/database.cpython-314.pyc and /dev/null differ diff --git a/backend/app/__pycache__/main.cpython-314.pyc b/backend/app/__pycache__/main.cpython-314.pyc deleted file mode 100644 index d2a6ce2..0000000 Binary files a/backend/app/__pycache__/main.cpython-314.pyc and /dev/null differ diff --git a/backend/app/__pycache__/models.cpython-314.pyc b/backend/app/__pycache__/models.cpython-314.pyc deleted file mode 100644 index c8c1382..0000000 Binary files a/backend/app/__pycache__/models.cpython-314.pyc and /dev/null differ diff --git a/backend/app/__pycache__/schemas.cpython-314.pyc b/backend/app/__pycache__/schemas.cpython-314.pyc deleted file mode 100644 index 16446a8..0000000 Binary files a/backend/app/__pycache__/schemas.cpython-314.pyc and /dev/null differ diff --git a/backend/app/database.py b/backend/app/database.py deleted file mode 100644 index 4695d8b..0000000 --- a/backend/app/database.py +++ /dev/null @@ -1,17 +0,0 @@ -import os -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.orm import declarative_base - -DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///./backup_tool.db") - -engine = create_async_engine(DATABASE_URL, echo=os.environ.get("SQL_ECHO", "false").lower() == "true") -AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - -Base = declarative_base() - -async def get_db(): - async with AsyncSessionLocal() as session: - try: - yield session - finally: - await session.close() diff --git a/backend/app/main.py b/backend/app/main.py deleted file mode 100644 index e9ae032..0000000 --- a/backend/app/main.py +++ /dev/null @@ -1,60 +0,0 @@ -import os -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse -from contextlib import asynccontextmanager -from app.database import engine, Base -from app import models # noqa: F401 - registers models with Base.metadata -from app.routers import sources, jobs, executions, backups, settings, dashboard -from backup.scheduler import backup_scheduler - -@asynccontextmanager -async def lifespan(app: FastAPI): - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - backup_scheduler.start() - await backup_scheduler.sync_schedules() - yield - backup_scheduler.shutdown() - -app = FastAPI( - title="Backup Tool API", - version="0.1.0", - lifespan=lifespan -) - -app.add_middleware( - CORSMiddleware, - allow_origins=os.environ.get("CORS_ORIGINS", "http://localhost:3000").split(","), - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -app.include_router(sources.router) -app.include_router(jobs.router) -app.include_router(executions.router) -app.include_router(backups.router) -app.include_router(settings.router) -app.include_router(dashboard.router) - -@app.get("/api/health") -async def health_check(): - return {"status": "healthy"} - -def main(): - import uvicorn - uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True) - -# Static files for production -frontend_dist = os.path.join(os.path.dirname(__file__), "../../frontend/dist") -if os.path.exists(frontend_dist): - app.mount("/assets", StaticFiles(directory=os.path.join(frontend_dist, "assets")), name="assets") - - @app.get("/{path:path}") - async def serve_frontend(path: str): - index_file = os.path.join(frontend_dist, "index.html") - if os.path.exists(index_file): - return FileResponse(index_file) - return {"detail": "Frontend not built"} diff --git a/backend/app/models.py b/backend/app/models.py deleted file mode 100644 index 83c1965..0000000 --- a/backend/app/models.py +++ /dev/null @@ -1,137 +0,0 @@ -from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, ForeignKey, JSON -from sqlalchemy.orm import relationship -from datetime import datetime, timezone -from .database import Base - - -class Source(Base): - __tablename__ = "sources" - - id = Column(Integer, primary_key=True) - name = Column(String, nullable=False) - type = Column(String, nullable=False) # local, ssh, database - config = Column(JSON, default=lambda: {}) - created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) - updated_at = Column( - DateTime, - default=lambda: datetime.now(timezone.utc), - onupdate=lambda: datetime.now(timezone.utc), - ) - - jobs = relationship("Job", back_populates="source", cascade="all, delete-orphan") - - def __repr__(self): - return f"" - - -class Job(Base): - __tablename__ = "jobs" - - id = Column(Integer, primary_key=True) - name = Column(String, nullable=False) - source_id = Column(Integer, ForeignKey("sources.id"), nullable=False, index=True) - strategy = Column(String, nullable=False, default="full") # full, incremental - destination_path = Column(String, nullable=False) - exclude_patterns = Column(JSON, default=lambda: []) - enabled = Column(Boolean, default=True) - created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) - updated_at = Column( - DateTime, - default=lambda: datetime.now(timezone.utc), - onupdate=lambda: datetime.now(timezone.utc), - ) - - source = relationship("Source", back_populates="jobs") - schedule = relationship( - "Schedule", back_populates="job", uselist=False, cascade="all, delete-orphan" - ) - executions = relationship( - "JobExecution", back_populates="job", cascade="all, delete-orphan" - ) - - def __repr__(self): - return f"" - - -class Schedule(Base): - __tablename__ = "schedules" - - id = Column(Integer, primary_key=True) - job_id = Column( - Integer, ForeignKey("jobs.id"), unique=True, nullable=False, index=True - ) - cron_expression = Column(String, nullable=False) - enabled = Column(Boolean, default=True) - created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) - updated_at = Column( - DateTime, - default=lambda: datetime.now(timezone.utc), - onupdate=lambda: datetime.now(timezone.utc), - ) - - job = relationship("Job", back_populates="schedule") - - def __repr__(self): - return f"" - - -class JobExecution(Base): - __tablename__ = "job_executions" - - id = Column(Integer, primary_key=True) - job_id = Column(Integer, ForeignKey("jobs.id"), nullable=False, index=True) - status = Column( - String, nullable=False, default="pending" - ) # pending, running, success, failed, cancelled - started_at = Column(DateTime, nullable=True) - completed_at = Column(DateTime, nullable=True) - bytes_processed = Column(Integer, default=0) - bytes_backed_up = Column(Integer, default=0) - error_message = Column(Text, nullable=True) - triggered_by = Column(String, nullable=False) # manual, schedule - - job = relationship("Job", back_populates="executions") - backups = relationship( - "Backup", back_populates="execution", cascade="all, delete-orphan" - ) - - def __repr__(self): - return f"" - - -class Backup(Base): - __tablename__ = "backups" - - id = Column(Integer, primary_key=True) - execution_id = Column( - Integer, ForeignKey("job_executions.id"), nullable=False, index=True - ) - storage_path = Column(String, nullable=False) - size_bytes = Column(Integer, default=0) - checksum = Column(String, nullable=True) - type = Column(String, nullable=False) # full, incremental - parent_backup_id = Column( - Integer, ForeignKey("backups.id"), nullable=True, index=True - ) - created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) - - execution = relationship("JobExecution", back_populates="backups") - parent_backup = relationship("Backup", remote_side=[id]) - - def __repr__(self): - return f"" - - -class Setting(Base): - __tablename__ = "settings" - - key = Column(String, primary_key=True) - value = Column(Text, nullable=True) - updated_at = Column( - DateTime, - default=lambda: datetime.now(timezone.utc), - onupdate=lambda: datetime.now(timezone.utc), - ) - - def __repr__(self): - return f"" diff --git a/backend/app/routers/__pycache__/backups.cpython-314.pyc b/backend/app/routers/__pycache__/backups.cpython-314.pyc deleted file mode 100644 index 7ba129c..0000000 Binary files a/backend/app/routers/__pycache__/backups.cpython-314.pyc and /dev/null differ diff --git a/backend/app/routers/__pycache__/dashboard.cpython-314.pyc b/backend/app/routers/__pycache__/dashboard.cpython-314.pyc deleted file mode 100644 index e4f4bf2..0000000 Binary files a/backend/app/routers/__pycache__/dashboard.cpython-314.pyc and /dev/null differ diff --git a/backend/app/routers/__pycache__/executions.cpython-314.pyc b/backend/app/routers/__pycache__/executions.cpython-314.pyc deleted file mode 100644 index 41aaa2f..0000000 Binary files a/backend/app/routers/__pycache__/executions.cpython-314.pyc and /dev/null differ diff --git a/backend/app/routers/__pycache__/jobs.cpython-314.pyc b/backend/app/routers/__pycache__/jobs.cpython-314.pyc deleted file mode 100644 index ad25d9e..0000000 Binary files a/backend/app/routers/__pycache__/jobs.cpython-314.pyc and /dev/null differ diff --git a/backend/app/routers/__pycache__/settings.cpython-314.pyc b/backend/app/routers/__pycache__/settings.cpython-314.pyc deleted file mode 100644 index 8193bdb..0000000 Binary files a/backend/app/routers/__pycache__/settings.cpython-314.pyc and /dev/null differ diff --git a/backend/app/routers/__pycache__/sources.cpython-314.pyc b/backend/app/routers/__pycache__/sources.cpython-314.pyc deleted file mode 100644 index 3979d16..0000000 Binary files a/backend/app/routers/__pycache__/sources.cpython-314.pyc and /dev/null differ diff --git a/backend/app/routers/backups.py b/backend/app/routers/backups.py deleted file mode 100644 index 6a3b441..0000000 --- a/backend/app/routers/backups.py +++ /dev/null @@ -1,34 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from typing import List -from app.database import get_db -from app.models import Backup -from app.schemas import Backup as BackupSchema - -router = APIRouter(prefix="/api/backups", tags=["backups"]) - -@router.get("/", response_model=List[BackupSchema]) -async def list_backups(db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Backup).order_by(Backup.created_at.desc())) - backups = result.scalars().all() - return backups - -@router.get("/{backup_id}", response_model=BackupSchema) -async def get_backup(backup_id: int, db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Backup).where(Backup.id == backup_id)) - backup = result.scalar_one_or_none() - if not backup: - raise HTTPException(status_code=404, detail="Backup not found") - return backup - -@router.delete("/{backup_id}") -async def delete_backup(backup_id: int, db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Backup).where(Backup.id == backup_id)) - backup = result.scalar_one_or_none() - if not backup: - raise HTTPException(status_code=404, detail="Backup not found") - - await db.delete(backup) - await db.commit() - return {"message": "Backup deleted"} diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py deleted file mode 100644 index 2f39f54..0000000 --- a/backend/app/routers/dashboard.py +++ /dev/null @@ -1,54 +0,0 @@ -from fastapi import APIRouter, Depends -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, and_ -from datetime import datetime, timezone, timedelta -from typing import List - -from app.database import get_db -from app.models import Job, JobExecution, Backup -from app.schemas import DashboardStats, JobExecution as JobExecutionSchema - -router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) - - -@router.get("/", response_model=DashboardStats) -async def get_dashboard_stats(db: AsyncSession = Depends(get_db)): - # Active jobs count (enabled jobs) - active_jobs_result = await db.execute( - select(func.count(Job.id)).where(Job.enabled == True) - ) - active_jobs = active_jobs_result.scalar() or 0 - - # Total backups count - total_backups_result = await db.execute(select(func.count(Backup.id))) - total_backups = total_backups_result.scalar() or 0 - - # Storage used bytes (sum of all backup sizes) - storage_used_result = await db.execute(select(func.sum(Backup.size_bytes))) - storage_used_bytes = storage_used_result.scalar() or 0 - - # Recent failures count (last 24 hours) - twenty_four_hours_ago = datetime.now(timezone.utc) - timedelta(hours=24) - recent_failures_result = await db.execute( - select(func.count(JobExecution.id)).where( - and_( - JobExecution.status == "failed", - JobExecution.completed_at >= twenty_four_hours_ago, - ) - ) - ) - recent_failures = recent_failures_result.scalar() or 0 - - # Recent executions (last 10, ordered by started_at desc) - recent_executions_result = await db.execute( - select(JobExecution).order_by(JobExecution.started_at.desc()).limit(10) - ) - recent_executions = recent_executions_result.scalars().all() - - return DashboardStats( - active_jobs=active_jobs, - total_backups=total_backups, - storage_used_bytes=storage_used_bytes, - recent_failures=recent_failures, - recent_executions=list(recent_executions), - ) diff --git a/backend/app/routers/executions.py b/backend/app/routers/executions.py deleted file mode 100644 index c51dab8..0000000 --- a/backend/app/routers/executions.py +++ /dev/null @@ -1,23 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from typing import List -from app.database import get_db -from app.models import JobExecution -from app.schemas import JobExecution as JobExecutionSchema - -router = APIRouter(prefix="/api/executions", tags=["executions"]) - -@router.get("/", response_model=List[JobExecutionSchema]) -async def list_executions(db: AsyncSession = Depends(get_db)): - result = await db.execute(select(JobExecution).order_by(JobExecution.started_at.desc())) - executions = result.scalars().all() - return executions - -@router.get("/{execution_id}", response_model=JobExecutionSchema) -async def get_execution(execution_id: int, db: AsyncSession = Depends(get_db)): - result = await db.execute(select(JobExecution).where(JobExecution.id == execution_id)) - execution = result.scalar_one_or_none() - if not execution: - raise HTTPException(status_code=404, detail="Execution not found") - return execution diff --git a/backend/app/routers/jobs.py b/backend/app/routers/jobs.py deleted file mode 100644 index 3a7b378..0000000 --- a/backend/app/routers/jobs.py +++ /dev/null @@ -1,101 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from typing import List -from app.database import get_db, AsyncSessionLocal -from app.models import Job, Schedule -from app.schemas import JobCreate, JobUpdate, Job as JobSchema, ScheduleCreate, Schedule as ScheduleSchema -from backup.engine import BackupEngine - -router = APIRouter(prefix="/api/jobs", tags=["jobs"]) - -@router.get("/", response_model=List[JobSchema]) -async def list_jobs(db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Job)) - jobs = result.scalars().all() - return jobs - -@router.post("/", response_model=JobSchema) -async def create_job(job: JobCreate, db: AsyncSession = Depends(get_db)): - db_job = Job(**job.model_dump()) - db.add(db_job) - await db.commit() - await db.refresh(db_job) - return db_job - -@router.get("/{job_id}", response_model=JobSchema) -async def get_job(job_id: int, db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Job).where(Job.id == job_id)) - job = result.scalar_one_or_none() - if not job: - raise HTTPException(status_code=404, detail="Job not found") - return job - -@router.put("/{job_id}", response_model=JobSchema) -async def update_job( - job_id: int, - job_update: JobUpdate, - db: AsyncSession = Depends(get_db) -): - result = await db.execute(select(Job).where(Job.id == job_id)) - job = result.scalar_one_or_none() - if not job: - raise HTTPException(status_code=404, detail="Job not found") - - update_data = job_update.model_dump(exclude_unset=True) - for field, value in update_data.items(): - setattr(job, field, value) - - await db.commit() - await db.refresh(job) - return job - -@router.delete("/{job_id}") -async def delete_job(job_id: int, db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Job).where(Job.id == job_id)) - job = result.scalar_one_or_none() - if not job: - raise HTTPException(status_code=404, detail="Job not found") - - await db.delete(job) - await db.commit() - return {"message": "Job deleted"} - -@router.post("/{job_id}/run") -async def run_job( - job_id: int, - background_tasks: BackgroundTasks, - db: AsyncSession = Depends(get_db) -): - result = await db.execute(select(Job).where(Job.id == job_id)) - job = result.scalar_one_or_none() - if not job: - raise HTTPException(status_code=404, detail="Job not found") - - # Run in background - async def execute(): - async with AsyncSessionLocal() as session: - engine = BackupEngine(session) - await engine.execute_job(job_id, triggered_by="manual") - - background_tasks.add_task(execute) - return {"message": "Job execution started"} - -@router.post("/{job_id}/schedule", response_model=ScheduleSchema) -async def create_schedule( - job_id: int, - schedule: ScheduleCreate, - db: AsyncSession = Depends(get_db) -): - result = await db.execute(select(Job).where(Job.id == job_id)) - job = result.scalar_one_or_none() - if not job: - raise HTTPException(status_code=404, detail="Job not found") - - schedule_data = schedule.model_dump() - schedule_data["job_id"] = job_id - db_schedule = Schedule(**schedule_data) - db.add(db_schedule) - await db.commit() - await db.refresh(db_schedule) - return ScheduleSchema.model_validate(db_schedule) diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py deleted file mode 100644 index f69c846..0000000 --- a/backend/app/routers/settings.py +++ /dev/null @@ -1,43 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from typing import List -from app.database import get_db -from app.models import Setting -from app.schemas import Setting as SettingSchema, SettingUpdate - -router = APIRouter(prefix="/api/settings", tags=["settings"]) - -@router.get("/", response_model=List[SettingSchema]) -async def list_settings(db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Setting)) - settings = result.scalars().all() - return settings - -@router.get("/{key}", response_model=SettingSchema) -async def get_setting(key: str, db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Setting).where(Setting.key == key)) - setting = result.scalar_one_or_none() - if not setting: - raise HTTPException(status_code=404, detail="Setting not found") - return setting - -@router.put("/{key}", response_model=SettingSchema) -async def update_setting( - key: str, - setting_update: SettingUpdate, - db: AsyncSession = Depends(get_db) -): - result = await db.execute(select(Setting).where(Setting.key == key)) - setting = result.scalar_one_or_none() - - if not setting: - # Create if not exists - setting = Setting(key=key, value=setting_update.value) - db.add(setting) - else: - setting.value = setting_update.value - - await db.commit() - await db.refresh(setting) - return setting diff --git a/backend/app/routers/sources.py b/backend/app/routers/sources.py deleted file mode 100644 index 3d4fac6..0000000 --- a/backend/app/routers/sources.py +++ /dev/null @@ -1,61 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from typing import List -from app.database import get_db -from app.models import Source -from app.schemas import SourceCreate, SourceUpdate, Source as SourceSchema - -router = APIRouter(prefix="/api/sources", tags=["sources"]) - -@router.get("/", response_model=List[SourceSchema]) -async def list_sources(db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Source)) - sources = result.scalars().all() - return sources - -@router.post("/", response_model=SourceSchema) -async def create_source(source: SourceCreate, db: AsyncSession = Depends(get_db)): - db_source = Source(**source.model_dump()) - db.add(db_source) - await db.commit() - await db.refresh(db_source) - return db_source - -@router.get("/{source_id}", response_model=SourceSchema) -async def get_source(source_id: int, db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Source).where(Source.id == source_id)) - source = result.scalar_one_or_none() - if not source: - raise HTTPException(status_code=404, detail="Source not found") - return source - -@router.put("/{source_id}", response_model=SourceSchema) -async def update_source( - source_id: int, - source_update: SourceUpdate, - db: AsyncSession = Depends(get_db) -): - result = await db.execute(select(Source).where(Source.id == source_id)) - source = result.scalar_one_or_none() - if not source: - raise HTTPException(status_code=404, detail="Source not found") - - update_data = source_update.model_dump(exclude_unset=True) - for field, value in update_data.items(): - setattr(source, field, value) - - await db.commit() - await db.refresh(source) - return source - -@router.delete("/{source_id}") -async def delete_source(source_id: int, db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Source).where(Source.id == source_id)) - source = result.scalar_one_or_none() - if not source: - raise HTTPException(status_code=404, detail="Source not found") - - await db.delete(source) - await db.commit() - return {"message": "Source deleted"} diff --git a/backend/app/schemas.py b/backend/app/schemas.py deleted file mode 100644 index a7364b2..0000000 --- a/backend/app/schemas.py +++ /dev/null @@ -1,143 +0,0 @@ -from pydantic import BaseModel, Field, ConfigDict, field_validator -from typing import Optional, List, Dict, Any -from datetime import datetime -import re - -# Source schemas -class SourceBase(BaseModel): - name: str - type: str = Field(..., pattern="^(local|ssh|database)$") - config: Dict[str, Any] = Field(default_factory=dict) - -class SourceCreate(SourceBase): - pass - -class SourceUpdate(BaseModel): - name: Optional[str] = None - config: Optional[Dict[str, Any]] = None - -class Source(SourceBase): - id: int - created_at: datetime - updated_at: datetime - - model_config = ConfigDict(from_attributes=True) - -# Job schemas -class JobBase(BaseModel): - name: str - source_id: int - strategy: str = Field(default="full", pattern="^(full|incremental)$") - destination_path: str - exclude_patterns: List[str] = Field(default_factory=list) - enabled: bool = True - -class JobCreate(JobBase): - pass - -class JobUpdate(BaseModel): - name: Optional[str] = None - strategy: Optional[str] = None - destination_path: Optional[str] = None - exclude_patterns: Optional[List[str]] = None - enabled: Optional[bool] = None - -class Job(JobBase): - id: int - created_at: datetime - updated_at: datetime - - model_config = ConfigDict(from_attributes=True) - -# Schedule schemas -class ScheduleBase(BaseModel): - job_id: int - cron_expression: str - enabled: bool = True - - @field_validator('cron_expression') - @classmethod - def validate_cron(cls, v: str) -> str: - pattern = r'^([0-9*,/-]+)\s+([0-9*,/-]+)\s+([0-9*,/-]+)\s+([0-9*,/-]+)\s+([0-9*,/-]+)$' - if not re.match(pattern, v): - raise ValueError('Invalid cron expression format') - return v - -class ScheduleCreate(ScheduleBase): - pass - -class ScheduleUpdate(BaseModel): - cron_expression: Optional[str] = None - enabled: Optional[bool] = None - -class Schedule(ScheduleBase): - id: int - created_at: datetime - updated_at: datetime - - model_config = ConfigDict(from_attributes=True) - -# Execution schemas -class JobExecutionBase(BaseModel): - job_id: int - status: str = Field(default="pending", pattern="^(pending|running|success|failed|cancelled)$") - triggered_by: str = Field(..., pattern="^(manual|schedule)$") - -class JobExecutionCreate(JobExecutionBase): - pass - -class JobExecution(JobExecutionBase): - id: int - started_at: Optional[datetime] = None - completed_at: Optional[datetime] = None - bytes_processed: int = 0 - bytes_backed_up: int = 0 - error_message: Optional[str] = None - - model_config = ConfigDict(from_attributes=True) - -class JobExecutionUpdate(BaseModel): - status: Optional[str] = Field(None, pattern="^(pending|running|success|failed|cancelled)$") - error_message: Optional[str] = None - -# Backup schemas -class BackupBase(BaseModel): - execution_id: int - storage_path: str - size_bytes: int = 0 - checksum: Optional[str] = None - type: str = Field(..., pattern="^(full|incremental)$") - parent_backup_id: Optional[int] = None - -class BackupCreate(BackupBase): - pass - -class Backup(BackupBase): - id: int - created_at: datetime - - model_config = ConfigDict(from_attributes=True) - -# Settings schemas -class SettingBase(BaseModel): - key: str - value: Optional[str] = None - -class SettingCreate(SettingBase): - pass - -class SettingUpdate(BaseModel): - value: Optional[str] = None - -class Setting(SettingBase): - updated_at: datetime - - model_config = ConfigDict(from_attributes=True) - -# Dashboard schemas -class DashboardStats(BaseModel): - active_jobs: int - total_backups: int - storage_used_bytes: int - recent_failures: int - recent_executions: List[JobExecution] diff --git a/backend/backup/__pycache__/engine.cpython-314.pyc b/backend/backup/__pycache__/engine.cpython-314.pyc deleted file mode 100644 index ceb472c..0000000 Binary files a/backend/backup/__pycache__/engine.cpython-314.pyc and /dev/null differ diff --git a/backend/backup/adapters/__init__.py b/backend/backup/adapters/__init__.py deleted file mode 100644 index 8431d2a..0000000 --- a/backend/backup/adapters/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import Dict, Any -from .base import SourceAdapter -from .local import LocalAdapter -from .ssh import SSHAdapter -from .database import DatabaseAdapter - -ADAPTER_MAP = { - "local": LocalAdapter, - "ssh": SSHAdapter, - "database": DatabaseAdapter, -} - -def get_adapter(source_type: str, config: Dict[str, Any]) -> SourceAdapter: - adapter_class = ADAPTER_MAP.get(source_type) - if not adapter_class: - raise ValueError(f"Unknown source type: {source_type}") - return adapter_class(config) diff --git a/backend/backup/adapters/__pycache__/__init__.cpython-314.pyc b/backend/backup/adapters/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 797a542..0000000 Binary files a/backend/backup/adapters/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/backend/backup/adapters/__pycache__/base.cpython-314.pyc b/backend/backup/adapters/__pycache__/base.cpython-314.pyc deleted file mode 100644 index 300c667..0000000 Binary files a/backend/backup/adapters/__pycache__/base.cpython-314.pyc and /dev/null differ diff --git a/backend/backup/adapters/__pycache__/local.cpython-314.pyc b/backend/backup/adapters/__pycache__/local.cpython-314.pyc deleted file mode 100644 index d90a12a..0000000 Binary files a/backend/backup/adapters/__pycache__/local.cpython-314.pyc and /dev/null differ diff --git a/backend/backup/adapters/base.py b/backend/backup/adapters/base.py deleted file mode 100644 index b48ce08..0000000 --- a/backend/backup/adapters/base.py +++ /dev/null @@ -1,39 +0,0 @@ -from abc import ABC, abstractmethod -from typing import List, Dict, Any, AsyncIterator -from dataclasses import dataclass - -@dataclass -class FileInfo: - path: str - size: int - modified_time: float - is_directory: bool - -class SourceAdapter(ABC): - def __init__(self, config: Dict[str, Any]): - self.config = config - - @abstractmethod - async def connect(self) -> None: - """Establish connection to source.""" - pass - - @abstractmethod - async def disconnect(self) -> None: - """Close connection to source.""" - pass - - @abstractmethod - async def list_files(self, path: str = "") -> List[FileInfo]: - """List files at given path.""" - pass - - @abstractmethod - async def read_file(self, path: str) -> AsyncIterator[bytes]: - """Read file in chunks.""" - pass - - @abstractmethod - async def get_database_dump(self, config: Dict[str, Any]) -> AsyncIterator[bytes]: - """Get database dump. Only implemented for database adapters.""" - pass diff --git a/backend/backup/adapters/database.py b/backend/backup/adapters/database.py deleted file mode 100644 index 3714bd0..0000000 --- a/backend/backup/adapters/database.py +++ /dev/null @@ -1,74 +0,0 @@ -import os -import subprocess -import tempfile -from typing import List, AsyncIterator, Dict, Any -from .base import SourceAdapter, FileInfo - - -class DatabaseAdapter(SourceAdapter): - async def connect(self) -> None: - pass - - async def disconnect(self) -> None: - pass - - async def list_files(self, path: str = "") -> List[FileInfo]: - return [] - - async def read_file(self, path: str) -> AsyncIterator[bytes]: - raise NotImplementedError("Database adapter does not support file reading") - - async def get_database_dump(self, config: Dict[str, Any]) -> AsyncIterator[bytes]: - db_type = config.get("db_type", "postgresql") - host = config.get("host", "localhost") - port = config.get("port", 5432 if db_type == "postgresql" else 3306) - database = config.get("database") - username = config.get("username") - password = config.get("password") - - if not database: - raise ValueError("Database name is required") - - with tempfile.NamedTemporaryFile(suffix=".sql", delete=False) as tmp: - tmp_path = tmp.name - - try: - if db_type == "postgresql": - env = os.environ.copy() - if password: - env["PGPASSWORD"] = password - - cmd = [ - "pg_dump", - "-h", host, - "-p", str(port), - "-U", username or "postgres", - "-f", tmp_path, - database - ] - elif db_type == "mysql": - env = os.environ.copy() - if password: - env["MYSQL_PWD"] = password - - cmd = [ - "mysqldump", - "-h", host, - "-P", str(port), - "-u", username or "root", - "--result-file", tmp_path, - database - ] - else: - raise ValueError(f"Unsupported database type: {db_type}") - - result = subprocess.run(cmd, env=env, capture_output=True, text=True) - if result.returncode != 0: - raise RuntimeError(f"Database dump failed: {result.stderr}") - - with open(tmp_path, "rb") as f: - while chunk := f.read(8192): - yield chunk - finally: - if os.path.exists(tmp_path): - os.unlink(tmp_path) diff --git a/backend/backup/adapters/local.py b/backend/backup/adapters/local.py deleted file mode 100644 index 5a2f500..0000000 --- a/backend/backup/adapters/local.py +++ /dev/null @@ -1,47 +0,0 @@ -import os -import aiofiles -from pathlib import Path -from typing import List, AsyncIterator, Dict, Any -from .base import SourceAdapter, FileInfo - -class LocalAdapter(SourceAdapter): - async def connect(self) -> None: - base_path = self.config.get("path", ".") - if not os.path.exists(base_path): - raise FileNotFoundError(f"Path not found: {base_path}") - - async def disconnect(self) -> None: - pass - - async def list_files(self, path: str = "") -> List[FileInfo]: - base_path = Path(self.config.get("path", ".")) - target_path = base_path / path if path else base_path - - files = [] - exclude_patterns = self.config.get("exclude", []) - - for item in target_path.iterdir(): - # Check exclude patterns - if any(item.match(pattern) for pattern in exclude_patterns): - continue - - stat = item.stat() - files.append(FileInfo( - path=str(item.relative_to(base_path)), - size=stat.st_size, - modified_time=stat.st_mtime, - is_directory=item.is_dir() - )) - - return files - - async def read_file(self, path: str) -> AsyncIterator[bytes]: - base_path = Path(self.config.get("path", ".")) - file_path = base_path / path - - async with aiofiles.open(file_path, "rb") as f: - while chunk := await f.read(8192): - yield chunk - - async def get_database_dump(self, config: Dict[str, Any]) -> AsyncIterator[bytes]: - raise NotImplementedError("Local adapter does not support database dumps") diff --git a/backend/backup/adapters/ssh.py b/backend/backup/adapters/ssh.py deleted file mode 100644 index 11ba55e..0000000 --- a/backend/backup/adapters/ssh.py +++ /dev/null @@ -1,92 +0,0 @@ -import os -import tempfile -from pathlib import Path -from typing import List, AsyncIterator, Dict, Any -import paramiko -from .base import SourceAdapter, FileInfo - - -class SSHAdapter(SourceAdapter): - def __init__(self, config: Dict[str, Any]): - super().__init__(config) - self.client = None - self.sftp = None - - async def connect(self) -> None: - self.client = paramiko.SSHClient() - self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - - host = self.config.get("host", "localhost") - port = self.config.get("port", 22) - username = self.config.get("username") - password = self.config.get("password") - key_path = self.config.get("key_path") - - connect_kwargs = { - "hostname": host, - "port": port, - "username": username, - } - - if password: - connect_kwargs["password"] = password - elif key_path and os.path.exists(key_path): - connect_kwargs["key_filename"] = key_path - - self.client.connect(**connect_kwargs) - self.sftp = self.client.open_sftp() - - async def disconnect(self) -> None: - if self.sftp: - self.sftp.close() - self.sftp = None - if self.client: - self.client.close() - self.client = None - - async def list_files(self, path: str = "") -> List[FileInfo]: - remote_path = self.config.get("path", ".") - target_path = f"{remote_path}/{path}" if path else remote_path - - files = [] - exclude_patterns = self.config.get("exclude", []) - - try: - for entry in self.sftp.listdir_attr(target_path): - entry_path = f"{target_path}/{entry.filename}" - rel_path = entry_path.replace(remote_path + "/", "", 1) if remote_path != "." else entry_path - - if any(pattern in rel_path for pattern in exclude_patterns): - continue - - is_dir = entry.st_mode & 0o40000 == 0o40000 if hasattr(entry, 'st_mode') else False - - files.append(FileInfo( - path=rel_path, - size=entry.st_size, - modified_time=entry.st_mtime, - is_directory=is_dir - )) - except IOError: - pass - - return files - - async def read_file(self, path: str) -> AsyncIterator[bytes]: - remote_path = self.config.get("path", ".") - file_path = f"{remote_path}/{path}" if not path.startswith("/") else path - - with tempfile.NamedTemporaryFile(delete=False) as tmp: - tmp_path = tmp.name - - try: - self.sftp.get(file_path, tmp_path) - with open(tmp_path, "rb") as f: - while chunk := f.read(8192): - yield chunk - finally: - if os.path.exists(tmp_path): - os.unlink(tmp_path) - - async def get_database_dump(self, config: Dict[str, Any]) -> AsyncIterator[bytes]: - raise NotImplementedError("SSH adapter does not support database dumps directly") diff --git a/backend/backup/engine.py b/backend/backup/engine.py deleted file mode 100644 index 922c91c..0000000 --- a/backend/backup/engine.py +++ /dev/null @@ -1,138 +0,0 @@ -import os -import hashlib -import shutil -from datetime import datetime, timezone -from pathlib import Path -from typing import Optional -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from app.models import Job, JobExecution, Backup -from backup.adapters import get_adapter -from backup.retention import RetentionPolicy - -class BackupEngine: - def __init__(self, db: AsyncSession): - self.db = db - - async def execute_job(self, job_id: int, triggered_by: str = "manual") -> JobExecution: - # Create execution record - execution = JobExecution( - job_id=job_id, - status="pending", - triggered_by=triggered_by - ) - self.db.add(execution) - await self.db.commit() - await self.db.refresh(execution) - - try: - # Load job with source - result = await self.db.execute( - select(Job).where(Job.id == job_id) - ) - job = result.scalar_one() - - # Update status to running - execution.status = "running" - execution.started_at = datetime.now(timezone.utc) - await self.db.commit() - - # Determine strategy - strategy = job.strategy - parent_backup_id = None - - if strategy == "incremental": - # Find last successful full backup - result = await self.db.execute( - select(Backup) - .join(JobExecution) - .where( - JobExecution.job_id == job_id, - JobExecution.status == "success", - Backup.type == "full" - ) - .order_by(Backup.created_at.desc()) - ) - last_full = result.scalar_one_or_none() - - if last_full: - parent_backup_id = last_full.id - else: - # No full backup exists, do full instead - strategy = "full" - - # Create backup directory - timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H%M%S") - backup_dir = Path(job.destination_path) / str(job_id) / f"{timestamp}_{strategy}" - backup_dir.mkdir(parents=True, exist_ok=True) - - # Get adapter and connect - adapter = get_adapter(job.source.type, job.source.config) - await adapter.connect() - - try: - # Copy files - total_processed = 0 - total_backed_up = 0 - - source_path = Path(job.source.config.get("path", ".")) - - for item in source_path.rglob("*"): - if item.is_file(): - rel_path = item.relative_to(source_path) - dest_path = backup_dir / "data" / rel_path - dest_path.parent.mkdir(parents=True, exist_ok=True) - - # Copy file - shutil.copy2(item, dest_path) - - size = item.stat().st_size - total_processed += size - total_backed_up += size - - # Calculate checksum - checksum = await self._calculate_checksum(backup_dir) - - # Create backup record - backup = Backup( - execution_id=execution.id, - storage_path=str(backup_dir), - size_bytes=total_backed_up, - checksum=checksum, - type=strategy, - parent_backup_id=parent_backup_id - ) - self.db.add(backup) - - # Update execution - execution.status = "success" - execution.completed_at = datetime.now(timezone.utc) - execution.bytes_processed = total_processed - execution.bytes_backed_up = total_backed_up - - # Apply retention policy - retention = RetentionPolicy(self.db) - keep_count = getattr(job, 'retention_count', None) - keep_days = getattr(job, 'retention_days', None) - if keep_count or keep_days: - await retention.apply_retention_for_job(job_id, keep_count, keep_days) - - finally: - await adapter.disconnect() - - except Exception as e: - execution.status = "failed" - execution.completed_at = datetime.now(timezone.utc) - execution.error_message = str(e) - - await self.db.commit() - return execution - - async def _calculate_checksum(self, path: Path) -> str: - hasher = hashlib.sha256() - for item in sorted(path.rglob("*")): - if item.is_file(): - with open(item, "rb") as f: - while chunk := f.read(8192): - hasher.update(chunk) - return hasher.hexdigest() diff --git a/backend/backup/retention.py b/backend/backup/retention.py deleted file mode 100644 index efb421d..0000000 --- a/backend/backup/retention.py +++ /dev/null @@ -1,90 +0,0 @@ -import shutil -from datetime import datetime, timezone, timedelta -from pathlib import Path -from typing import List, Optional -from sqlalchemy import select - -from app.models import Backup - - -class RetentionPolicy: - def __init__(self, db): - self.db = db - - async def apply_retention_for_job( - self, - job_id: int, - keep_count: Optional[int] = None, - keep_days: Optional[int] = None - ) -> List[Backup]: - """ - Apply retention policy for a job's backups. - - Args: - job_id: The job ID to apply retention for - keep_count: Maximum number of backups to keep (oldest deleted first) - keep_days: Delete backups older than this many days - - Returns: - List of deleted backups - """ - deleted_backups = [] - - result = await self.db.execute( - select(Backup) - .where(Backup.execution.has(job_id=job_id)) - .order_by(Backup.created_at.asc()) - ) - backups = result.scalars().all() - - if not backups: - return deleted_backups - - backups_to_delete = set() - - if keep_count is not None and len(backups) > keep_count: - backups_to_delete.update(backups[:-keep_count]) - - if keep_days is not None: - cutoff_date = datetime.now(timezone.utc) - timedelta(days=keep_days) - for backup in backups: - if backup.created_at < cutoff_date: - backups_to_delete.add(backup) - - for backup in list(backups_to_delete): - await self._delete_backup(backup) - deleted_backups.append(backup) - - await self.db.commit() - return deleted_backups - - async def _delete_backup(self, backup: Backup): - """Delete a backup and its storage.""" - try: - storage_path = Path(backup.storage_path) - if storage_path.exists(): - shutil.rmtree(storage_path) - except Exception: - pass - - await self.db.delete(backup) - - async def cleanup_orphaned_backups(self) -> int: - """ - Remove backup records whose storage no longer exists. - - Returns: - Number of orphaned backups removed - """ - result = await self.db.execute(select(Backup)) - backups = result.scalars().all() - - removed_count = 0 - for backup in backups: - storage_path = Path(backup.storage_path) - if not storage_path.exists(): - await self.db.delete(backup) - removed_count += 1 - - await self.db.commit() - return removed_count diff --git a/backend/backup/scheduler.py b/backend/backup/scheduler.py deleted file mode 100644 index 43d7924..0000000 --- a/backend/backup/scheduler.py +++ /dev/null @@ -1,81 +0,0 @@ -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from apscheduler.triggers.cron import CronTrigger -from sqlalchemy import select -from typing import Optional -import logging - -from app.database import AsyncSessionLocal -from app.models import Schedule -from backup.engine import BackupEngine - -logger = logging.getLogger(__name__) - - -class BackupScheduler: - def __init__(self): - self.scheduler = AsyncIOScheduler() - self._job_map = {} - - def start(self): - """Start the scheduler.""" - self.scheduler.start() - logger.info("Backup scheduler started") - - def shutdown(self): - """Shutdown the scheduler.""" - self.scheduler.shutdown() - logger.info("Backup scheduler shutdown") - - async def sync_schedules(self): - """Sync all enabled schedules from database.""" - async with AsyncSessionLocal() as db: - result = await db.execute( - select(Schedule).where(Schedule.enabled == True) - ) - schedules = result.scalars().all() - - # Clear existing jobs - for schedule_id, job_id in list(self._job_map.items()): - self.scheduler.remove_job(job_id) - del self._job_map[schedule_id] - - # Add new jobs - for schedule in schedules: - await self._add_schedule_job(schedule) - - async def _add_schedule_job(self, schedule: Schedule): - """Add a single schedule job to the scheduler.""" - try: - trigger = CronTrigger.from_crontab(schedule.cron_expression) - job = self.scheduler.add_job( - self._run_backup_job, - trigger=trigger, - args=[schedule.job_id], - id=f"backup_job_{schedule.job_id}", - replace_existing=True - ) - self._job_map[schedule.id] = job.id - logger.info(f"Scheduled backup job {schedule.job_id} with cron: {schedule.cron_expression}") - except Exception as e: - logger.error(f"Failed to schedule job {schedule.job_id}: {e}") - - async def _run_backup_job(self, job_id: int): - """Execute a backup job.""" - logger.info(f"Running scheduled backup job {job_id}") - async with AsyncSessionLocal() as db: - engine = BackupEngine(db) - await engine.execute_job(job_id, triggered_by="schedule") - - async def add_schedule(self, schedule: Schedule): - """Add a new schedule to the scheduler.""" - await self._add_schedule_job(schedule) - - def remove_schedule(self, schedule_id: int): - """Remove a schedule from the scheduler.""" - if schedule_id in self._job_map: - self.scheduler.remove_job(self._job_map[schedule_id]) - del self._job_map[schedule_id] - - -# Global scheduler instance -backup_scheduler = BackupScheduler() diff --git a/backend/backup_tool.db b/backend/backup_tool.db deleted file mode 100644 index c7037e1..0000000 Binary files a/backend/backup_tool.db and /dev/null differ diff --git a/backend/pyproject.toml b/backend/pyproject.toml index bfc5066..19b63cd 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,68 +1,63 @@ [build-system] -requires = ["setuptools>=61.0", "wheel"] +requires = ["setuptools==80.9.0", "wheel==0.46.3"] build-backend = "setuptools.build_meta" [project] name = "backup-tool" -version = "0.1.0" -description = "Web-based backup management tool for small teams and SMBs" -readme = "README.md" -license = {text = "MIT"} -requires-python = ">=3.11" -authors = [ - {name = "Backup Tool Team"} -] -keywords = ["backup", "restore", "scheduler", "web"] -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: System Administrators", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", - "Topic :: System :: Archiving :: Backup", -] +version = "2.0.0.dev0" +description = "Self-hosted, integrity-first backup appliance" +license = "MIT" +requires-python = ">=3.12,<3.15" +authors = [{ name = "Backup Tool Team" }] dependencies = [ - "fastapi>=0.115.0", - "uvicorn[standard]>=0.34.0", - "sqlalchemy[asyncio]>=2.0.0", - "aiosqlite>=0.21.0", - "alembic>=1.15.0", - "pydantic>=2.13.0", - "pydantic-settings>=2.9.0", - "apscheduler>=3.11.0", - "paramiko>=3.5.0", - "aiofiles>=23.2.0", - "httpx>=0.28.0", + "aiofiles==25.1.0", + "aiosqlite==0.22.1", + "alembic==1.18.5", + "apscheduler==3.11.3", + "fastapi==0.136.1", + "httpx==0.28.1", + "paramiko==5.0.0", + "pydantic==2.13.4", + "pydantic-settings==2.14.2", + "sqlalchemy[asyncio]==2.0.49", + "uvicorn[standard]==0.51.0", ] [project.optional-dependencies] dev = [ - "pytest>=8.3.0", - "pytest-asyncio>=0.26.0", -] -prod = [ - "gunicorn>=23.0.0", + "jsonschema==4.26.0", + "mypy==2.3.0", + "pytest==9.0.3", + "pytest-asyncio==1.3.0", + "ruff==0.16.0", ] +prod = ["gunicorn==23.0.0"] [project.scripts] -backup-tool = "app.main:main" +backup-tool = "backup_tool.cli:main" -[project.urls] -Homepage = "https://github.com/backup-tool/backup-tool" -Documentation = "https://github.com/backup-tool/backup-tool#readme" -Repository = "https://github.com/backup-tool/backup-tool.git" +[tool.setuptools] +package-dir = { "" = "src" } [tool.setuptools.packages.find] -where = ["."] -include = ["app*", "backup*", "alembic*"] +where = ["src"] +include = ["backup_tool*"] + +[tool.setuptools.package-data] +backup_tool = ["py.typed"] [tool.pytest.ini_options] asyncio_mode = "auto" -testpaths = ["tests"] -pythonpath = [".", "app", "backup"] -[tool.setuptools.package-data] -alembic = ["*.ini", "*.py", "*.mako"] +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] + +[tool.mypy] +python_version = "3.12" +strict = true +packages = ["backup_tool"] +mypy_path = "src" diff --git a/backend/src/backup_tool/__init__.py b/backend/src/backup_tool/__init__.py new file mode 100644 index 0000000..0fd3dc5 --- /dev/null +++ b/backend/src/backup_tool/__init__.py @@ -0,0 +1,3 @@ +"""Backup Tool v2 package.""" + +__version__ = "2.0.0.dev0" diff --git a/backend/src/backup_tool/cli.py b/backend/src/backup_tool/cli.py new file mode 100644 index 0000000..343636b --- /dev/null +++ b/backend/src/backup_tool/cli.py @@ -0,0 +1,23 @@ +"""Minimal v2 command entry point; runtime roles arrive in M1.""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence + +from backup_tool import __version__ + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="backup-tool") + parser.add_argument("--version", action="version", version=__version__) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + build_parser().parse_args(argv) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backup_tool.db b/backend/src/backup_tool/py.typed similarity index 100% rename from backup_tool.db rename to backend/src/backup_tool/py.typed diff --git a/backend/tests/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc b/backend/tests/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index cc1bf02..0000000 Binary files a/backend/tests/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc and /dev/null differ diff --git a/backend/tests/__pycache__/test_engine.cpython-314-pytest-9.0.3.pyc b/backend/tests/__pycache__/test_engine.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index d9b80b0..0000000 Binary files a/backend/tests/__pycache__/test_engine.cpython-314-pytest-9.0.3.pyc and /dev/null differ diff --git a/backend/tests/__pycache__/test_jobs.cpython-314-pytest-9.0.3.pyc b/backend/tests/__pycache__/test_jobs.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index 65978cf..0000000 Binary files a/backend/tests/__pycache__/test_jobs.cpython-314-pytest-9.0.3.pyc and /dev/null differ diff --git a/backend/tests/__pycache__/test_sources.cpython-314-pytest-9.0.3.pyc b/backend/tests/__pycache__/test_sources.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index 40af9a6..0000000 Binary files a/backend/tests/__pycache__/test_sources.cpython-314-pytest-9.0.3.pyc and /dev/null differ diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py deleted file mode 100644 index f47b1dc..0000000 --- a/backend/tests/conftest.py +++ /dev/null @@ -1,33 +0,0 @@ -import pytest -import pytest_asyncio -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from app.database import Base, get_db -from app.main import app -from httpx import AsyncClient, ASGITransport - -TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" - -@pytest_asyncio.fixture -async def db(): - engine = create_async_engine(TEST_DATABASE_URL, echo=False) - try: - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - async with async_session() as session: - yield session - finally: - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.drop_all) - await engine.dispose() - -@pytest_asyncio.fixture -async def client(db): - async def override_get_db(): - yield db - - app.dependency_overrides[get_db] = override_get_db - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: - yield ac - app.dependency_overrides.clear() diff --git a/backend/tests/test_engine.py b/backend/tests/test_engine.py deleted file mode 100644 index 12891c3..0000000 --- a/backend/tests/test_engine.py +++ /dev/null @@ -1,82 +0,0 @@ -import pytest -import pytest_asyncio -import tempfile -import os -from pathlib import Path -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload -from app.models import Source, Job, JobExecution -from backup.engine import BackupEngine - -@pytest_asyncio.fixture -async def test_source(db: AsyncSession): - with tempfile.TemporaryDirectory() as tmpdir: - # Create test files - (Path(tmpdir) / "test.txt").write_text("Hello, World!") - (Path(tmpdir) / "subdir").mkdir() - (Path(tmpdir) / "subdir" / "nested.txt").write_text("Nested content") - - source = Source( - name="Test Source", - type="local", - config={"path": tmpdir} - ) - db.add(source) - await db.commit() - await db.refresh(source) - yield source - -@pytest_asyncio.fixture -async def test_job(db: AsyncSession, test_source): - with tempfile.TemporaryDirectory() as tmpdir: - job = Job( - name="Test Job", - source_id=test_source.id, - strategy="full", - destination_path=tmpdir - ) - db.add(job) - await db.commit() - await db.refresh(job) - yield job - -@pytest.mark.asyncio -async def test_execute_full_backup(db: AsyncSession, test_job): - engine = BackupEngine(db) - execution = await engine.execute_job(test_job.id, triggered_by="manual") - - assert execution.status == "success" - assert execution.bytes_processed > 0 - assert execution.bytes_backed_up > 0 - assert execution.triggered_by == "manual" - - # Refresh test_job to load executions relationship - await db.refresh(test_job, ["executions"]) - - # Verify backup was created - assert len(test_job.executions) == 1 - - # Refresh execution to load backups relationship - await db.refresh(test_job.executions[0], ["backups"]) - backup = test_job.executions[0].backups[0] - assert backup.type == "full" - assert backup.checksum is not None - assert os.path.exists(backup.storage_path) - -@pytest.mark.asyncio -async def test_execute_incremental_without_full(db: AsyncSession, test_job): - # Set job to incremental but no full backup exists - test_job.strategy = "incremental" - await db.commit() - - engine = BackupEngine(db) - execution = await engine.execute_job(test_job.id) - - # Should fall back to full backup - assert execution.status == "success" - - # Refresh execution to load backups relationship - await db.refresh(execution, ["backups"]) - backup = execution.backups[0] - assert backup.type == "full" - assert backup.parent_backup_id is None diff --git a/backend/tests/test_jobs.py b/backend/tests/test_jobs.py deleted file mode 100644 index aee96e1..0000000 --- a/backend/tests/test_jobs.py +++ /dev/null @@ -1,205 +0,0 @@ -import pytest - -@pytest.mark.asyncio -async def test_create_job(client): - # Create a source first (job requires source_id) - source_resp = await client.post("/api/sources/", json={ - "name": "Test Source", - "type": "local", - "config": {"path": "/tmp/test"} - }) - assert source_resp.status_code == 200 - source_id = source_resp.json()["id"] - - response = await client.post("/api/jobs/", json={ - "name": "Test Job", - "source_id": source_id, - "strategy": "full", - "destination_path": "/tmp/backups", - "exclude_patterns": [], - "enabled": True - }) - assert response.status_code == 200 - data = response.json() - assert data["name"] == "Test Job" - assert data["source_id"] == source_id - assert data["strategy"] == "full" - assert "id" in data - -@pytest.mark.asyncio -async def test_list_jobs(client): - # Create source and job - source_resp = await client.post("/api/sources/", json={ - "name": "Test Source", - "type": "local", - "config": {"path": "/tmp/test"} - }) - source_id = source_resp.json()["id"] - - await client.post("/api/jobs/", json={ - "name": "Test Job", - "source_id": source_id, - "strategy": "full", - "destination_path": "/tmp/backups", - "exclude_patterns": [], - "enabled": True - }) - - response = await client.get("/api/jobs/") - assert response.status_code == 200 - data = response.json() - assert len(data) >= 1 - -@pytest.mark.asyncio -async def test_get_job(client): - source_resp = await client.post("/api/sources/", json={ - "name": "Test Source", - "type": "local", - "config": {"path": "/tmp/test"} - }) - source_id = source_resp.json()["id"] - - job_resp = await client.post("/api/jobs/", json={ - "name": "Test Job", - "source_id": source_id, - "strategy": "full", - "destination_path": "/tmp/backups", - "exclude_patterns": [], - "enabled": True - }) - job_id = job_resp.json()["id"] - - response = await client.get(f"/api/jobs/{job_id}") - assert response.status_code == 200 - assert response.json()["id"] == job_id - -@pytest.mark.asyncio -async def test_delete_job(client): - source_resp = await client.post("/api/sources/", json={ - "name": "Test Source", - "type": "local", - "config": {"path": "/tmp/test"} - }) - source_id = source_resp.json()["id"] - - job_resp = await client.post("/api/jobs/", json={ - "name": "Delete Me", - "source_id": source_id, - "strategy": "full", - "destination_path": "/tmp/backups", - "exclude_patterns": [], - "enabled": True - }) - job_id = job_resp.json()["id"] - - response = await client.delete(f"/api/jobs/{job_id}") - assert response.status_code == 200 - - # Verify deletion - get_resp = await client.get(f"/api/jobs/{job_id}") - assert get_resp.status_code == 404 - -@pytest.mark.asyncio -async def test_run_job(client): - # Create source - source_resp = await client.post("/api/sources/", json={ - "name": "Test Source", - "type": "local", - "config": {"path": "/tmp/test"} - }) - source_id = source_resp.json()["id"] - - # Create job - job_resp = await client.post("/api/jobs/", json={ - "name": "Test Job", - "source_id": source_id, - "strategy": "full", - "destination_path": "/tmp/backups", - "exclude_patterns": [], - "enabled": True - }) - job_id = job_resp.json()["id"] - - response = await client.post(f"/api/jobs/{job_id}/run") - assert response.status_code == 200 - assert response.json()["message"] == "Job execution started" - -@pytest.mark.asyncio -async def test_run_job_not_found(client): - response = await client.post("/api/jobs/999/run") - assert response.status_code == 404 - -@pytest.mark.asyncio -async def test_update_job(client): - # Create source - source_resp = await client.post("/api/sources/", json={ - "name": "Test Source", - "type": "local", - "config": {"path": "/tmp/test"} - }) - source_id = source_resp.json()["id"] - - # Create job - job_resp = await client.post("/api/jobs/", json={ - "name": "Original Name", - "source_id": source_id, - "strategy": "full", - "destination_path": "/tmp/backups", - "exclude_patterns": [], - "enabled": True - }) - job_id = job_resp.json()["id"] - - response = await client.put(f"/api/jobs/{job_id}", json={ - "name": "Updated Name" - }) - assert response.status_code == 200 - assert response.json()["name"] == "Updated Name" - assert response.json()["strategy"] == "full" # Unchanged - -@pytest.mark.asyncio -async def test_create_schedule(client): - # Create source - source_resp = await client.post("/api/sources/", json={ - "name": "Test Source", - "type": "local", - "config": {"path": "/tmp/test"} - }) - source_id = source_resp.json()["id"] - - # Create job - job_resp = await client.post("/api/jobs/", json={ - "name": "Test Job", - "source_id": source_id, - "strategy": "full", - "destination_path": "/tmp/backups", - "exclude_patterns": [], - "enabled": True - }) - job_id = job_resp.json()["id"] - - response = await client.post(f"/api/jobs/{job_id}/schedule", json={ - "job_id": job_id, - "cron_expression": "0 0 * * *", - "enabled": True - }) - assert response.status_code == 200 - data = response.json() - assert data["job_id"] == job_id - assert data["cron_expression"] == "0 0 * * *" - assert "id" in data - -@pytest.mark.asyncio -async def test_get_job_not_found(client): - response = await client.get("/api/jobs/99999") - assert response.status_code == 404 - -@pytest.mark.asyncio -async def test_update_job_not_found(client): - response = await client.put("/api/jobs/99999", json={"name": "Test"}) - assert response.status_code == 404 - -@pytest.mark.asyncio -async def test_delete_job_not_found(client): - response = await client.delete("/api/jobs/99999") - assert response.status_code == 404 diff --git a/backend/tests/test_sources.py b/backend/tests/test_sources.py deleted file mode 100644 index 14ff2ae..0000000 --- a/backend/tests/test_sources.py +++ /dev/null @@ -1,88 +0,0 @@ -import pytest - -@pytest.mark.asyncio -async def test_create_source(client): - response = await client.post("/api/sources/", json={ - "name": "Test Source", - "type": "local", - "config": {"path": "/tmp/test"} - }) - assert response.status_code == 200 - data = response.json() - assert data["name"] == "Test Source" - assert data["type"] == "local" - assert "id" in data - -@pytest.mark.asyncio -async def test_list_sources(client): - # Create source first - await client.post("/api/sources/", json={ - "name": "Test Source", - "type": "local", - "config": {"path": "/tmp/test"} - }) - - response = await client.get("/api/sources/") - assert response.status_code == 200 - data = response.json() - assert len(data) >= 1 - -@pytest.mark.asyncio -async def test_get_source(client): - create_resp = await client.post("/api/sources/", json={ - "name": "Test Source", - "type": "local", - "config": {"path": "/tmp/test"} - }) - source_id = create_resp.json()["id"] - - response = await client.get(f"/api/sources/{source_id}") - assert response.status_code == 200 - assert response.json()["id"] == source_id - -@pytest.mark.asyncio -async def test_delete_source(client): - create_resp = await client.post("/api/sources/", json={ - "name": "Delete Me", - "type": "local", - "config": {"path": "/tmp/test"} - }) - source_id = create_resp.json()["id"] - - response = await client.delete(f"/api/sources/{source_id}") - assert response.status_code == 200 - - # Verify deletion - get_resp = await client.get(f"/api/sources/{source_id}") - assert get_resp.status_code == 404 - -@pytest.mark.asyncio -async def test_update_source(client): - create_resp = await client.post("/api/sources/", json={ - "name": "Original Name", - "type": "local", - "config": {"path": "/tmp/test"} - }) - source_id = create_resp.json()["id"] - - response = await client.put(f"/api/sources/{source_id}", json={ - "name": "Updated Name" - }) - assert response.status_code == 200 - assert response.json()["name"] == "Updated Name" - assert response.json()["type"] == "local" # Unchanged - -@pytest.mark.asyncio -async def test_get_source_not_found(client): - response = await client.get("/api/sources/99999") - assert response.status_code == 404 - -@pytest.mark.asyncio -async def test_update_source_not_found(client): - response = await client.put("/api/sources/99999", json={"name": "Test"}) - assert response.status_code == 404 - -@pytest.mark.asyncio -async def test_delete_source_not_found(client): - response = await client.delete("/api/sources/99999") - assert response.status_code == 404 diff --git a/contracts/repository/v1/capabilities-v2.0.json b/contracts/repository/v1/capabilities-v2.0.json new file mode 100644 index 0000000..6dff906 --- /dev/null +++ b/contracts/repository/v1/capabilities-v2.0.json @@ -0,0 +1,14 @@ +{ + "api_version": "v2", + "sources": ["local", "ssh"], + "repositories": ["local"], + "features": { + "email": true, + "encryption": true, + "mysql": false, + "postgresql": false, + "restore": true, + "tar_download": false, + "webhook": true + } +} diff --git a/contracts/repository/v1/error-codes.json b/contracts/repository/v1/error-codes.json new file mode 100644 index 0000000..0a2b8cb --- /dev/null +++ b/contracts/repository/v1/error-codes.json @@ -0,0 +1,22 @@ +[ + "authentication_failed", + "baseline_missing", + "cancelled_by_operator", + "capacity_exhausted", + "conflict_active_execution", + "corrupt_blob", + "corrupt_manifest", + "deletion_failed", + "forbidden", + "host_key_changed", + "host_key_unknown", + "invalid_configuration", + "not_found", + "permission_denied", + "source_changed", + "source_empty", + "timeout", + "transient_io", + "unsupported_entry", + "worker_lost" +] diff --git a/contracts/repository/v1/execution-transitions.json b/contracts/repository/v1/execution-transitions.json new file mode 100644 index 0000000..98e0770 --- /dev/null +++ b/contracts/repository/v1/execution-transitions.json @@ -0,0 +1,10 @@ +{ + "queued": ["cancelled", "preparing"], + "preparing": ["cancelling", "failed", "running"], + "running": ["cancelling", "failed", "verifying"], + "verifying": ["committed", "failed"], + "cancelling": ["cancelled", "failed"], + "committed": [], + "failed": [], + "cancelled": [] +} diff --git a/contracts/repository/v1/fault-points.json b/contracts/repository/v1/fault-points.json new file mode 100644 index 0000000..f865e1a --- /dev/null +++ b/contracts/repository/v1/fault-points.json @@ -0,0 +1,14 @@ +[ + "blob.before_write", + "blob.after_write", + "blob.after_fsync", + "blob.before_rename", + "manifest.before_write", + "manifest.after_fsync", + "manifest.before_publish", + "metadata.before_commit", + "metadata.after_commit", + "restore.before_write", + "restore.after_fsync", + "restore.before_replace" +] diff --git a/contracts/repository/v1/fixtures/invalid-manifest.json b/contracts/repository/v1/fixtures/invalid-manifest.json new file mode 100644 index 0000000..a114da6 --- /dev/null +++ b/contracts/repository/v1/fixtures/invalid-manifest.json @@ -0,0 +1 @@ +{"aggregates":{"entry_count":-1,"logical_bytes":-5,"stored_bytes":0},"backup_id":"bad","created_at":"not-a-time","effective_mode":"full","encryption_key_id":null,"entries":[],"exclusion_policy":{"matcher":"glob","patterns":[],"version":0},"execution_id":"bad","format_version":2,"job_id":"bad","manifest_digest":"short","repository_id":"bad","requested_mode":"full","source_consistency":{},"source_id":"bad"} diff --git a/contracts/repository/v1/fixtures/invalid-repository.json b/contracts/repository/v1/fixtures/invalid-repository.json new file mode 100644 index 0000000..42df253 --- /dev/null +++ b/contracts/repository/v1/fixtures/invalid-repository.json @@ -0,0 +1 @@ +{"compression":"none","created_at":"2026-07-27T00:00:00Z","digest_algorithm":"md5","encryption":{"key_id":null,"mode":"none"},"format_version":2,"repository_id":"not-a-uuid"} diff --git a/contracts/repository/v1/fixtures/valid-manifest.json b/contracts/repository/v1/fixtures/valid-manifest.json new file mode 100644 index 0000000..f9b7da6 --- /dev/null +++ b/contracts/repository/v1/fixtures/valid-manifest.json @@ -0,0 +1 @@ +{"aggregates":{"entry_count":2,"logical_bytes":5,"stored_bytes":5},"backup_id":"0198c57f-0000-7000-8000-000000000006","created_at":"2026-07-27T00:00:00Z","effective_mode":"full","encryption_key_id":null,"entries":[{"blob_digest":null,"link_target":null,"metadata_support":["mode","mtime_ns"],"mode":493,"mtime_ns":0,"path":"data","size":0,"type":"directory"},{"blob_digest":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","link_target":null,"metadata_support":["mode","mtime_ns"],"mode":420,"mtime_ns":0,"path":"data/hello.txt","size":5,"type":"file"}],"exclusion_policy":{"matcher":"gitignore","patterns":[],"version":1},"execution_id":"0198c57f-0000-7000-8000-000000000005","format_version":1,"job_id":"0198c57f-0000-7000-8000-000000000004","manifest_digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","repository_id":"0198c57f-0000-7000-8000-000000000001","requested_mode":"full","source_consistency":{"adapter":"local","captured_at":"2026-07-27T00:00:00Z"},"source_id":"0198c57f-0000-7000-8000-000000000003"} diff --git a/contracts/repository/v1/fixtures/valid-repository.json b/contracts/repository/v1/fixtures/valid-repository.json new file mode 100644 index 0000000..8457345 --- /dev/null +++ b/contracts/repository/v1/fixtures/valid-repository.json @@ -0,0 +1 @@ +{"compression":"none","created_at":"2026-07-27T00:00:00Z","digest_algorithm":"sha256","encryption":{"key_id":null,"mode":"none"},"format_version":1,"repository_id":"0198c57f-0000-7000-8000-000000000001"} diff --git a/contracts/repository/v1/manifest.schema.json b/contracts/repository/v1/manifest.schema.json new file mode 100644 index 0000000..79ede3a --- /dev/null +++ b/contracts/repository/v1/manifest.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://backup-tool.invalid/contracts/repository/v1/manifest.schema.json", + "title": "Backup Tool Manifest v1", + "type": "object", + "additionalProperties": false, + "required": [ + "format_version", "backup_id", "repository_id", "source_id", "job_id", "execution_id", + "requested_mode", "effective_mode", "created_at", "source_consistency", "exclusion_policy", + "entries", "aggregates", "encryption_key_id", "manifest_digest" + ], + "properties": { + "format_version": {"const": 1}, + "backup_id": {"type": "string", "format": "uuid"}, + "repository_id": {"type": "string", "format": "uuid"}, + "source_id": {"type": "string", "format": "uuid"}, + "job_id": {"type": "string", "format": "uuid"}, + "execution_id": {"type": "string", "format": "uuid"}, + "requested_mode": {"enum": ["full", "incremental"]}, + "effective_mode": {"enum": ["full", "incremental"]}, + "created_at": {"type": "string", "format": "date-time"}, + "source_consistency": {"type": "object"}, + "exclusion_policy": { + "type": "object", + "additionalProperties": false, + "required": ["matcher", "version", "patterns"], + "properties": { + "matcher": {"const": "gitignore"}, + "version": {"type": "integer", "minimum": 1}, + "patterns": {"type": "array", "items": {"type": "string"}} + } + }, + "entries": { + "type": "array", + "items": {"$ref": "#/$defs/entry"} + }, + "aggregates": { + "type": "object", + "additionalProperties": false, + "required": ["entry_count", "logical_bytes", "stored_bytes"], + "properties": { + "entry_count": {"type": "integer", "minimum": 0}, + "logical_bytes": {"type": "integer", "minimum": 0}, + "stored_bytes": {"type": "integer", "minimum": 0} + } + }, + "encryption_key_id": {"type": ["string", "null"]}, + "manifest_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"} + }, + "$defs": { + "entry": { + "type": "object", + "additionalProperties": false, + "required": ["path", "type", "size", "blob_digest", "mode", "mtime_ns", "link_target", "metadata_support"], + "properties": { + "path": {"type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+$"}, + "type": {"enum": ["file", "directory", "symlink"]}, + "size": {"type": "integer", "minimum": 0}, + "blob_digest": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"}, + "mode": {"type": ["integer", "null"], "minimum": 0}, + "mtime_ns": {"type": ["integer", "null"], "minimum": 0}, + "link_target": {"type": ["string", "null"]}, + "metadata_support": {"type": "array", "items": {"type": "string"}, "uniqueItems": true} + } + } + } +} diff --git a/contracts/repository/v1/normalized-paths.json b/contracts/repository/v1/normalized-paths.json new file mode 100644 index 0000000..0f202c2 --- /dev/null +++ b/contracts/repository/v1/normalized-paths.json @@ -0,0 +1,9 @@ +[ + {"raw": "file.txt", "normalized": "file.txt", "valid": true}, + {"raw": "dir/./file.txt", "normalized": "dir/file.txt", "valid": true}, + {"raw": "unicodé/文件.txt", "normalized": "unicodé/文件.txt", "valid": true}, + {"raw": "/absolute", "normalized": null, "valid": false}, + {"raw": "../escape", "normalized": null, "valid": false}, + {"raw": "a\\b", "normalized": null, "valid": false}, + {"raw": "", "normalized": null, "valid": false} +] diff --git a/contracts/repository/v1/repository.schema.json b/contracts/repository/v1/repository.schema.json new file mode 100644 index 0000000..8093ea7 --- /dev/null +++ b/contracts/repository/v1/repository.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://backup-tool.invalid/contracts/repository/v1/repository.schema.json", + "title": "Backup Tool Repository v1", + "type": "object", + "additionalProperties": false, + "required": ["repository_id", "format_version", "digest_algorithm", "compression", "encryption", "created_at"], + "properties": { + "repository_id": {"type": "string", "format": "uuid"}, + "format_version": {"const": 1}, + "digest_algorithm": {"const": "sha256"}, + "compression": {"enum": ["none", "zstd"]}, + "encryption": { + "type": "object", + "additionalProperties": false, + "required": ["mode", "key_id"], + "properties": { + "mode": {"enum": ["none", "aes-256-gcm"]}, + "key_id": {"type": ["string", "null"], "minLength": 1} + } + }, + "created_at": {"type": "string", "format": "date-time"} + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..cc96aa3 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3670 @@ +{ + "name": "backup-tool-frontend", + "version": "2.0.0-dev.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "backup-tool-frontend", + "version": "2.0.0-dev.0", + "dependencies": { + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@testing-library/jest-dom": "7.0.0", + "@testing-library/react": "16.3.2", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "6.0.4", + "autoprefixer": "10.5.4", + "jsdom": "30.0.0", + "postcss": "8.5.23", + "tailwindcss": "3.4.19", + "typescript": "7.0.2", + "vite": "8.1.5", + "vitest": "4.1.10" + }, + "engines": { + "node": "22.17.1" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz", + "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.2.1", + "@csstools/css-color-parser": "^4.1.9", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.0.tgz", + "integrity": "sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", + "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", + "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.5.tgz", + "integrity": "sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.396", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", + "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.0.tgz", + "integrity": "sha512-JQHfRGmmKmaZoUAvIgff5jjG/0SzTQlGz8c7t72KzBzo8ZULEjAjnYE0sNwBOUA4QtWwYE2xoYitg8NFsmiYxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.2.5", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.6", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.7.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json index 3518745..e455079 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,36 +1,35 @@ { "name": "backup-tool-frontend", "private": true, - "version": "0.0.1", + "version": "2.0.0-dev.0", "type": "module", + "packageManager": "npm@10.9.2", + "engines": { + "node": "22.17.1" + }, "scripts": { "dev": "vite", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests", "build": "tsc && vite build", "preview": "vite preview" }, "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-router-dom": "^6.21.0", - "@tanstack/react-query": "^5.17.0", - "axios": "^1.6.5", - "react-hook-form": "^7.49.0", - "recharts": "^2.10.0", - "lucide-react": "^0.303.0", - "clsx": "^2.1.0" + "react": "19.2.8", + "react-dom": "19.2.8" }, "devDependencies": { - "@types/react": "^18.2.43", - "@types/react-dom": "^18.2.17", - "@vitejs/plugin-react": "^4.2.1", - "autoprefixer": "^10.4.16", - "postcss": "^8.4.32", - "tailwindcss": "^3.4.0", - "typescript": "^5.2.2", - "vite": "^5.0.8", - "vitest": "^1.1.0", - "@testing-library/react": "^14.1.0", - "@testing-library/jest-dom": "^6.2.0", - "jsdom": "^23.0.0" + "@testing-library/jest-dom": "7.0.0", + "@testing-library/react": "16.3.2", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "6.0.4", + "autoprefixer": "10.5.4", + "jsdom": "30.0.0", + "postcss": "8.5.23", + "tailwindcss": "3.4.19", + "typescript": "7.0.2", + "vite": "8.1.5", + "vitest": "4.1.10" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx deleted file mode 100644 index 02ff4f4..0000000 --- a/frontend/src/App.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { BrowserRouter, Routes, Route } from 'react-router-dom'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { Layout } from './components/Layout'; -import { Dashboard } from './pages/Dashboard'; -import { Backups } from './pages/Backups'; -import { Settings } from './pages/Settings'; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 5 * 60 * 1000, - retry: 1, - }, - }, -}); - -function App() { - return ( - - - - - } /> - } /> - } /> - - - - - ); -} - -export default App; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts deleted file mode 100644 index 3038927..0000000 --- a/frontend/src/api/client.ts +++ /dev/null @@ -1,62 +0,0 @@ -import axios from 'axios'; - -const API_BASE_URL = '/api'; - -export const apiClient = axios.create({ - baseURL: API_BASE_URL, - headers: { - 'Content-Type': 'application/json', - }, -}); - -export interface BackupSource { - id: string; - name: string; - type: string; - config: Record; - created_at: string; - updated_at: string; -} - -export interface BackupJob { - id: string; - source_id: string; - status: 'pending' | 'running' | 'completed' | 'failed'; - started_at: string | null; - completed_at: string | null; - error_message: string | null; - created_at: string; -} - -export interface DashboardStats { - total_sources: number; - total_jobs: number; - completed_jobs: number; - failed_jobs: number; - pending_jobs: number; -} - -export const sourcesApi = { - getAll: () => apiClient.get('/sources'), - getById: (id: string) => apiClient.get(`/sources/${id}`), - create: (data: Omit) => - apiClient.post('/sources', data), - update: (id: string, data: Partial) => - apiClient.put(`/sources/${id}`, data), - delete: (id: string) => apiClient.delete(`/sources/${id}`), -}; - -export const jobsApi = { - getAll: () => apiClient.get('/jobs'), - getById: (id: string) => apiClient.get(`/jobs/${id}`), - create: (sourceId: string) => - apiClient.post('/jobs', { source_id: sourceId }), - delete: (id: string) => apiClient.delete(`/jobs/${id}`), - getLogs: (id: string) => apiClient.get(`/jobs/${id}/logs`), -}; - -export const dashboardApi = { - getStats: () => apiClient.get('/dashboard/stats'), - getRecentJobs: (limit: number = 10) => - apiClient.get(`/dashboard/recent-jobs?limit=${limit}`), -}; diff --git a/frontend/src/api/generated/.gitkeep b/frontend/src/api/generated/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx new file mode 100644 index 0000000..a1f9b2f --- /dev/null +++ b/frontend/src/app/App.tsx @@ -0,0 +1,16 @@ +export function App() { + return ( +
+
+

+ Backup Tool v2 +

+

Protocol foundation ready

+

+ Operator workflows are added as their versioned API contracts become + executable. +

+
+
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx deleted file mode 100644 index 76c714e..0000000 --- a/frontend/src/components/Layout.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { Link, useLocation } from 'react-router-dom'; -import { LayoutDashboard, Archive, Settings, Menu } from 'lucide-react'; -import { useState } from 'react'; - -interface LayoutProps { - children: React.ReactNode; -} - -const navItems = [ - { path: '/', label: 'Dashboard', icon: LayoutDashboard }, - { path: '/backups', label: 'Backups', icon: Archive }, - { path: '/settings', label: 'Settings', icon: Settings }, -]; - -export function Layout({ children }: LayoutProps) { - const location = useLocation(); - const [sidebarOpen, setSidebarOpen] = useState(false); - - return ( -
- {/* Mobile sidebar overlay */} - {sidebarOpen && ( -
setSidebarOpen(false)} - /> - )} - - {/* Sidebar */} - - - {/* Main content */} -
- {/* Mobile header */} -
- - - Backup Tool - -
- -
{children}
-
-
- ); -} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 964aeb4..e3e15cc 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,17 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App' -import './index.css' +import React from "react"; +import ReactDOM from "react-dom/client"; -ReactDOM.createRoot(document.getElementById('root')!).render( - - - , -) +import { App } from "./app/App"; +import "./index.css"; + +const root = document.getElementById("root"); + +if (!root) { + throw new Error("Missing #root element"); +} + +ReactDOM.createRoot(root).render( + + + , +); diff --git a/frontend/src/pages/Backups.tsx b/frontend/src/pages/Backups.tsx deleted file mode 100644 index f19a298..0000000 --- a/frontend/src/pages/Backups.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import { useState } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Play, Trash2, Plus } from 'lucide-react'; -import { jobsApi, sourcesApi } from '../api/client'; -import type { BackupJob, BackupSource } from '../api/client'; - -function StatusBadge({ status }: { status: string }) { - const styles = { - pending: 'bg-yellow-100 text-yellow-800', - running: 'bg-blue-100 text-blue-800', - completed: 'bg-green-100 text-green-800', - failed: 'bg-red-100 text-red-800', - }; - return ( - - {status} - - ); -} - -function CreateJobModal({ - onClose, -}: { - onClose: () => void; -}) { - const queryClient = useQueryClient(); - const [sourceId, setSourceId] = useState(''); - - const { data: sources } = useQuery({ - queryKey: ['sources'], - queryFn: () => sourcesApi.getAll().then((res) => res.data), - }); - - const createMutation = useMutation({ - mutationFn: (sid: string) => jobsApi.create(sid), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['jobs'] }); - onClose(); - }, - }); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (sourceId) { - createMutation.mutate(sourceId); - } - }; - - return ( -
-
-
-

- Create Backup Job -

-
-
-
- - -
-
- - -
-
-
-
- ); -} - -export function Backups() { - const [showModal, setShowModal] = useState(false); - const queryClient = useQueryClient(); - - const { data: jobs, isLoading } = useQuery({ - queryKey: ['jobs'], - queryFn: () => jobsApi.getAll().then((res) => res.data), - }); - - const { data: sources } = useQuery({ - queryKey: ['sources'], - queryFn: () => sourcesApi.getAll().then((res) => res.data), - }); - - const deleteMutation = useMutation({ - mutationFn: (id: string) => jobsApi.delete(id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['jobs'] }); - }, - }); - - const runMutation = useMutation({ - mutationFn: (id: string) => jobsApi.create(id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['jobs'] }); - }, - }); - - const getSourceName = (sourceId: string) => { - const source = sources?.find((s: BackupSource) => s.id === sourceId); - return source?.name || sourceId.slice(0, 8); - }; - - return ( -
-
-

Backups

- -
- -
- - - - - - - - - - - - {isLoading && ( - - - - )} - {jobs?.length === 0 && !isLoading && ( - - - - )} - {jobs?.map((job: BackupJob) => ( - - - - - - - - ))} - -
- Job ID - - Source - - Status - - Started - - Actions -
- Loading... -
- No backup jobs yet. Create one to get started. -
- {job.id.slice(0, 8)} - - {getSourceName(job.source_id)} - - - - {job.started_at - ? new Date(job.started_at).toLocaleString() - : 'Not started'} - -
- - -
-
-
- - {showModal && setShowModal(false)} />} -
- ); -} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx deleted file mode 100644 index ffcd5de..0000000 --- a/frontend/src/pages/Dashboard.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { Activity, Archive, AlertTriangle, CheckCircle } from 'lucide-react'; -import { dashboardApi } from '../api/client'; - -function StatCard({ - title, - value, - icon: Icon, - color, -}: { - title: string; - value: number; - icon: React.ElementType; - color: string; -}) { - return ( -
-
-
-

{title}

-

{value}

-
-
- -
-
-
- ); -} - -function StatusBadge({ status }: { status: string }) { - const styles = { - pending: 'bg-yellow-100 text-yellow-800', - running: 'bg-blue-100 text-blue-800', - completed: 'bg-green-100 text-green-800', - failed: 'bg-red-100 text-red-800', - }; - return ( - - {status} - - ); -} - -export function Dashboard() { - const { data: stats } = useQuery({ - queryKey: ['dashboard-stats'], - queryFn: () => dashboardApi.getStats().then((res) => res.data), - }); - - const { data: recentJobs } = useQuery({ - queryKey: ['recent-jobs'], - queryFn: () => dashboardApi.getRecentJobs(5).then((res) => res.data), - }); - - const activeJobs = stats?.pending_jobs || 0; - const recentFailures = stats?.failed_jobs || 0; - - return ( -
-

Dashboard

- -
- - - - -
- -
-
-

- Recent Activity -

-
-
- {recentJobs?.length === 0 && ( -
- No recent activity -
- )} - {recentJobs?.map((job) => ( -
-
-
-
-

- Job {job.id.slice(0, 8)} -

-

- {job.created_at - ? new Date(job.created_at).toLocaleString() - : 'Unknown'} -

-
-
- -
- ))} -
-
-
- ); -} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx deleted file mode 100644 index 5076a7f..0000000 --- a/frontend/src/pages/Settings.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { useState } from 'react'; - -const tabs = [ - { id: 'general', label: 'General' }, - { id: 'notifications', label: 'Notifications' }, - { id: 'security', label: 'Security' }, - { id: 'logs', label: 'Logs' }, -]; - -function GeneralSettings() { - return ( -
-
- - -
-
- - -
-
- - -
-
- ); -} - -function NotificationSettings() { - return ( -
-
- - -
-
- - -
-
- - -
-
- ); -} - -function SecuritySettings() { - return ( -
-
- - -
-
- - -
-
- - -
-
- ); -} - -function LogSettings() { - return ( -
-
- - -
-
- - -
-
- - -
-
- ); -} - -export function Settings() { - const [activeTab, setActiveTab] = useState('general'); - - const tabContent = { - general: , - notifications: , - security: , - logs: , - }; - - return ( -
-

Settings

- -
-
- -
-
{tabContent[activeTab as keyof typeof tabContent]}
-
-
- ); -} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tests/contract/test_no_v1.py b/tests/contract/test_no_v1.py index 7691090..e907778 100644 --- a/tests/contract/test_no_v1.py +++ b/tests/contract/test_no_v1.py @@ -1,9 +1,8 @@ from __future__ import annotations -from pathlib import Path import subprocess import sys - +from pathlib import Path ROOT = Path(__file__).parents[2] diff --git a/tests/contract/test_repository_format.py b/tests/contract/test_repository_format.py index 1fddcff..6aed68d 100644 --- a/tests/contract/test_repository_format.py +++ b/tests/contract/test_repository_format.py @@ -7,7 +7,6 @@ from typing import Any import jsonschema import pytest - ROOT = Path(__file__).parents[2] CONTRACT = ROOT / "contracts" / "repository" / "v1" FIXTURES = CONTRACT / "fixtures" diff --git a/tests/e2e/.gitkeep b/tests/e2e/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fault/.gitkeep b/tests/fault/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/.gitkeep b/tests/fixtures/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/.gitkeep b/tests/integration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/performance/.gitkeep b/tests/performance/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/security/.gitkeep b/tests/security/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/.gitkeep b/tests/unit/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tools/forbidden_v1_scan.py b/tools/forbidden_v1_scan.py new file mode 100644 index 0000000..66cd07d --- /dev/null +++ b/tools/forbidden_v1_scan.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Reject executable v1 compatibility paths and symbols from the v2 tree.""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +LEGACY_PATHS = ( + Path("backend/app"), + Path("backend/backup"), + Path("backend/tests"), + Path("frontend/src/api/client.ts"), +) +SCAN_ROOTS = (Path("backend/src"), Path("frontend/src"), Path("openapi")) +TEXT_SUFFIXES = {".json", ".py", ".ts", ".tsx", ".yaml", ".yml"} +FORBIDDEN = ( + re.compile(r"/api/v1(?:/|\b)"), + re.compile(r"\blegacy_(?:reader|importer?|converter?)\b", re.IGNORECASE), + re.compile(r"\btimestamp_directory\b", re.IGNORECASE), + re.compile(r"\b(?:app\.main|backup\.engine)\b"), +) + + +def scan(root: Path) -> list[str]: + findings: list[str] = [] + for relative in LEGACY_PATHS: + if (root / relative).exists(): + findings.append(f"legacy path exists: {relative}") + + for relative_root in SCAN_ROOTS: + scan_root = root / relative_root + if not scan_root.exists(): + continue + for path in sorted(scan_root.rglob("*")): + if not path.is_file() or path.suffix not in TEXT_SUFFIXES: + continue + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + findings.append(f"cannot read {path.relative_to(root)}: {error}") + continue + for pattern in FORBIDDEN: + if pattern.search(text): + findings.append( + f"forbidden symbol {pattern.pattern!r}: {path.relative_to(root)}" + ) + return findings + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("root", nargs="?", default=".", type=Path) + args = parser.parse_args() + root = args.root.resolve() + findings = scan(root) + if findings: + print("v1 compatibility scan failed:", file=sys.stderr) + for finding in findings: + print(f"- {finding}", file=sys.stderr) + return 1 + print("v1 compatibility scan: OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())