From 99d38fbbb968764ff280e588122136aa0cf2f9e0 Mon Sep 17 00:00:00 2001 From: Vanshika Mittal <39671162+Vanshika-Mittal@users.noreply.github.com> Date: Thu, 5 Feb 2026 12:36:16 +0530 Subject: [PATCH 01/38] Create README --- README.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..31915a4 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +--- + +# General Instructions + +You are supposed to **fork this repo** and work on the tasks that will come out regularly. + +Make sure you **create a README** for each task describing what you have done and what you understand. + +--- + +# Objective + +"Briefly describe **what this task is about** and **what you aimed to achieve**." + +--- + +## Steps / Implementation + +List the **exact steps you followed** to complete the task, including important commands or configurations. + + +--- + +## Observations / Outputs + +Include **screenshots**, **logs**, or **terminal outputs** that prove your setup works. + +--- + +## Issues Faced & Fixes + +Describe **any challenges or bugs** you encountered and **how you solved them**. + +--- + +## Presentation Note + +Be ready to **explain your approach and design choices** during meets — the team may ask questions to test your understanding. + +--- From 07c7fafd324d7e275121a58a11890983690ea0bf Mon Sep 17 00:00:00 2001 From: Vanshika Mittal <39671162+Vanshika-Mittal@users.noreply.github.com> Date: Thu, 5 Feb 2026 13:14:22 +0530 Subject: [PATCH 02/38] Create Task-1.md --- Task-1/Task-1.md | 110 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 Task-1/Task-1.md diff --git a/Task-1/Task-1.md b/Task-1/Task-1.md new file mode 100644 index 0000000..921b5c0 --- /dev/null +++ b/Task-1/Task-1.md @@ -0,0 +1,110 @@ +# Task: Building a Production-Style Containerized To-Do Application + +## Goal: +Build a containerized full-stack web application that exposes a REST API, serves a modern frontend, and uses Docker, Docker Compose, and Nginx following best practices for service isolation, networking, and configuration. + +The task focuses on understanding how frontend, backend, and infrastructure components interact in real-world systems rather than just building a working UI. + +## Deliverables: +A full-stack To-Do application consisting of: + +- A REST API backend for managing tasks +- A frontend web application for interacting with the API +- A containerized environment using Docker and Docker Compose +- A reverse proxy that exposes the application cleanly to users + +All components must run inside containers and communicate over Docker-managed networks. + +## 1. Backend: To-Do API Service +**Objective**: +- Implement a backend service that manages to-do items and exposes a RESTful API using Django + Django REST Framework, backed by a PostgreSQL database for persistent storage. + +**Requirements**: +- Design a relational data model for a to-do item stored in PostgreSQL +- The model should support basic task attributes (content, status, timestamps, etc.) +- Database migrations must be part of the setup workflow +- Expose REST endpoints to: + - Create a to-do + - Fetch all to-dos + - Update a to-do + - Delete a to-do +- Use environment variables for configuration +- Ensure the API is designed to be consumed by an external frontend +- Include a basic health check endpoint to verify service availability and database connectivity + +**Expected Output:** +- A fully functional Django REST API backed by PostgreSQL +- Database schema created and managed via migrations +- Clear documentation of API endpoints + +## 2. Frontend: To-Do Web Application +**Objective**: + +Build a frontend application that consumes the backend API and provides an interactive UI for users using Vite with optional Tailwind CSS integration. + +**Requirements:** +- Display a list of to-do items retrieved from the backend +- Allow users to perform CRUD operations on to-do list items. +- Backend API URL should be configurable via environment variables +- UI/UX decisions are flexible + +**Expected Output:** +- A frontend that communicates only via the API +- No backend logic embedded in frontend code + +## 3. Containerization of Services +**Objective:** + +Package frontend and backend as independent Docker services. + +**Requirements:** + +- Create separate Dockerfiles for: + - Backend service + - Frontend service +- PostgreSQL should run as a dedicated container +- Each container should: + - Be reproducible + - Run independently + - Use environment variables for configuration + - Avoid hardcoded secrets or URLs + +**Expected Output:** +- Two Docker images that can be built and run independently +- Clean, readable Dockerfiles + +## 4. Service Orchestration with Docker Compose +**Objective:** + +Run the entire application stack using a single orchestration configuration using Docker Compose. + +**Requirements:** + +- Define services for: + - Backend (Django + DRF) + - Frontend (Vite) + - Database (PostgreSQL) + - Reverse proxy (Nginx) +- Use: + - Custom Docker networks for service communication + - Named volumes for PostgreSQL data persistence +- Ensure internal services communicate via Docker networking +- Expose only necessary ports to the host system + +**Expected Output:** +- A docker-compose.yml file that brings up the full system with one command + +## 5. Reverse Proxy & Routing +**Objective:** +Expose the application through a single entry point using a reverse proxy (Nginx). + +**Requirements:** + +- Configure routing such that: + - API requests are forwarded to the backend service + - Frontend traffic is served correctly + - Backend and PostgreSQL services should not be directly exposed to users + +**Expected Output:** +- Application accessible through a single URL +- Clear separation between internal services and public access From 848529e924d4a9db3c85bb9c013e42a73c1193f5 Mon Sep 17 00:00:00 2001 From: vivin dsouza Date: Tue, 17 Mar 2026 18:48:57 +0530 Subject: [PATCH 03/38] feat(auth) : added jwt authentication --- .gitignore | 3 +- backend/authentication/admin.py | 16 +- .../authentication/migrations/0001_initial.py | 45 +++ backend/authentication/models.py | 18 +- backend/authentication/permissions.py | 16 ++ backend/authentication/serializers.py | 30 ++ backend/authentication/tests.py | 267 +++++++++++++++++- backend/authentication/urls.py | 10 + backend/authentication/views.py | 37 ++- backend/core/settings.py | 64 ++++- backend/core/urls.py | 3 +- backend/requirements.txt | 5 +- docker-compose.yml | 28 ++ frontend/src/App.jsx | 75 +++-- frontend/src/components/Dashboard.jsx | 43 +++ frontend/src/components/Login.jsx | 59 ++++ frontend/src/components/Register.jsx | 126 +++++++++ frontend/src/context/AuthContext.jsx | 39 +++ frontend/src/index.css | 69 +---- frontend/src/services/authService.js | 64 +++++ frontend/vite.config.js | 15 +- 21 files changed, 915 insertions(+), 117 deletions(-) create mode 100644 backend/authentication/migrations/0001_initial.py create mode 100644 backend/authentication/permissions.py create mode 100644 backend/authentication/serializers.py create mode 100644 backend/authentication/urls.py create mode 100644 docker-compose.yml create mode 100644 frontend/src/components/Dashboard.jsx create mode 100644 frontend/src/components/Login.jsx create mode 100644 frontend/src/components/Register.jsx create mode 100644 frontend/src/context/AuthContext.jsx create mode 100644 frontend/src/services/authService.js diff --git a/.gitignore b/.gitignore index 320bfc2..f17a534 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ dist-ssr *.ntvs* *.njsproj *.sln -*.sw? \ No newline at end of file +*.sw? +.env \ No newline at end of file diff --git a/backend/authentication/admin.py b/backend/authentication/admin.py index 8c38f3f..0f6dd51 100644 --- a/backend/authentication/admin.py +++ b/backend/authentication/admin.py @@ -1,3 +1,17 @@ from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.contrib.auth import get_user_model -# Register your models here. +User = get_user_model() + + +@admin.register(User) +class CustomUserAdmin(UserAdmin): + list_display = ['username', 'email', 'role', 'is_staff', 'is_active'] + list_filter = ['role', 'is_staff', 'is_active'] + fieldsets = UserAdmin.fieldsets + ( + ('Role', {'fields': ('role',)}), + ) + add_fieldsets = UserAdmin.add_fieldsets + ( + ('Role', {'fields': ('role',)}), + ) diff --git a/backend/authentication/migrations/0001_initial.py b/backend/authentication/migrations/0001_initial.py new file mode 100644 index 0000000..5f2fe00 --- /dev/null +++ b/backend/authentication/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated migration for custom User model with role field + +from django.db import migrations, models +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('role', models.CharField(choices=[('admin', 'Admin'), ('host', 'Host'), ('viewer', 'Viewer')], default='viewer', max_length=10)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 71a8362..bd2b605 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -1,3 +1,19 @@ +from django.contrib.auth.models import AbstractUser from django.db import models -# Create your models here. + +class User(AbstractUser): + ADMIN = 'admin' + HOST = 'host' + VIEWER = 'viewer' + + ROLE_CHOICES = [ + (ADMIN, 'Admin'), + (HOST, 'Host'), + (VIEWER, 'Viewer'), + ] + + role = models.CharField(max_length=10, choices=ROLE_CHOICES, default=VIEWER) + + def __str__(self): + return f"{self.username} ({self.role})" diff --git a/backend/authentication/permissions.py b/backend/authentication/permissions.py new file mode 100644 index 0000000..0bc7457 --- /dev/null +++ b/backend/authentication/permissions.py @@ -0,0 +1,16 @@ +from rest_framework import permissions + + +class IsAdmin(permissions.BasePermission): + def has_permission(self, request, view): + return request.user.is_authenticated and request.user.role == 'admin' + + +class IsHost(permissions.BasePermission): + def has_permission(self, request, view): + return request.user.is_authenticated and request.user.role in ['admin', 'host'] + + +class IsViewer(permissions.BasePermission): + def has_permission(self, request, view): + return request.user.is_authenticated and request.user.role in ['admin', 'host', 'viewer'] diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py new file mode 100644 index 0000000..66a5289 --- /dev/null +++ b/backend/authentication/serializers.py @@ -0,0 +1,30 @@ +from rest_framework import serializers +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ['id', 'username', 'email', 'role', 'first_name', 'last_name'] + read_only_fields = ['id'] + + +class RegisterSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True, min_length=8) + + class Meta: + model = User + fields = ['username', 'email', 'password', 'role', 'first_name', 'last_name'] + + def create(self, validated_data): + user = User.objects.create_user( + username=validated_data['username'], + email=validated_data.get('email', ''), + password=validated_data['password'], + role=validated_data.get('role', User.VIEWER), + first_name=validated_data.get('first_name', ''), + last_name=validated_data.get('last_name', '') + ) + return user diff --git a/backend/authentication/tests.py b/backend/authentication/tests.py index 7ce503c..732ebac 100644 --- a/backend/authentication/tests.py +++ b/backend/authentication/tests.py @@ -1,3 +1,268 @@ from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient +from rest_framework import status -# Create your tests here. +User = get_user_model() + + +class UserModelTest(TestCase): + """Test custom User model""" + + def test_create_user_with_role(self): + user = User.objects.create_user( + username='testuser', + email='test@example.com', + password='testpass123', + role='viewer' + ) + self.assertEqual(user.username, 'testuser') + self.assertEqual(user.role, 'viewer') + self.assertTrue(user.check_password('testpass123')) + + def test_default_role_is_viewer(self): + user = User.objects.create_user( + username='testuser', + password='testpass123' + ) + self.assertEqual(user.role, 'viewer') + + def test_user_string_representation(self): + user = User.objects.create_user( + username='testuser', + password='testpass123', + role='admin' + ) + self.assertEqual(str(user), 'testuser (admin)') + + +class RegistrationTest(TestCase): + """Test user registration endpoint""" + + def setUp(self): + self.client = APIClient() + self.register_url = '/api/auth/register/' + + def test_register_user_success(self): + data = { + 'username': 'newuser', + 'email': 'new@example.com', + 'password': 'newpass123', + 'role': 'host', + 'first_name': 'New', + 'last_name': 'User' + } + response = self.client.post(self.register_url, data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(User.objects.count(), 1) + self.assertEqual(User.objects.get().username, 'newuser') + self.assertEqual(User.objects.get().role, 'host') + + def test_register_user_default_role(self): + data = { + 'username': 'newuser', + 'email': 'new@example.com', + 'password': 'newpass123' + } + response = self.client.post(self.register_url, data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(User.objects.get().role, 'viewer') + + def test_register_user_missing_username(self): + data = { + 'email': 'new@example.com', + 'password': 'newpass123' + } + response = self.client.post(self.register_url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_register_user_short_password(self): + data = { + 'username': 'newuser', + 'password': 'short' + } + response = self.client.post(self.register_url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_register_duplicate_username(self): + User.objects.create_user(username='existing', password='pass123') + data = { + 'username': 'existing', + 'password': 'newpass123' + } + response = self.client.post(self.register_url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + +class LoginTest(TestCase): + """Test JWT login endpoint""" + + def setUp(self): + self.client = APIClient() + self.login_url = '/api/auth/login/' + self.user = User.objects.create_user( + username='testuser', + email='test@example.com', + password='testpass123', + role='admin' + ) + + def test_login_success(self): + data = { + 'username': 'testuser', + 'password': 'testpass123' + } + response = self.client.post(self.login_url, data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('access', response.data) + self.assertIn('refresh', response.data) + self.assertEqual(response.data['role'], 'admin') + self.assertEqual(response.data['username'], 'testuser') + self.assertEqual(response.data['email'], 'test@example.com') + + def test_login_wrong_password(self): + data = { + 'username': 'testuser', + 'password': 'wrongpass' + } + response = self.client.post(self.login_url, data) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_login_nonexistent_user(self): + data = { + 'username': 'nonexistent', + 'password': 'testpass123' + } + response = self.client.post(self.login_url, data) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_login_missing_credentials(self): + response = self.client.post(self.login_url, {}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + +class TokenRefreshTest(TestCase): + """Test JWT token refresh endpoint""" + + def setUp(self): + self.client = APIClient() + self.login_url = '/api/auth/login/' + self.refresh_url = '/api/auth/token/refresh/' + self.user = User.objects.create_user( + username='testuser', + password='testpass123' + ) + + def test_refresh_token_success(self): + # Login to get tokens + login_response = self.client.post(self.login_url, { + 'username': 'testuser', + 'password': 'testpass123' + }) + refresh_token = login_response.data['refresh'] + + # Refresh the token + response = self.client.post(self.refresh_url, { + 'refresh': refresh_token + }) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('access', response.data) + + def test_refresh_token_invalid(self): + response = self.client.post(self.refresh_url, { + 'refresh': 'invalid_token' + }) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + +class UserProfileTest(TestCase): + """Test user profile endpoint""" + + def setUp(self): + self.client = APIClient() + self.profile_url = '/api/auth/profile/' + self.login_url = '/api/auth/login/' + self.user = User.objects.create_user( + username='testuser', + email='test@example.com', + password='testpass123', + role='host', + first_name='Test', + last_name='User' + ) + + def test_get_profile_authenticated(self): + # Login to get access token + login_response = self.client.post(self.login_url, { + 'username': 'testuser', + 'password': 'testpass123' + }) + access_token = login_response.data['access'] + + # Get profile + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {access_token}') + response = self.client.get(self.profile_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['username'], 'testuser') + self.assertEqual(response.data['email'], 'test@example.com') + self.assertEqual(response.data['role'], 'host') + self.assertEqual(response.data['first_name'], 'Test') + self.assertEqual(response.data['last_name'], 'User') + + def test_get_profile_unauthenticated(self): + response = self.client.get(self.profile_url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_update_profile(self): + # Login + login_response = self.client.post(self.login_url, { + 'username': 'testuser', + 'password': 'testpass123' + }) + access_token = login_response.data['access'] + + # Update profile + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {access_token}') + response = self.client.patch(self.profile_url, { + 'first_name': 'Updated', + 'last_name': 'Name' + }) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['first_name'], 'Updated') + self.assertEqual(response.data['last_name'], 'Name') + + # Verify in database + self.user.refresh_from_db() + self.assertEqual(self.user.first_name, 'Updated') + self.assertEqual(self.user.last_name, 'Name') + + +class PermissionsTest(TestCase): + """Test role-based permissions""" + + def setUp(self): + self.client = APIClient() + self.admin_user = User.objects.create_user( + username='admin', + password='pass123', + role='admin' + ) + self.host_user = User.objects.create_user( + username='host', + password='pass123', + role='host' + ) + self.viewer_user = User.objects.create_user( + username='viewer', + password='pass123', + role='viewer' + ) + + def test_admin_role_assignment(self): + self.assertEqual(self.admin_user.role, 'admin') + + def test_host_role_assignment(self): + self.assertEqual(self.host_user.role, 'host') + + def test_viewer_role_assignment(self): + self.assertEqual(self.viewer_user.role, 'viewer') diff --git a/backend/authentication/urls.py b/backend/authentication/urls.py new file mode 100644 index 0000000..31ee588 --- /dev/null +++ b/backend/authentication/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from rest_framework_simplejwt.views import TokenRefreshView +from .views import CustomTokenObtainPairView, RegisterView, UserProfileView + +urlpatterns = [ + path('register/', RegisterView.as_view(), name='register'), + path('login/', CustomTokenObtainPairView.as_view(), name='login'), + path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('profile/', UserProfileView.as_view(), name='profile'), +] diff --git a/backend/authentication/views.py b/backend/authentication/views.py index 91ea44a..2d2d8aa 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -1,3 +1,36 @@ -from django.shortcuts import render +from rest_framework import generics, status +from rest_framework.response import Response +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework_simplejwt.views import TokenObtainPairView +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer +from django.contrib.auth import get_user_model +from .serializers import RegisterSerializer, UserSerializer -# Create your views here. +User = get_user_model() + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + def validate(self, attrs): + data = super().validate(attrs) + data['role'] = self.user.role + data['username'] = self.user.username + data['email'] = self.user.email + return data + + +class CustomTokenObtainPairView(TokenObtainPairView): + serializer_class = CustomTokenObtainPairSerializer + + +class RegisterView(generics.CreateAPIView): + queryset = User.objects.all() + permission_classes = [AllowAny] + serializer_class = RegisterSerializer + + +class UserProfileView(generics.RetrieveUpdateAPIView): + permission_classes = [IsAuthenticated] + serializer_class = UserSerializer + + def get_object(self): + return self.request.user diff --git a/backend/core/settings.py b/backend/core/settings.py index bf979e4..28395c9 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -10,11 +10,33 @@ https://docs.djangoproject.com/en/6.0/ref/settings/ """ +import os from pathlib import Path +from django.core.exceptions import ImproperlyConfigured +from dotenv import load_dotenv + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent +# Load environment variables from repo root .env +load_dotenv(BASE_DIR.parent / ".env") + + +def get_env(name: str) -> str: + value = os.getenv(name) + if value is None or value == "": + raise ImproperlyConfigured(f"Missing required env var: {name}") + return value + + +def get_bool_env(name: str) -> bool: + return get_env(name).strip().lower() in {"1", "true", "yes", "on"} + + +def get_list_env(name: str) -> list[str]: + return [item.strip() for item in get_env(name).split(",") if item.strip()] + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ @@ -23,9 +45,9 @@ SECRET_KEY = 'django-insecure-te&ou5qu*=p=dg_s96^#tb7xwegvzz@l&(m!kwnigflsa1pg#*' # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = get_bool_env("DJANGO_DEBUG") -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = get_list_env("DJANGO_ALLOWED_HOSTS") # Application definition @@ -39,6 +61,7 @@ 'django.contrib.staticfiles', 'authentication', 'rest_framework', + 'rest_framework_simplejwt', 'corsheaders', ] @@ -77,9 +100,13 @@ # https://docs.djangoproject.com/en/6.0/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": get_env("POSTGRES_DB"), + "USER": get_env("POSTGRES_USER"), + "PASSWORD": get_env("POSTGRES_PASSWORD"), + "HOST": get_env("POSTGRES_HOST"), + "PORT": get_env("POSTGRES_PORT"), } } @@ -120,4 +147,29 @@ STATIC_URL = 'static/' -CORS_ALLOW_ALL_ORIGINS = True \ No newline at end of file +CORS_ALLOW_ALL_ORIGINS = True + + +# Custom User Model +AUTH_USER_MODEL = 'authentication.User' + +# REST Framework Configuration +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], +} + +# JWT Configuration +from datetime import timedelta + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(hours=1), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), + 'ROTATE_REFRESH_TOKENS': True, + 'BLACKLIST_AFTER_ROTATION': True, + 'AUTH_HEADER_TYPES': ('Bearer',), +} diff --git a/backend/core/urls.py b/backend/core/urls.py index 3667112..e14cd97 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -15,8 +15,9 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin -from django.urls import path +from django.urls import path, include urlpatterns = [ path('admin/', admin.site.urls), + path('api/auth/', include('authentication.urls')), ] diff --git a/backend/requirements.txt b/backend/requirements.txt index 4958795..6e0374a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,4 +2,7 @@ asgiref==3.11.1 Django==6.0.3 django-cors-headers==4.9.0 djangorestframework==3.16.1 -sqlparse==0.5.5 \ No newline at end of file +djangorestframework-simplejwt==5.3.1 +psycopg2-binary==2.9.9 +python-dotenv==1.0.1 +sqlparse==0.5.5 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..88cfd40 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + postgres: + image: postgres:16 + container_name: di-postgres + restart: unless-stopped + ports: + - "${POSTGRES_PORT}:5432" + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + + pgadmin: + image: dpage/pgadmin4:8 + container_name: di-pgadmin + restart: unless-stopped + ports: + - "${PGADMIN_PORT}:80" + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD} + depends_on: + - postgres + +volumes: + postgres_data: diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f67355a..78289b1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,35 +1,50 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' -import './App.css' +import { useState } from 'react'; +import { AuthProvider, useAuth } from './context/AuthContext'; +import Login from './components/Login'; +import Register from './components/Register'; +import Dashboard from './components/Dashboard'; -function App() { - const [count, setCount] = useState(0) +function AppContent() { + const { user, loading } = useAuth(); + const [showRegister, setShowRegister] = useState(false); - return ( - <> -
- - Vite logo - - - React logo - -
-

Vite + React

-
- -

- Edit src/App.jsx and save to test HMR -

+ if (loading) { + return
Loading...
; + } + + if (!user) { + return ( +
+
+

Docker Integration Host

+

Manage containers across multiple hosts

+
+ +
+ +
+ + {showRegister ? : }
-

- Click on the Vite and React logos to learn more -

- - ) + ); + } + + return ( +
+ +
+ ); } -export default App +export default function App() { + return ( + + + + ); +} diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx new file mode 100644 index 0000000..bd69f4f --- /dev/null +++ b/frontend/src/components/Dashboard.jsx @@ -0,0 +1,43 @@ +import { useAuth } from '../context/AuthContext'; + +export default function Dashboard() { + const { user, logout } = useAuth(); + + return ( +
+
+
+

Dashboard

+ +
+ +
+

User Information

+

Username: {user?.username}

+

Email: {user?.email}

+

Role: {user?.role}

+
+ +
+
+

Containers

+

Manage your containers

+
+
+

Networks

+

Configure networks

+
+
+

Hosts

+

Manage remote hosts

+
+
+
+
+ ); +} diff --git a/frontend/src/components/Login.jsx b/frontend/src/components/Login.jsx new file mode 100644 index 0000000..698e0ac --- /dev/null +++ b/frontend/src/components/Login.jsx @@ -0,0 +1,59 @@ +import { useState } from 'react'; +import { useAuth } from '../context/AuthContext'; + +export default function Login() { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const { login } = useAuth(); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + setLoading(true); + try { + await login(username, password); + } catch (err) { + setError(err.detail || 'Login failed'); + } finally { + setLoading(false); + } + }; + + return ( +
+

Login

+ {error &&
{error}
} +
+
+ + setUsername(e.target.value)} + className="w-full p-2 border rounded" + required + /> +
+
+ + setPassword(e.target.value)} + className="w-full p-2 border rounded" + required + /> +
+ +
+
+ ); +} diff --git a/frontend/src/components/Register.jsx b/frontend/src/components/Register.jsx new file mode 100644 index 0000000..7bf42f2 --- /dev/null +++ b/frontend/src/components/Register.jsx @@ -0,0 +1,126 @@ +import { useState } from 'react'; +import { useAuth } from '../context/AuthContext'; + +export default function Register() { + const [formData, setFormData] = useState({ + username: '', + email: '', + password: '', + role: 'viewer', + first_name: '', + last_name: '', + }); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + const [loading, setLoading] = useState(false); + const { register } = useAuth(); + + const handleChange = (e) => { + setFormData({ ...formData, [e.target.name]: e.target.value }); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + setLoading(true); + try { + await register(formData); + setSuccess(true); + } catch (err) { + setError(JSON.stringify(err)); + } finally { + setLoading(false); + } + }; + + if (success) { + return ( +
+

Registration Successful!

+

You can now login with your credentials.

+
+ ); + } + + return ( +
+

Register

+ {error &&
{error}
} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ ); +} diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx new file mode 100644 index 0000000..a95819b --- /dev/null +++ b/frontend/src/context/AuthContext.jsx @@ -0,0 +1,39 @@ +import { createContext, useContext, useState, useEffect } from 'react'; +import { authService } from '../services/authService'; + +const AuthContext = createContext(null); + +export const AuthProvider = ({ children }) => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const user = authService.getUser(); + setUser(user); + setLoading(false); + }, []); + + const login = async (username, password) => { + const data = await authService.login(username, password); + setUser({ username: data.username, email: data.email, role: data.role }); + return data; + }; + + const register = async (userData) => { + const data = await authService.register(userData); + return data; + }; + + const logout = () => { + authService.logout(); + setUser(null); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => useContext(AuthContext); diff --git a/frontend/src/index.css b/frontend/src/index.css index 08a3ac9..f1d8c73 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,68 +1 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} +@import "tailwindcss"; diff --git a/frontend/src/services/authService.js b/frontend/src/services/authService.js new file mode 100644 index 0000000..b929e4b --- /dev/null +++ b/frontend/src/services/authService.js @@ -0,0 +1,64 @@ +const API_URL = 'http://localhost:8000/api/auth'; + +export const authService = { + async register(userData) { + const response = await fetch(`${API_URL}/register/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(userData), + }); + if (!response.ok) throw await response.json(); + return response.json(); + }, + + async login(username, password) { + const response = await fetch(`${API_URL}/login/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + if (!response.ok) throw await response.json(); + const data = await response.json(); + localStorage.setItem('access_token', data.access); + localStorage.setItem('refresh_token', data.refresh); + localStorage.setItem('user', JSON.stringify({ username: data.username, email: data.email, role: data.role })); + return data; + }, + + async getProfile() { + const token = localStorage.getItem('access_token'); + const response = await fetch(`${API_URL}/profile/`, { + headers: { 'Authorization': `Bearer ${token}` }, + }); + if (!response.ok) throw await response.json(); + return response.json(); + }, + + async refreshToken() { + const refresh = localStorage.getItem('refresh_token'); + const response = await fetch(`${API_URL}/token/refresh/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh }), + }); + if (!response.ok) throw await response.json(); + const data = await response.json(); + localStorage.setItem('access_token', data.access); + return data; + }, + + logout() { + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + localStorage.removeItem('user'); + }, + + getUser() { + const user = localStorage.getItem('user'); + return user ? JSON.parse(user) : null; + }, + + isAuthenticated() { + return !!localStorage.getItem('access_token'); + }, +}; diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 3d15f68..cf2612f 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -2,10 +2,15 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' -// https://vite.dev/config/ export default defineConfig({ - plugins: [ - react(), - tailwindcss(), - ], + plugins: [react(), tailwindcss()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, }) From 58768bde83e45f0a45682d31454868359dd20d8f Mon Sep 17 00:00:00 2001 From: DevanshSharma351 Date: Fri, 20 Mar 2026 22:36:00 +0530 Subject: [PATCH 04/38] feat: project scaffold with dependencies --- backend/.gitignore | 4 ++ backend/core/__init__.py | 0 backend/core/asgi.py | 16 ++++++ backend/core/settings.py | 117 +++++++++++++++++++++++++++++++++++++++ backend/core/urls.py | 22 ++++++++ backend/core/wsgi.py | 16 ++++++ backend/manage.py | 22 ++++++++ backend/requirements.txt | 62 +++++++++++++++++++++ 8 files changed, 259 insertions(+) create mode 100644 backend/.gitignore create mode 100644 backend/core/__init__.py create mode 100644 backend/core/asgi.py create mode 100644 backend/core/settings.py create mode 100644 backend/core/urls.py create mode 100644 backend/core/wsgi.py create mode 100755 backend/manage.py create mode 100644 backend/requirements.txt diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..effa466 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,4 @@ +venv/ +*.sqlite3 +__pycache__/ +.env \ No newline at end of file diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/asgi.py b/backend/core/asgi.py new file mode 100644 index 0000000..cf099bf --- /dev/null +++ b/backend/core/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for core project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + +application = get_asgi_application() diff --git a/backend/core/settings.py b/backend/core/settings.py new file mode 100644 index 0000000..4e21829 --- /dev/null +++ b/backend/core/settings.py @@ -0,0 +1,117 @@ +""" +Django settings for core project. + +Generated by 'django-admin startproject' using Django 6.0.3. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/6.0/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-zy)5=s0d32g!f(!w7q_ngdezcde93r*2vp%d0_kuiw+7&5i2hl' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'core.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'core.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/6.0/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/6.0/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/6.0/howto/static-files/ + +STATIC_URL = 'static/' diff --git a/backend/core/urls.py b/backend/core/urls.py new file mode 100644 index 0000000..3667112 --- /dev/null +++ b/backend/core/urls.py @@ -0,0 +1,22 @@ +""" +URL configuration for core project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/6.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path('admin/', admin.site.urls), +] diff --git a/backend/core/wsgi.py b/backend/core/wsgi.py new file mode 100644 index 0000000..6d36530 --- /dev/null +++ b/backend/core/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for core project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + +application = get_wsgi_application() diff --git a/backend/manage.py b/backend/manage.py new file mode 100755 index 0000000..f2a662c --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..637f19b --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,62 @@ +amqp==5.3.1 +asgiref==3.11.1 +attrs==26.1.0 +autobahn==25.12.2 +Automat==25.4.16 +billiard==4.2.4 +cbor2==5.8.0 +celery==5.6.2 +certifi==2026.2.25 +cffi==2.0.0 +channels==4.3.2 +charset-normalizer==3.4.6 +click==8.3.1 +click-didyoumean==0.3.1 +click-plugins==1.1.1.2 +click-repl==0.3.0 +constantly==23.10.4 +cryptography==46.0.5 +daphne==4.2.1 +Django==6.0.3 +django-cors-headers==4.9.0 +djangorestframework==3.16.1 +djangorestframework-simplejwt==5.3.1 +docker==7.1.0 +hyperlink==21.0.0 +idna==3.11 +Incremental==24.11.0 +iniconfig==2.3.0 +kombu==5.6.2 +msgpack==1.1.2 +packaging==26.0 +pluggy==1.6.0 +prompt_toolkit==3.0.52 +psycopg==3.2.6 +psycopg-binary==3.2.6 +psycopg2-binary==2.9.11 +py-ubjson==0.16.1 +pyasn1==0.6.3 +pyasn1_modules==0.4.2 +pycparser==3.0 +Pygments==2.19.2 +PyJWT==2.12.1 +pyOpenSSL==26.0.0 +pytest==9.0.2 +pytest-asyncio==1.3.0 +pytest-django==4.12.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.0.1 +requests==2.32.5 +service-identity==24.2.0 +six==1.17.0 +sqlparse==0.5.5 +Twisted==25.5.0 +txaio==25.12.2 +typing_extensions==4.15.0 +tzdata==2025.3 +tzlocal==5.3.1 +ujson==5.12.0 +urllib3==2.6.3 +vine==5.1.0 +wcwidth==0.6.0 +zope.interface==8.2 From 900637f3df1ae85749a602ec23454e74ee8e9f4d Mon Sep 17 00:00:00 2001 From: DevanshSharma351 Date: Fri, 20 Mar 2026 22:40:50 +0530 Subject: [PATCH 05/38] feat: containers app scaffold --- backend/containers/__init__.py | 0 backend/containers/admin.py | 3 +++ backend/containers/apps.py | 5 +++++ backend/containers/auth.py | 0 backend/containers/consumers/__init__.py | 0 backend/containers/consumers/exec_consumer.py | 0 backend/containers/consumers/log_consumer.py | 0 backend/containers/docker_client.py | 0 backend/containers/migrations/__init__.py | 0 backend/containers/models.py | 3 +++ backend/containers/routing.py | 0 backend/containers/serializers.py | 0 backend/containers/services.py | 0 backend/containers/tasks.py | 0 backend/containers/tests/__init__.py | 0 backend/containers/tests/test_models.py | 0 backend/containers/tests/test_services.py | 0 backend/containers/tests/test_views.py | 0 backend/containers/urls.py | 0 backend/containers/views/__init__.py | 0 backend/containers/views/container_views.py | 0 backend/containers/views/event_views.py | 0 backend/containers/views/exec_views.py | 0 backend/containers/views/log_views.py | 0 backend/containers/views/stats_views.py | 0 25 files changed, 11 insertions(+) create mode 100644 backend/containers/__init__.py create mode 100644 backend/containers/admin.py create mode 100644 backend/containers/apps.py create mode 100644 backend/containers/auth.py create mode 100644 backend/containers/consumers/__init__.py create mode 100644 backend/containers/consumers/exec_consumer.py create mode 100644 backend/containers/consumers/log_consumer.py create mode 100644 backend/containers/docker_client.py create mode 100644 backend/containers/migrations/__init__.py create mode 100644 backend/containers/models.py create mode 100644 backend/containers/routing.py create mode 100644 backend/containers/serializers.py create mode 100644 backend/containers/services.py create mode 100644 backend/containers/tasks.py create mode 100644 backend/containers/tests/__init__.py create mode 100644 backend/containers/tests/test_models.py create mode 100644 backend/containers/tests/test_services.py create mode 100644 backend/containers/tests/test_views.py create mode 100644 backend/containers/urls.py create mode 100644 backend/containers/views/__init__.py create mode 100644 backend/containers/views/container_views.py create mode 100644 backend/containers/views/event_views.py create mode 100644 backend/containers/views/exec_views.py create mode 100644 backend/containers/views/log_views.py create mode 100644 backend/containers/views/stats_views.py diff --git a/backend/containers/__init__.py b/backend/containers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/admin.py b/backend/containers/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/backend/containers/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/containers/apps.py b/backend/containers/apps.py new file mode 100644 index 0000000..eba06af --- /dev/null +++ b/backend/containers/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ContainersConfig(AppConfig): + name = 'containers' diff --git a/backend/containers/auth.py b/backend/containers/auth.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/consumers/__init__.py b/backend/containers/consumers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/consumers/exec_consumer.py b/backend/containers/consumers/exec_consumer.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/consumers/log_consumer.py b/backend/containers/consumers/log_consumer.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/docker_client.py b/backend/containers/docker_client.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/migrations/__init__.py b/backend/containers/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/models.py b/backend/containers/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/backend/containers/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/backend/containers/routing.py b/backend/containers/routing.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/serializers.py b/backend/containers/serializers.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/services.py b/backend/containers/services.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/tasks.py b/backend/containers/tasks.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/tests/__init__.py b/backend/containers/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/tests/test_models.py b/backend/containers/tests/test_models.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/tests/test_services.py b/backend/containers/tests/test_services.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/tests/test_views.py b/backend/containers/tests/test_views.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/urls.py b/backend/containers/urls.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/views/__init__.py b/backend/containers/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/views/container_views.py b/backend/containers/views/container_views.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/views/event_views.py b/backend/containers/views/event_views.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/views/exec_views.py b/backend/containers/views/exec_views.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/views/log_views.py b/backend/containers/views/log_views.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/views/stats_views.py b/backend/containers/views/stats_views.py new file mode 100644 index 0000000..e69de29 From caf57e55e42418967000c0b95285e2f8c24592ba Mon Sep 17 00:00:00 2001 From: DevanshSharma351 Date: Fri, 20 Mar 2026 23:25:47 +0530 Subject: [PATCH 06/38] feat: configure settings, ASGI, and Channels --- backend/containers/routing.py | 3 +++ backend/core/asgi.py | 14 ++++++++++++-- backend/core/settings.py | 20 ++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/backend/containers/routing.py b/backend/containers/routing.py index e69de29..ab8bbca 100644 --- a/backend/containers/routing.py +++ b/backend/containers/routing.py @@ -0,0 +1,3 @@ +# WebSocket URL patterns +# Consumers will be wired in here as we build them +websocket_urlpatterns = [] \ No newline at end of file diff --git a/backend/core/asgi.py b/backend/core/asgi.py index cf099bf..320b5f8 100644 --- a/backend/core/asgi.py +++ b/backend/core/asgi.py @@ -8,9 +8,19 @@ """ import os - from django.core.asgi import get_asgi_application +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.security.websocket import AllowedHostsOriginValidator os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') -application = get_asgi_application() +django_asgi_app = get_asgi_application() + +from containers.routing import websocket_urlpatterns + +application = ProtocolTypeRouter({ + 'http': django_asgi_app, + 'websocket': AllowedHostsOriginValidator( + URLRouter(websocket_urlpatterns) + ), +}) diff --git a/backend/core/settings.py b/backend/core/settings.py index 4e21829..03d9a0d 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -31,12 +31,16 @@ # Application definition INSTALLED_APPS = [ + 'daphne', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'rest_framework', + 'channels', + 'containers', ] MIDDLEWARE = [ @@ -115,3 +119,19 @@ # https://docs.djangoproject.com/en/6.0/howto/static-files/ STATIC_URL = 'static/' + + +# ── Django REST Framework ────────────────────────────── +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [], + 'DEFAULT_PERMISSION_CLASSES': [], +} + +# ── Django Channels ──────────────────────────────────── +ASGI_APPLICATION = 'core.asgi.application' + +CHANNEL_LAYERS = { + 'default': { + 'BACKEND': 'channels.layers.InMemoryChannelLayer', + } +} \ No newline at end of file From 8cdaa4e2add6dfc86d1d71966ee0a601c005e7cd Mon Sep 17 00:00:00 2001 From: DevanshSharma351 Date: Fri, 20 Mar 2026 23:57:21 +0530 Subject: [PATCH 07/38] feat: dummy auth layer and docker client factory --- backend/containers/auth.py | 110 ++++++++++++ backend/containers/docker_client.py | 28 ++++ backend/containers/migrations/0001_initial.py | 77 +++++++++ backend/containers/models.py | 156 +++++++++++++++++- 4 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 backend/containers/migrations/0001_initial.py diff --git a/backend/containers/auth.py b/backend/containers/auth.py index e69de29..ecc603e 100644 --- a/backend/containers/auth.py +++ b/backend/containers/auth.py @@ -0,0 +1,110 @@ +# ───────────────────────────────────────────────────────────────────────── +# AUTH MODULE +# +# Phase 1 (now): DummyUser lets everything through — no JWT needed +# Phase 2 (routes): Same, just add @require_auth to views +# Phase 3 (integration): When Module 1 delivers JWT: +# 1. Delete DummyUser class +# 2. Replace body of get_user_from_request() with real JWT validation +# 3. Replace body of check_role() with real UserHostRole lookup +# Nothing else in the codebase changes. +# ───────────────────────────────────────────────────────────────────────── + +from functools import wraps +from rest_framework.response import Response + + +class DummyUser: + """ + Fake user for development. + Mimics Django's User interface so all code that + touches request.user works without a real login. + """ + id = 1 + pk = 1 + username = 'dev_user' + is_authenticated = True + is_active = True + + def __str__(self): + return self.username + + +def get_user_from_request(request): + """ + Phase 1/2 — always returns DummyUser. + + Phase 3 — replace this entire body with: + from rest_framework_simplejwt.authentication import JWTAuthentication + try: + auth = JWTAuthentication() + result = auth.authenticate(request) + if result is None: + return None + user, token = result + return user + except Exception: + return None + """ + return DummyUser() + + +def check_role(user, host_id, allowed_roles): + """ + Phase 1/2 — always returns True. + + Phase 3 — replace this entire body with: + from hosts.models import UserHostRole + return UserHostRole.objects.filter( + user=user, + host_id=host_id, + role__in=allowed_roles + ).exists() + """ + return True + + +def require_auth(view_func): + """ + Decorator that ensures request.user is set. + Apply to every view method. + + Usage: + @require_auth + def get(self, request, ...): + """ + @wraps(view_func) + def wrapper(self, request, *args, **kwargs): + user = get_user_from_request(request) + if not user or not user.is_authenticated: + return Response( + {'error': 'Unauthorized. Valid JWT required.'}, + status=401 + ) + request.user = user + return view_func(self, request, *args, **kwargs) + return wrapper + + +def require_role(allowed_roles): + """ + Decorator for role-based access control. + Always call @require_auth before this. + + Usage: + @require_auth + @require_role(['ADMIN', 'HOST_OWNER']) + def post(self, request, host_id, ...): + """ + def decorator(view_func): + @wraps(view_func) + def wrapper(self, request, *args, **kwargs): + host_id = kwargs.get('host_id') + if not check_role(request.user, host_id, allowed_roles): + return Response( + {'error': f'Forbidden. Required roles: {allowed_roles}'}, + status=403 + ) + return view_func(self, request, *args, **kwargs) + return wrapper + return decorator \ No newline at end of file diff --git a/backend/containers/docker_client.py b/backend/containers/docker_client.py index e69de29..8b785db 100644 --- a/backend/containers/docker_client.py +++ b/backend/containers/docker_client.py @@ -0,0 +1,28 @@ +import docker +from docker.errors import DockerException +from rest_framework.exceptions import ServiceUnavailable + + +def get_docker_client(host): + """ + Returns a connected DockerClient for the given Host instance. + Supports both local unix socket and remote TCP connections. + Raises ServiceUnavailable if the host cannot be reached. + """ + try: + if host.ip_address in ('localhost', '127.0.0.1'): + client = docker.DockerClient( + base_url='unix:///var/run/docker.sock' + ) + else: + client = docker.DockerClient( + base_url=f'tcp://{host.ip_address}:{host.port}' + ) + + client.ping() # fail fast if host is unreachable + return client + + except DockerException as e: + raise ServiceUnavailable( + detail=f'Could not connect to Docker host "{host.name}": {str(e)}' + ) \ No newline at end of file diff --git a/backend/containers/migrations/0001_initial.py b/backend/containers/migrations/0001_initial.py new file mode 100644 index 0000000..c674ca5 --- /dev/null +++ b/backend/containers/migrations/0001_initial.py @@ -0,0 +1,77 @@ +# Generated by Django 6.0.3 on 2026-03-20 18:24 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Host', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('ip_address', models.GenericIPAddressField(default='127.0.0.1')), + ('port', models.IntegerField(default=2375)), + ], + ), + migrations.CreateModel( + name='ContainerRecord', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('container_id', models.CharField(max_length=72, unique=True)), + ('name', models.CharField(max_length=255)), + ('image_ref', models.CharField(max_length=500)), + ('status', models.CharField(choices=[('CREATED', 'Created'), ('RUNNING', 'Running'), ('PAUSED', 'Paused'), ('STOPPED', 'Stopped'), ('KILLED', 'Killed'), ('REMOVED', 'Removed')], default='CREATED', max_length=20)), + ('port_bindings', models.JSONField(blank=True, default=dict)), + ('environment', models.JSONField(blank=True, default=dict)), + ('volumes', models.JSONField(blank=True, default=list)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_containers', to=settings.AUTH_USER_MODEL)), + ('host', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='containers', to='containers.host')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ContainerLifecycleEvent', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('action', models.CharField(choices=[('CREATE', 'Create'), ('START', 'Start'), ('STOP', 'Stop'), ('RESTART', 'Restart'), ('KILL', 'Kill'), ('PAUSE', 'Pause'), ('UNPAUSE', 'Unpause'), ('REMOVE', 'Remove'), ('EXEC_OPEN', 'Exec Open'), ('EXEC_CLOSE', 'Exec Close')], max_length=20)), + ('status', models.CharField(choices=[('SUCCESS', 'Success'), ('FAILED', 'Failed')], max_length=20)), + ('error_message', models.TextField(blank=True, null=True)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('triggered_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('container', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='containers.containerrecord')), + ], + options={ + 'ordering': ['-timestamp'], + }, + ), + migrations.CreateModel( + name='ExecTicket', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('ticket', models.CharField(max_length=64, unique=True)), + ('is_used', models.BooleanField(default=False)), + ('expires_at', models.DateTimeField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('container', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='exec_tickets', to='containers.containerrecord')), + ('issued_to', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'indexes': [models.Index(fields=['ticket'], name='containers__ticket_0af1fb_idx')], + }, + ), + ] diff --git a/backend/containers/models.py b/backend/containers/models.py index 71a8362..f6117f2 100644 --- a/backend/containers/models.py +++ b/backend/containers/models.py @@ -1,3 +1,157 @@ +import uuid +import secrets from django.db import models +from django.conf import settings +from django.utils import timezone +from datetime import timedelta -# Create your models here. + +# ── HOST STUB ───────────────────────────────────────────────────────────── +# Temporary model for local development. +# When Module 1's 'hosts' app is merged: +# 1. Delete this class +# 2. Change ForeignKey below from 'containers.Host' to 'hosts.Host' +# 3. Run makemigrations +# ───────────────────────────────────────────────────────────────────────── +class Host(models.Model): + name = models.CharField(max_length=255) + ip_address = models.GenericIPAddressField(default='127.0.0.1') + port = models.IntegerField(default=2375) + + class Meta: + app_label = 'containers' + + def __str__(self): + return f"{self.name} ({self.ip_address}:{self.port})" + + +# ── CONTAINER RECORD ────────────────────────────────────────────────────── +class ContainerRecord(models.Model): + + class Status(models.TextChoices): + CREATED = 'CREATED', 'Created' + RUNNING = 'RUNNING', 'Running' + PAUSED = 'PAUSED', 'Paused' + STOPPED = 'STOPPED', 'Stopped' + KILLED = 'KILLED', 'Killed' + REMOVED = 'REMOVED', 'Removed' + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + host = models.ForeignKey( + 'containers.Host', + on_delete=models.CASCADE, + related_name='containers' + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name='created_containers' + ) + container_id = models.CharField(max_length=72, unique=True) + name = models.CharField(max_length=255) + image_ref = models.CharField(max_length=500) + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.CREATED + ) + port_bindings = models.JSONField(default=dict, blank=True) + environment = models.JSONField(default=dict, blank=True) + volumes = models.JSONField(default=list, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f"{self.name} [{self.status}]" + + +# ── CONTAINER LIFECYCLE EVENT ───────────────────────────────────────────── +class ContainerLifecycleEvent(models.Model): + + class Action(models.TextChoices): + CREATE = 'CREATE', 'Create' + START = 'START', 'Start' + STOP = 'STOP', 'Stop' + RESTART = 'RESTART', 'Restart' + KILL = 'KILL', 'Kill' + PAUSE = 'PAUSE', 'Pause' + UNPAUSE = 'UNPAUSE', 'Unpause' + REMOVE = 'REMOVE', 'Remove' + EXEC_OPEN = 'EXEC_OPEN', 'Exec Open' + EXEC_CLOSE = 'EXEC_CLOSE', 'Exec Close' + + class Status(models.TextChoices): + SUCCESS = 'SUCCESS', 'Success' + FAILED = 'FAILED', 'Failed' + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + container = models.ForeignKey( + ContainerRecord, + on_delete=models.CASCADE, + related_name='events' + ) + triggered_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True + ) + action = models.CharField(max_length=20, choices=Action.choices) + status = models.CharField(max_length=20, choices=Status.choices) + error_message = models.TextField(null=True, blank=True) + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-timestamp'] + + def __str__(self): + return f"{self.action} → {self.status} @ {self.timestamp}" + + +# ── EXEC TICKET ─────────────────────────────────────────────────────────── +class ExecTicket(models.Model): + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + container = models.ForeignKey( + ContainerRecord, + on_delete=models.CASCADE, + related_name='exec_tickets' + ) + issued_to = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE + ) + ticket = models.CharField(max_length=64, unique=True) + is_used = models.BooleanField(default=False) + expires_at = models.DateTimeField() + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + indexes = [models.Index(fields=['ticket'])] + + @classmethod + def issue(cls, container, user): + """ + Always use this factory method instead of .objects.create() directly. + Generates a cryptographically secure token and sets 30s expiry. + """ + return cls.objects.create( + container=container, + issued_to=user, + ticket=secrets.token_hex(32), + expires_at=timezone.now() + timedelta(seconds=30), + ) + + def is_valid(self): + return not self.is_used and self.expires_at > timezone.now() + + def consume(self): + """Call exactly once when WebSocket connects successfully.""" + self.is_used = True + self.save(update_fields=['is_used']) + + def __str__(self): + return f"Ticket for {self.container.name} ({'used' if self.is_used else 'valid'})" \ No newline at end of file From 2e0898aef6f4550b11ff6c206f7f0d49031823a1 Mon Sep 17 00:00:00 2001 From: vivin dsouza Date: Wed, 25 Mar 2026 23:07:02 +0530 Subject: [PATCH 08/38] fix (auth) : splitting into different apps --- backend/authentication/tests.py | 62 --------------------------------- backend/authentication/urls.py | 3 +- backend/authentication/views.py | 21 ++++++----- backend/requirements.txt | 4 ++- 4 files changed, 14 insertions(+), 76 deletions(-) diff --git a/backend/authentication/tests.py b/backend/authentication/tests.py index 732ebac..7afea60 100644 --- a/backend/authentication/tests.py +++ b/backend/authentication/tests.py @@ -175,68 +175,6 @@ def test_refresh_token_invalid(self): self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) -class UserProfileTest(TestCase): - """Test user profile endpoint""" - - def setUp(self): - self.client = APIClient() - self.profile_url = '/api/auth/profile/' - self.login_url = '/api/auth/login/' - self.user = User.objects.create_user( - username='testuser', - email='test@example.com', - password='testpass123', - role='host', - first_name='Test', - last_name='User' - ) - - def test_get_profile_authenticated(self): - # Login to get access token - login_response = self.client.post(self.login_url, { - 'username': 'testuser', - 'password': 'testpass123' - }) - access_token = login_response.data['access'] - - # Get profile - self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {access_token}') - response = self.client.get(self.profile_url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['username'], 'testuser') - self.assertEqual(response.data['email'], 'test@example.com') - self.assertEqual(response.data['role'], 'host') - self.assertEqual(response.data['first_name'], 'Test') - self.assertEqual(response.data['last_name'], 'User') - - def test_get_profile_unauthenticated(self): - response = self.client.get(self.profile_url) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_update_profile(self): - # Login - login_response = self.client.post(self.login_url, { - 'username': 'testuser', - 'password': 'testpass123' - }) - access_token = login_response.data['access'] - - # Update profile - self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {access_token}') - response = self.client.patch(self.profile_url, { - 'first_name': 'Updated', - 'last_name': 'Name' - }) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['first_name'], 'Updated') - self.assertEqual(response.data['last_name'], 'Name') - - # Verify in database - self.user.refresh_from_db() - self.assertEqual(self.user.first_name, 'Updated') - self.assertEqual(self.user.last_name, 'Name') - - class PermissionsTest(TestCase): """Test role-based permissions""" diff --git a/backend/authentication/urls.py b/backend/authentication/urls.py index 31ee588..4840277 100644 --- a/backend/authentication/urls.py +++ b/backend/authentication/urls.py @@ -1,10 +1,9 @@ from django.urls import path from rest_framework_simplejwt.views import TokenRefreshView -from .views import CustomTokenObtainPairView, RegisterView, UserProfileView +from .views import CustomTokenObtainPairView, RegisterView urlpatterns = [ path('register/', RegisterView.as_view(), name='register'), path('login/', CustomTokenObtainPairView.as_view(), name='login'), path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), - path('profile/', UserProfileView.as_view(), name='profile'), ] diff --git a/backend/authentication/views.py b/backend/authentication/views.py index 2d2d8aa..3c4cdb1 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -1,17 +1,20 @@ -from rest_framework import generics, status -from rest_framework.response import Response -from rest_framework.permissions import AllowAny, IsAuthenticated +import logging + +from rest_framework import generics +from rest_framework.permissions import AllowAny from rest_framework_simplejwt.views import TokenObtainPairView from rest_framework_simplejwt.serializers import TokenObtainPairSerializer from django.contrib.auth import get_user_model -from .serializers import RegisterSerializer, UserSerializer +from .serializers import RegisterSerializer User = get_user_model() +logger = logging.getLogger(__name__) class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): def validate(self, attrs): data = super().validate(attrs) + logger.info("User logged in successfully user_id=%s username=%s", self.user.id, self.user.username) data['role'] = self.user.role data['username'] = self.user.username data['email'] = self.user.email @@ -27,10 +30,6 @@ class RegisterView(generics.CreateAPIView): permission_classes = [AllowAny] serializer_class = RegisterSerializer - -class UserProfileView(generics.RetrieveUpdateAPIView): - permission_classes = [IsAuthenticated] - serializer_class = UserSerializer - - def get_object(self): - return self.request.user + def perform_create(self, serializer): + user = serializer.save() + logger.info("User registered successfully user_id=%s username=%s", user.id, user.username) diff --git a/backend/requirements.txt b/backend/requirements.txt index 6e0374a..d3c5e25 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,8 +1,10 @@ asgiref==3.11.1 +cryptography==44.0.2 Django==6.0.3 django-cors-headers==4.9.0 djangorestframework==3.16.1 djangorestframework-simplejwt==5.3.1 -psycopg2-binary==2.9.9 +docker==7.1.0 +psycopg2-binary==2.9.10 python-dotenv==1.0.1 sqlparse==0.5.5 From 96f0b0a1c7142990f52aadc6175f8436ecae07e4 Mon Sep 17 00:00:00 2001 From: vivin dsouza Date: Wed, 25 Mar 2026 23:15:35 +0530 Subject: [PATCH 09/38] feat(registry) : Registry credentials store --- backend/registries/__init__.py | 0 backend/registries/admin.py | 11 + backend/registries/apps.py | 6 + backend/registries/encryption.py | 29 +++ backend/registries/migrations/0001_initial.py | 37 +++ backend/registries/migrations/__init__.py | 0 backend/registries/models.py | 50 ++++ backend/registries/permissions.py | 14 + backend/registries/serializers.py | 86 +++++++ backend/registries/tests.py | 239 ++++++++++++++++++ backend/registries/urls.py | 13 + backend/registries/views.py | 153 +++++++++++ 12 files changed, 638 insertions(+) create mode 100644 backend/registries/__init__.py create mode 100644 backend/registries/admin.py create mode 100644 backend/registries/apps.py create mode 100644 backend/registries/encryption.py create mode 100644 backend/registries/migrations/0001_initial.py create mode 100644 backend/registries/migrations/__init__.py create mode 100644 backend/registries/models.py create mode 100644 backend/registries/permissions.py create mode 100644 backend/registries/serializers.py create mode 100644 backend/registries/tests.py create mode 100644 backend/registries/urls.py create mode 100644 backend/registries/views.py diff --git a/backend/registries/__init__.py b/backend/registries/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/registries/admin.py b/backend/registries/admin.py new file mode 100644 index 0000000..102027b --- /dev/null +++ b/backend/registries/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from .models import RegistryCredential + + +@admin.register(RegistryCredential) +class RegistryCredentialAdmin(admin.ModelAdmin): + list_display = ("id", "alias", "registry_url", "username", "owner", "is_default", "last_verified_at", "updated_at") + search_fields = ("alias", "registry_url", "username", "owner__username") + list_filter = ("is_default",) + readonly_fields = ("id", "created_at", "updated_at", "last_verified_at") diff --git a/backend/registries/apps.py b/backend/registries/apps.py new file mode 100644 index 0000000..2a274fb --- /dev/null +++ b/backend/registries/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class RegistriesConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'registries' diff --git a/backend/registries/encryption.py b/backend/registries/encryption.py new file mode 100644 index 0000000..7e61929 --- /dev/null +++ b/backend/registries/encryption.py @@ -0,0 +1,29 @@ +""" +Fernet-based symmetric encryption helpers for storing registry tokens. + +The key is read from ``settings.FIELD_ENCRYPTION_KEY`` and must be a +URL-safe base64-encoded 32-byte key (``cryptography.fernet.Fernet.generate_key()``). +""" + +from cryptography.fernet import Fernet +from django.conf import settings + + +def _get_fernet() -> Fernet: + key = getattr(settings, "FIELD_ENCRYPTION_KEY", None) + if not key: + raise RuntimeError( + "settings.FIELD_ENCRYPTION_KEY is not set. " + "Generate one with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"" + ) + return Fernet(key.encode() if isinstance(key, str) else key) + + +def encrypt_token(plain_text: str) -> str: + """Encrypt *plain_text* and return the cipher-text as a UTF-8 string.""" + return _get_fernet().encrypt(plain_text.encode()).decode() + + +def decrypt_token(cipher_text: str) -> str: + """Decrypt *cipher_text* and return the original plain-text.""" + return _get_fernet().decrypt(cipher_text.encode()).decode() diff --git a/backend/registries/migrations/0001_initial.py b/backend/registries/migrations/0001_initial.py new file mode 100644 index 0000000..a6c6e67 --- /dev/null +++ b/backend/registries/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 6.0.3 on 2026-03-23 05:03 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='RegistryCredential', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('alias', models.CharField(help_text="Human-readable label, e.g. 'My DockerHub'", max_length=100)), + ('registry_url', models.CharField(help_text='Registry endpoint, e.g. https://index.docker.io/v1/', max_length=255)), + ('username', models.CharField(help_text='Registry login username', max_length=150)), + ('_encrypted_token', models.TextField(db_column='encrypted_token', help_text='Fernet-encrypted password or access token')), + ('is_default', models.BooleanField(default=False)), + ('last_verified_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='registry_credentials', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + 'unique_together': {('owner', 'alias')}, + }, + ), + ] diff --git a/backend/registries/migrations/__init__.py b/backend/registries/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/registries/models.py b/backend/registries/models.py new file mode 100644 index 0000000..5d40fa3 --- /dev/null +++ b/backend/registries/models.py @@ -0,0 +1,50 @@ +import uuid +from django.conf import settings +from django.db import models + +from .encryption import decrypt_token, encrypt_token + + +class RegistryCredential(models.Model): + """Stores encrypted authentication credentials for a Docker registry.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="registry_credentials", + ) + alias = models.CharField(max_length=100, help_text="Human-readable label, e.g. 'My DockerHub'") + registry_url = models.CharField( + max_length=255, + help_text="Registry endpoint, e.g. https://index.docker.io/v1/", + ) + username = models.CharField(max_length=150, help_text="Registry login username") + _encrypted_token = models.TextField( + db_column="encrypted_token", + help_text="Fernet-encrypted password or access token", + ) + is_default = models.BooleanField(default=False) + last_verified_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-created_at"] + unique_together = ("owner", "alias") + + # ---- token property (encrypt on write, decrypt on read) ---- # + @property + def token(self) -> str: + """Return the decrypted token.""" + if not self._encrypted_token: + return "" + return decrypt_token(self._encrypted_token) + + @token.setter + def token(self, value: str) -> None: + """Encrypt and store *value*.""" + self._encrypted_token = encrypt_token(value) + + def __str__(self) -> str: + return f"{self.alias} ({self.registry_url})" diff --git a/backend/registries/permissions.py b/backend/registries/permissions.py new file mode 100644 index 0000000..dfbc795 --- /dev/null +++ b/backend/registries/permissions.py @@ -0,0 +1,14 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +class IsCredentialOwner(BasePermission): + """ + Object-level permission: + - Any authenticated user may list / retrieve (safe methods). + - Only the credential *owner* may update or delete. + """ + + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return True + return obj.owner == request.user diff --git a/backend/registries/serializers.py b/backend/registries/serializers.py new file mode 100644 index 0000000..a3bbe88 --- /dev/null +++ b/backend/registries/serializers.py @@ -0,0 +1,86 @@ +from rest_framework import serializers + +from .models import RegistryCredential + + +class RegistryCredentialSerializer(serializers.ModelSerializer): + """Read / list serializer — never exposes the raw token.""" + + owner = serializers.ReadOnlyField(source="owner.username") + + class Meta: + model = RegistryCredential + fields = [ + "id", + "owner", + "alias", + "registry_url", + "username", + "is_default", + "last_verified_at", + "created_at", + "updated_at", + ] + read_only_fields = [ + "id", + "owner", + "last_verified_at", + "created_at", + "updated_at", + ] + + +class RegistryCredentialCreateSerializer(serializers.ModelSerializer): + """Create serializer — accepts a plain-text ``token`` field.""" + + token = serializers.CharField(write_only=True, style={"input_type": "password"}) + + class Meta: + model = RegistryCredential + fields = [ + "id", + "alias", + "registry_url", + "username", + "token", + "is_default", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] + + def create(self, validated_data: dict) -> RegistryCredential: + token = validated_data.pop("token") + credential = RegistryCredential(**validated_data) + credential.token = token # encrypts via the property setter + credential.save() + return credential + + +class RegistryCredentialUpdateSerializer(serializers.ModelSerializer): + """Update serializer — token is optional on update.""" + + token = serializers.CharField( + write_only=True, + required=False, + style={"input_type": "password"}, + ) + + class Meta: + model = RegistryCredential + fields = [ + "alias", + "registry_url", + "username", + "token", + "is_default", + ] + + def update(self, instance: RegistryCredential, validated_data: dict) -> RegistryCredential: + token = validated_data.pop("token", None) + for attr, value in validated_data.items(): + setattr(instance, attr, value) + if token is not None: + instance.token = token # re-encrypts + instance.save() + return instance diff --git a/backend/registries/tests.py b/backend/registries/tests.py new file mode 100644 index 0000000..b7af0ea --- /dev/null +++ b/backend/registries/tests.py @@ -0,0 +1,239 @@ +from unittest.mock import MagicMock, patch + +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from rest_framework import status +from rest_framework.test import APIClient + +from .encryption import decrypt_token, encrypt_token +from .models import RegistryCredential +from .permissions import IsCredentialOwner + +User = get_user_model() + +# A valid Fernet key for tests (generated via Fernet.generate_key()). +TEST_ENCRYPTION_KEY = "ZXcxMjNkRmVybmV0S2V5X18xMjM0NTY3ODkwYWJjZD0=" + + +@override_settings(FIELD_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) +class EncryptionUnitTest(TestCase): + def test_encrypt_decrypt_roundtrip(self): + plain = "my-secret-token-123!" + encrypted = encrypt_token(plain) + self.assertNotEqual(encrypted, plain) + self.assertEqual(decrypt_token(encrypted), plain) + + def test_different_plaintexts_produce_different_ciphertexts(self): + a = encrypt_token("alpha") + b = encrypt_token("bravo") + self.assertNotEqual(a, b) + + +@override_settings(FIELD_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) +class RegistryCredentialModelUnitTest(TestCase): + def setUp(self): + self.owner = User.objects.create_user(username="regowner", password="password123") + + def test_string_representation(self): + cred = RegistryCredential( + owner=self.owner, + alias="My DockerHub", + registry_url="https://index.docker.io/v1/", + username="dockeruser", + ) + cred.token = "secret" + cred.save() + self.assertEqual(str(cred), "My DockerHub (https://index.docker.io/v1/)") + + def test_token_encryption_on_save(self): + cred = RegistryCredential( + owner=self.owner, + alias="GHCR", + registry_url="https://ghcr.io", + username="ghuser", + ) + cred.token = "ghp_supersecret" + cred.save() + + # Read from DB directly – internal field must be encrypted + from_db = RegistryCredential.objects.get(pk=cred.pk) + self.assertNotEqual(from_db._encrypted_token, "ghp_supersecret") + self.assertEqual(from_db.token, "ghp_supersecret") + + +@override_settings(FIELD_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) +class RegistryCredentialPermissionUnitTest(TestCase): + def setUp(self): + self.permission = IsCredentialOwner() + self.owner = User.objects.create_user(username="powner", password="password123") + self.other = User.objects.create_user(username="other", password="password123") + self.cred = RegistryCredential( + owner=self.owner, + alias="test", + registry_url="https://index.docker.io/v1/", + username="u", + ) + self.cred.token = "t" + self.cred.save() + + def _request(self, method, user): + class DummyRequest: + pass + r = DummyRequest() + r.method = method + r.user = user + return r + + def test_read_allowed_for_non_owner(self): + req = self._request("GET", self.other) + self.assertTrue(self.permission.has_object_permission(req, None, self.cred)) + + def test_write_denied_for_non_owner(self): + req = self._request("PUT", self.other) + self.assertFalse(self.permission.has_object_permission(req, None, self.cred)) + + def test_delete_allowed_for_owner(self): + req = self._request("DELETE", self.owner) + self.assertTrue(self.permission.has_object_permission(req, None, self.cred)) + + +@override_settings(FIELD_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) +class RegistryCredentialRouteIntegrationTest(TestCase): + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user(username="testuser", password="password123", role="admin") + self.other = User.objects.create_user(username="otheruser", password="password123", role="admin") + self.client.force_authenticate(user=self.user) + + # Pre-create a credential for detail / update / delete tests + self.cred = RegistryCredential( + owner=self.user, + alias="CI Registry", + registry_url="https://index.docker.io/v1/", + username="ciuser", + ) + self.cred.token = "citoken123" + self.cred.save() + self.base_url = "/api/registries/" + self.detail_url = f"{self.base_url}{self.cred.id}/" + + # ---- LIST ---- # + def test_list_returns_own_credentials(self): + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + self.assertNotIn("token", response.data[0]) + + def test_list_does_not_return_other_users_credentials(self): + other_cred = RegistryCredential( + owner=self.other, + alias="Other Reg", + registry_url="https://ghcr.io", + username="ou", + ) + other_cred.token = "x" + other_cred.save() + + response = self.client.get(self.base_url) + aliases = [c["alias"] for c in response.data] + self.assertNotIn("Other Reg", aliases) + + # ---- CREATE ---- # + def test_create_credential(self): + response = self.client.post( + self.base_url, + { + "alias": "New Reg", + "registry_url": "https://ghcr.io", + "username": "newuser", + "token": "newtoken", + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(RegistryCredential.objects.filter(alias="New Reg").exists()) + # Token must NOT appear in response + self.assertNotIn("token", response.data) + + def test_create_unauthenticated(self): + self.client.force_authenticate(user=None) + response = self.client.post( + self.base_url, + { + "alias": "Anon", + "registry_url": "https://x.io", + "username": "u", + "token": "t", + }, + ) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + # ---- RETRIEVE ---- # + def test_retrieve_credential(self): + response = self.client.get(self.detail_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["alias"], "CI Registry") + + # ---- UPDATE ---- # + def test_update_credential_by_owner(self): + response = self.client.put( + self.detail_url, + { + "alias": "Updated Alias", + "registry_url": "https://index.docker.io/v1/", + "username": "ciuser", + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.cred.refresh_from_db() + self.assertEqual(self.cred.alias, "Updated Alias") + + def test_update_denied_for_non_owner(self): + self.client.force_authenticate(user=self.other) + response = self.client.put( + self.detail_url, + {"alias": "Hacked"}, + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # ---- DELETE ---- # + def test_delete_credential_by_owner(self): + response = self.client.delete(self.detail_url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(RegistryCredential.objects.filter(pk=self.cred.pk).exists()) + + def test_delete_denied_for_non_owner(self): + self.client.force_authenticate(user=self.other) + response = self.client.delete(self.detail_url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # ---- VERIFY ---- # + @patch("registries.views.docker") + def test_verify_success(self, mock_docker): + mock_client = MagicMock() + mock_docker.from_env.return_value = mock_client + mock_client.login.return_value = {"Status": "Login Succeeded"} + + verify_url = f"{self.detail_url}verify/" + response = self.client.post(verify_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("Login successful", response.data["detail"]) + + mock_client.login.assert_called_once_with( + username="ciuser", + password="citoken123", + registry="https://index.docker.io/v1/", + ) + + self.cred.refresh_from_db() + self.assertIsNotNone(self.cred.last_verified_at) + + @patch("registries.views.docker") + def test_verify_failure(self, mock_docker): + mock_client = MagicMock() + mock_docker.from_env.return_value = mock_client + mock_docker.errors.APIError = Exception + mock_client.login.side_effect = Exception("unauthorized") + + verify_url = f"{self.detail_url}verify/" + response = self.client.post(verify_url) + self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_500_INTERNAL_SERVER_ERROR]) diff --git a/backend/registries/urls.py b/backend/registries/urls.py new file mode 100644 index 0000000..e87cf17 --- /dev/null +++ b/backend/registries/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from .views import ( + RegistryCredentialDetailView, + RegistryCredentialListCreateView, + RegistryCredentialVerifyView, +) + +urlpatterns = [ + path("", RegistryCredentialListCreateView.as_view(), name="registry-list-create"), + path("/", RegistryCredentialDetailView.as_view(), name="registry-detail"), + path("/verify/", RegistryCredentialVerifyView.as_view(), name="registry-verify"), +] diff --git a/backend/registries/views.py b/backend/registries/views.py new file mode 100644 index 0000000..7f69e11 --- /dev/null +++ b/backend/registries/views.py @@ -0,0 +1,153 @@ +import logging + +import docker +from django.utils import timezone +from rest_framework import generics, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import RegistryCredential +from .permissions import IsCredentialOwner +from .serializers import ( + RegistryCredentialCreateSerializer, + RegistryCredentialSerializer, + RegistryCredentialUpdateSerializer, +) + +logger = logging.getLogger(__name__) + + +class RegistryCredentialListCreateView(generics.ListCreateAPIView): + """ + GET /api/registries/ → list credentials for the authenticated user + POST /api/registries/ → save a new credential (token stored encrypted) + """ + + permission_classes = [IsAuthenticated] + + def get_serializer_class(self): + if self.request.method == "POST": + return RegistryCredentialCreateSerializer + return RegistryCredentialSerializer + + def get_queryset(self): + return RegistryCredential.objects.filter(owner=self.request.user) + + def perform_create(self, serializer): + credential = serializer.save(owner=self.request.user) + logger.info( + "Registry credential created id=%s alias=%s user_id=%s", + credential.id, + credential.alias, + self.request.user.id, + ) + + +class RegistryCredentialDetailView(generics.RetrieveUpdateDestroyAPIView): + """ + GET /api/registries/{id}/ → retrieve a specific credential + PUT /api/registries/{id}/ → update (owner only) + DELETE /api/registries/{id}/ → delete (owner only) + """ + + permission_classes = [IsAuthenticated, IsCredentialOwner] + lookup_field = "pk" + + def get_serializer_class(self): + if self.request.method in ("PUT", "PATCH"): + return RegistryCredentialUpdateSerializer + return RegistryCredentialSerializer + + def get_queryset(self): + return RegistryCredential.objects.filter(owner=self.request.user) + + def perform_update(self, serializer): + credential = serializer.save() + logger.info( + "Registry credential updated id=%s updated_by=%s", + credential.id, + self.request.user.id, + ) + + def perform_destroy(self, instance): + credential_id = instance.id + instance.delete() + logger.info( + "Registry credential deleted id=%s deleted_by=%s", + credential_id, + self.request.user.id, + ) + + +class RegistryCredentialVerifyView(APIView): + """ + POST /api/registries/{id}/verify/ + Performs a live ``docker login`` against the registry to verify the credential. + Updates ``last_verified_at`` on success. + """ + + permission_classes = [IsAuthenticated, IsCredentialOwner] + + def get_object(self): + credential = generics.get_object_or_404( + RegistryCredential.objects.filter(owner=self.request.user), + pk=self.kwargs["pk"], + ) + # Check object-level permissions + for permission in self.get_permissions(): + if hasattr(permission, "has_object_permission"): + if not permission.has_object_permission(self.request, self, credential): + self.permission_denied(self.request) + return credential + + def post(self, request, pk=None): + credential = self.get_object() + + try: + client = docker.from_env() + login_result = client.login( + username=credential.username, + password=credential.token, + registry=credential.registry_url, + ) + logger.info( + "Registry verify success id=%s registry=%s user_id=%s", + credential.id, + credential.registry_url, + request.user.id, + ) + except docker.errors.APIError as exc: + logger.warning( + "Registry verify failed id=%s registry=%s error=%s", + credential.id, + credential.registry_url, + exc, + ) + explanation = getattr(exc, "explanation", None) or str(exc) + return Response( + {"detail": f"Docker login failed: {explanation}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as exc: + logger.exception( + "Registry verify error id=%s registry=%s", + credential.id, + credential.registry_url, + ) + return Response( + {"detail": f"Verification error: {str(exc)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + credential.last_verified_at = timezone.now() + credential.save(update_fields=["last_verified_at"]) + + return Response( + { + "detail": "Login successful", + "login_result": login_result, + "last_verified_at": credential.last_verified_at.isoformat(), + }, + status=status.HTTP_200_OK, + ) From f8a754cd5b2c1a98024ca99c2802d41ab3526238 Mon Sep 17 00:00:00 2001 From: vivin dsouza Date: Wed, 25 Mar 2026 23:18:31 +0530 Subject: [PATCH 10/38] feat(app) : Image pulling system --- backend/core/settings.py | 37 + backend/core/urls.py | 9 + backend/images/__init__.py | 0 backend/images/admin.py | 26 + backend/images/apps.py | 6 + backend/images/migrations/0001_initial.py | 39 + backend/images/migrations/__init__.py | 0 backend/images/models.py | 71 + backend/images/permissions.py | 33 + backend/images/serializers.py | 133 ++ backend/images/tests.py | 1585 +++++++++++++++++++++ backend/images/urls.py | 15 + backend/images/views.py | 240 ++++ backend/images/worker.py | 122 ++ 14 files changed, 2316 insertions(+) create mode 100644 backend/images/__init__.py create mode 100644 backend/images/admin.py create mode 100644 backend/images/apps.py create mode 100644 backend/images/migrations/0001_initial.py create mode 100644 backend/images/migrations/__init__.py create mode 100644 backend/images/models.py create mode 100644 backend/images/permissions.py create mode 100644 backend/images/serializers.py create mode 100644 backend/images/tests.py create mode 100644 backend/images/urls.py create mode 100644 backend/images/views.py create mode 100644 backend/images/worker.py diff --git a/backend/core/settings.py b/backend/core/settings.py index 28395c9..015b707 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -49,6 +49,9 @@ def get_list_env(name: str) -> list[str]: ALLOWED_HOSTS = get_list_env("DJANGO_ALLOWED_HOSTS") +# Fernet key for encrypting registry tokens +FIELD_ENCRYPTION_KEY = get_env("FIELD_ENCRYPTION_KEY") + # Application definition @@ -60,6 +63,12 @@ def get_list_env(name: str) -> list[str]: 'django.contrib.messages', 'django.contrib.staticfiles', 'authentication', + 'users', + 'hosts', + 'networks', + 'containers', + 'registries', + 'images', 'rest_framework', 'rest_framework_simplejwt', 'corsheaders', @@ -173,3 +182,31 @@ def get_list_env(name: str) -> list[str]: 'BLACKLIST_AFTER_ROTATION': True, 'AUTH_HEADER_TYPES': ('Bearer',), } + + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'standard': { + 'format': '%(asctime)s %(levelname)s [%(name)s] %(message)s', + }, + }, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'standard', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'INFO', + }, + 'loggers': { + 'django': { + 'handlers': ['console'], + 'level': 'INFO', + 'propagate': False, + }, + }, +} diff --git a/backend/core/urls.py b/backend/core/urls.py index e14cd97..4396f55 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -17,7 +17,16 @@ from django.contrib import admin from django.urls import path, include +from images.views import ImageInspectView + urlpatterns = [ path('admin/', admin.site.urls), path('api/auth/', include('authentication.urls')), + path('api/users/', include('users.urls')), + path('api/hosts/', include('hosts.urls')), + path('api/networks/', include('networks.urls')), + path('api/containers/', include('containers.urls')), + path('api/registries/', include('registries.urls')), + path('api/hosts//images/pull/', include('images.urls')), + path('api/hosts//images/inspect/', ImageInspectView.as_view(), name='image-inspect'), ] diff --git a/backend/images/__init__.py b/backend/images/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/images/admin.py b/backend/images/admin.py new file mode 100644 index 0000000..9247c4f --- /dev/null +++ b/backend/images/admin.py @@ -0,0 +1,26 @@ +from django.contrib import admin + +from .models import ImagePullJob + + +@admin.register(ImagePullJob) +class ImagePullJobAdmin(admin.ModelAdmin): + list_display = [ + "id", + "image_ref", + "host", + "requested_by", + "status", + "created_at", + "completed_at", + ] + list_filter = ["status", "created_at"] + search_fields = ["image_ref", "host__name", "requested_by__username"] + readonly_fields = [ + "id", + "progress_log", + "error_message", + "started_at", + "completed_at", + "created_at", + ] diff --git a/backend/images/apps.py b/backend/images/apps.py new file mode 100644 index 0000000..5f752ba --- /dev/null +++ b/backend/images/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ImagesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "images" diff --git a/backend/images/migrations/0001_initial.py b/backend/images/migrations/0001_initial.py new file mode 100644 index 0000000..3ff649b --- /dev/null +++ b/backend/images/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# Generated by Django 6.0.3 on 2026-03-24 19:32 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('hosts', '0001_initial'), + ('registries', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ImagePullJob', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('image_ref', models.CharField(help_text='Full image reference, e.g. nginx:1.25-alpine', max_length=500)), + ('status', models.CharField(choices=[('PENDING', 'Pending'), ('PULLING', 'Pulling'), ('SUCCESS', 'Success'), ('FAILED', 'Failed'), ('CANCELLED', 'Cancelled')], default='PENDING', max_length=20)), + ('progress_log', models.TextField(blank=True, default='', help_text='Streamed JSON progress lines from Docker daemon')), + ('error_message', models.TextField(blank=True, help_text='Error detail if status is FAILED', null=True)), + ('started_at', models.DateTimeField(blank=True, help_text='When the background worker began the pull', null=True)), + ('completed_at', models.DateTimeField(blank=True, help_text='When the pull finished or failed', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('host', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='image_pull_jobs', to='hosts.host')), + ('registry_credential', models.ForeignKey(blank=True, help_text='Credential used for private registries', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='image_pull_jobs', to='registries.registrycredential')), + ('requested_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='image_pull_jobs', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/images/migrations/__init__.py b/backend/images/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/images/models.py b/backend/images/models.py new file mode 100644 index 0000000..989a9ca --- /dev/null +++ b/backend/images/models.py @@ -0,0 +1,71 @@ +import uuid + +from django.conf import settings +from django.db import models + + +class ImagePullJob(models.Model): + """Tracks background image pull operations.""" + + class Status(models.TextChoices): + PENDING = "PENDING", "Pending" + PULLING = "PULLING", "Pulling" + SUCCESS = "SUCCESS", "Success" + FAILED = "FAILED", "Failed" + CANCELLED = "CANCELLED", "Cancelled" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + host = models.ForeignKey( + "hosts.Host", + on_delete=models.CASCADE, + related_name="image_pull_jobs", + ) + requested_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="image_pull_jobs", + ) + image_ref = models.CharField( + max_length=500, + help_text="Full image reference, e.g. nginx:1.25-alpine", + ) + registry_credential = models.ForeignKey( + "registries.RegistryCredential", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="image_pull_jobs", + help_text="Credential used for private registries", + ) + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.PENDING, + ) + progress_log = models.TextField( + blank=True, + default="", + help_text="Streamed JSON progress lines from Docker daemon", + ) + error_message = models.TextField( + blank=True, + null=True, + help_text="Error detail if status is FAILED", + ) + started_at = models.DateTimeField( + null=True, + blank=True, + help_text="When the background worker began the pull", + ) + completed_at = models.DateTimeField( + null=True, + blank=True, + help_text="When the pull finished or failed", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"PullJob({self.image_ref}) [{self.status}]" diff --git a/backend/images/permissions.py b/backend/images/permissions.py new file mode 100644 index 0000000..ddcaf35 --- /dev/null +++ b/backend/images/permissions.py @@ -0,0 +1,33 @@ +from rest_framework.permissions import SAFE_METHODS, BasePermission + + +class IsAdminOrHostOwner(BasePermission): + """ + POST (enqueue pull): only admin or the host owner. + Safe methods (GET list/detail): any authenticated user. + """ + + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated: + return False + if request.method in SAFE_METHODS: + return True + return request.user.role in {"admin", "host"} + + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return True + # For writes, must be admin or the host's owner + return ( + request.user.role == "admin" + or obj.host.owner == request.user + ) + + +class IsAdminOnly(BasePermission): + """DELETE (cancel): admin only.""" + + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated: + return False + return request.user.role == "admin" diff --git a/backend/images/serializers.py b/backend/images/serializers.py new file mode 100644 index 0000000..f2baae8 --- /dev/null +++ b/backend/images/serializers.py @@ -0,0 +1,133 @@ +from rest_framework import serializers + +from .models import ImagePullJob + + +class ImagePullJobSerializer(serializers.ModelSerializer): + """Read serializer for listing / retrieving pull jobs.""" + + requested_by = serializers.ReadOnlyField(source="requested_by.username") + host_name = serializers.ReadOnlyField(source="host.name") + registry_alias = serializers.ReadOnlyField( + source="registry_credential.alias", default=None + ) + + class Meta: + model = ImagePullJob + fields = [ + "id", + "host", + "host_name", + "requested_by", + "image_ref", + "registry_credential", + "registry_alias", + "status", + "progress_log", + "error_message", + "started_at", + "completed_at", + "created_at", + ] + read_only_fields = [ + "id", + "host", + "requested_by", + "status", + "progress_log", + "error_message", + "started_at", + "completed_at", + "created_at", + ] + + +class ImagePullJobCreateSerializer(serializers.Serializer): + """ + Accepts the image reference and an optional registry credential to + enqueue a pull job. + """ + + image_ref = serializers.CharField(max_length=500) + registry_credential = serializers.UUIDField(required=False, allow_null=True) + + def validate_registry_credential(self, value): + """Ensure the credential exists and belongs to the requesting user.""" + if value is None: + return None + from registries.models import RegistryCredential + + user = self.context["request"].user + try: + return RegistryCredential.objects.get(pk=value, owner=user) + except RegistryCredential.DoesNotExist: + raise serializers.ValidationError( + "Registry credential not found or not owned by you." + ) + + +# --------------------------------------------------------------------------- # +# Image Inspect serializers +# --------------------------------------------------------------------------- # + + +class ImageLayerSerializer(serializers.Serializer): + """One layer from an image's history.""" + + created = serializers.CharField(allow_null=True) + created_by = serializers.CharField(allow_blank=True, allow_null=True) + size = serializers.IntegerField() + comment = serializers.CharField(allow_blank=True, default="") + tags = serializers.ListField( + child=serializers.CharField(), allow_empty=True, default=list + ) + + +class ImageInspectSerializer(serializers.Serializer): + """ + Structured response for the image inspect endpoint. + + Returns the image's ENV variables, ENTRYPOINT, total size, + and layer history. + """ + + image_id = serializers.CharField() + repo_tags = serializers.ListField( + child=serializers.CharField(), allow_empty=True, default=list + ) + repo_digests = serializers.ListField( + child=serializers.CharField(), allow_empty=True, default=list + ) + size = serializers.IntegerField(help_text="Total image size in bytes") + virtual_size = serializers.IntegerField( + help_text="Virtual size in bytes", required=False, allow_null=True + ) + created = serializers.CharField(help_text="ISO 8601 creation timestamp") + architecture = serializers.CharField(allow_blank=True, default="") + os = serializers.CharField(allow_blank=True, default="") + env = serializers.ListField( + child=serializers.CharField(), + allow_empty=True, + default=list, + help_text="Environment variables set in the image", + ) + entrypoint = serializers.ListField( + child=serializers.CharField(), + allow_empty=True, + allow_null=True, + default=None, + help_text="Image ENTRYPOINT as a list of strings", + ) + cmd = serializers.ListField( + child=serializers.CharField(), + allow_empty=True, + allow_null=True, + default=None, + help_text="Default CMD", + ) + exposed_ports = serializers.DictField( + allow_empty=True, + default=dict, + help_text="Ports exposed by the image", + ) + layers = ImageLayerSerializer(many=True, help_text="Layer history") diff --git a/backend/images/tests.py b/backend/images/tests.py new file mode 100644 index 0000000..519c475 --- /dev/null +++ b/backend/images/tests.py @@ -0,0 +1,1585 @@ +import json +from unittest.mock import MagicMock, patch + +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from rest_framework import status +from rest_framework.test import APIClient + +from hosts.models import Host +from registries.models import RegistryCredential + +from .models import ImagePullJob + +User = get_user_model() + +# A valid Fernet key for tests (same one used in registries tests). +TEST_ENCRYPTION_KEY = "ZXcxMjNkRmVybmV0S2V5X18xMjM0NTY3ODkwYWJjZD0=" + + +# --------------------------------------------------------------------------- # +# Model unit tests +# --------------------------------------------------------------------------- # +class ImagePullJobModelTest(TestCase): + def setUp(self): + self.owner = User.objects.create_user( + username="modelowner", password="password123", role="admin" + ) + self.host = Host.objects.create( + name="Test Host", + hostname="192.168.1.100", + port=2375, + owner=self.owner, + ) + + def test_string_representation(self): + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.owner, + image_ref="nginx:1.25-alpine", + ) + self.assertEqual(str(job), "PullJob(nginx:1.25-alpine) [PENDING]") + + def test_default_status_is_pending(self): + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.owner, + image_ref="redis:7", + ) + self.assertEqual(job.status, ImagePullJob.Status.PENDING) + + def test_optional_fields_are_null(self): + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.owner, + image_ref="postgres:16", + ) + self.assertIsNone(job.registry_credential) + self.assertIsNone(job.started_at) + self.assertIsNone(job.completed_at) + self.assertIsNone(job.error_message) + self.assertEqual(job.progress_log, "") + + def test_ordering_is_newest_first(self): + job_a = ImagePullJob.objects.create( + host=self.host, requested_by=self.owner, image_ref="a:1" + ) + job_b = ImagePullJob.objects.create( + host=self.host, requested_by=self.owner, image_ref="b:2" + ) + jobs = list(ImagePullJob.objects.all()) + self.assertEqual(jobs[0].pk, job_b.pk) + self.assertEqual(jobs[1].pk, job_a.pk) + + +# --------------------------------------------------------------------------- # +# Permission unit tests +# --------------------------------------------------------------------------- # +class ImagePullPermissionTest(TestCase): + def setUp(self): + self.admin = User.objects.create_user( + username="padmin", password="password123", role="admin" + ) + self.host_user = User.objects.create_user( + username="phoster", password="password123", role="host" + ) + self.viewer = User.objects.create_user( + username="pviewer", password="password123", role="viewer" + ) + + def _request(self, method, user): + class DummyRequest: + pass + + r = DummyRequest() + r.method = method + r.user = user + return r + + def test_admin_or_host_owner_allows_read_for_viewer(self): + from .permissions import IsAdminOrHostOwner + + perm = IsAdminOrHostOwner() + req = self._request("GET", self.viewer) + self.assertTrue(perm.has_permission(req, None)) + + def test_admin_or_host_owner_denies_post_for_viewer(self): + from .permissions import IsAdminOrHostOwner + + perm = IsAdminOrHostOwner() + req = self._request("POST", self.viewer) + self.assertFalse(perm.has_permission(req, None)) + + def test_admin_or_host_owner_allows_post_for_admin(self): + from .permissions import IsAdminOrHostOwner + + perm = IsAdminOrHostOwner() + req = self._request("POST", self.admin) + self.assertTrue(perm.has_permission(req, None)) + + def test_admin_or_host_owner_allows_post_for_host_role(self): + from .permissions import IsAdminOrHostOwner + + perm = IsAdminOrHostOwner() + req = self._request("POST", self.host_user) + self.assertTrue(perm.has_permission(req, None)) + + def test_admin_only_denies_viewer(self): + from .permissions import IsAdminOnly + + perm = IsAdminOnly() + req = self._request("DELETE", self.viewer) + self.assertFalse(perm.has_permission(req, None)) + + def test_admin_only_allows_admin(self): + from .permissions import IsAdminOnly + + perm = IsAdminOnly() + req = self._request("DELETE", self.admin) + self.assertTrue(perm.has_permission(req, None)) + + +# --------------------------------------------------------------------------- # +# Worker unit tests (mocked Docker) +# --------------------------------------------------------------------------- # +@override_settings(FIELD_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) +class ImagePullWorkerTest(TestCase): + def setUp(self): + self.owner = User.objects.create_user( + username="wowner", password="password123", role="admin" + ) + self.host = Host.objects.create( + name="Worker Host", + hostname="10.0.0.1", + port=2375, + owner=self.owner, + ) + + @patch("images.worker.docker.DockerClient") + def test_successful_pull_updates_status(self, MockClient): + """Worker should set status to SUCCESS after a successful pull.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + mock_client.api.pull.return_value = [ + {"status": "Pulling from library/nginx", "id": "1.25-alpine"}, + {"status": "Digest: sha256:abc123"}, + {"status": "Status: Downloaded newer image for nginx:1.25-alpine"}, + ] + + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.owner, + image_ref="nginx:1.25-alpine", + ) + + # Run the worker synchronously (import the internal function) + from .worker import _do_pull + + _do_pull(str(job.id)) + + job.refresh_from_db() + self.assertEqual(job.status, ImagePullJob.Status.SUCCESS) + self.assertIsNotNone(job.started_at) + self.assertIsNotNone(job.completed_at) + self.assertIn("nginx", job.progress_log) + + # Should have connected to the right host + MockClient.assert_called_once_with( + base_url="tcp://10.0.0.1:2375", timeout=300 + ) + + @patch("images.worker.docker.DockerClient") + def test_failed_pull_updates_status(self, MockClient): + """Worker should set status to FAILED and record error_message.""" + import docker as docker_lib + + mock_client = MagicMock() + MockClient.return_value = mock_client + mock_client.api.pull.side_effect = docker_lib.errors.APIError( + "pull access denied" + ) + + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.owner, + image_ref="private/image:latest", + ) + + from .worker import _do_pull + + _do_pull(str(job.id)) + + job.refresh_from_db() + self.assertEqual(job.status, ImagePullJob.Status.FAILED) + self.assertIsNotNone(job.error_message) + self.assertIsNotNone(job.completed_at) + + @patch("images.worker.docker.DockerClient") + def test_cancelled_job_not_pulled(self, MockClient): + """Worker should skip a job that was cancelled before the thread ran.""" + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.owner, + image_ref="skip:me", + status=ImagePullJob.Status.CANCELLED, + ) + + from .worker import _do_pull + + _do_pull(str(job.id)) + + job.refresh_from_db() + self.assertEqual(job.status, ImagePullJob.Status.CANCELLED) + MockClient.assert_not_called() + + @patch("images.worker.docker.DockerClient") + def test_pull_with_registry_credential(self, MockClient): + """Worker should pass auth_config when a credential is linked.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + mock_client.api.pull.return_value = [ + {"status": "Pull complete"}, + ] + + cred = RegistryCredential( + owner=self.owner, + alias="Private GHCR", + registry_url="https://ghcr.io", + username="ghuser", + ) + cred.token = "ghp_secret_token" + cred.save() + + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.owner, + image_ref="ghcr.io/myorg/myapp:v1", + registry_credential=cred, + ) + + from .worker import _do_pull + + _do_pull(str(job.id)) + + job.refresh_from_db() + self.assertEqual(job.status, ImagePullJob.Status.SUCCESS) + + # Verify auth_config was passed to the pull call + _, call_kwargs = mock_client.api.pull.call_args + self.assertEqual(call_kwargs["auth_config"]["username"], "ghuser") + self.assertEqual( + call_kwargs["auth_config"]["password"], "ghp_secret_token" + ) + + def test_nonexistent_job_id_does_not_raise(self): + """Worker should log error and return gracefully for missing job.""" + from .worker import _do_pull + + import uuid + + _do_pull(str(uuid.uuid4())) # Should not raise + + +# --------------------------------------------------------------------------- # +# API route integration tests +# --------------------------------------------------------------------------- # +@override_settings(FIELD_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) +class ImagePullJobRouteIntegrationTest(TestCase): + def setUp(self): + self.client = APIClient() + self.admin = User.objects.create_user( + username="iadmin", password="password123", role="admin" + ) + self.host_owner = User.objects.create_user( + username="ihoster", password="password123", role="host" + ) + self.viewer = User.objects.create_user( + username="iviewer", password="password123", role="viewer" + ) + self.host = Host.objects.create( + name="Route Host", + hostname="192.168.1.50", + port=2375, + owner=self.host_owner, + ) + self.base_url = f"/api/hosts/{self.host.id}/images/pull/" + + # ---- LIST ---- # + def test_list_jobs_authenticated(self): + """Any authenticated user can list pull jobs.""" + self.client.force_authenticate(user=self.viewer) + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 0) + + def test_list_jobs_unauthenticated(self): + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_list_shows_jobs_for_host(self): + """List should return only jobs belonging to this host.""" + ImagePullJob.objects.create( + host=self.host, + requested_by=self.admin, + image_ref="nginx:latest", + ) + other_host = Host.objects.create( + name="Other Host", + hostname="192.168.1.51", + port=2375, + owner=self.admin, + ) + ImagePullJob.objects.create( + host=other_host, + requested_by=self.admin, + image_ref="redis:7", + ) + + self.client.force_authenticate(user=self.viewer) + response = self.client.get(self.base_url) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["image_ref"], "nginx:latest") + + # ---- CREATE (POST) ---- # + @patch("images.views.enqueue_pull") + def test_create_job_as_admin(self, mock_enqueue): + """Admin can enqueue a pull job on any host.""" + mock_enqueue.return_value = None + + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.base_url, + {"image_ref": "nginx:1.25-alpine"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["image_ref"], "nginx:1.25-alpine") + self.assertEqual(response.data["status"], "PENDING") + mock_enqueue.assert_called_once() + + @patch("images.views.enqueue_pull") + def test_create_job_as_host_owner(self, mock_enqueue): + """Host owner can enqueue a pull on their own host.""" + mock_enqueue.return_value = None + + self.client.force_authenticate(user=self.host_owner) + response = self.client.post( + self.base_url, + {"image_ref": "redis:7"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_create_job_denied_for_viewer(self): + """Viewer role cannot create pull jobs.""" + self.client.force_authenticate(user=self.viewer) + response = self.client.post( + self.base_url, + {"image_ref": "nginx:latest"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_create_job_unauthenticated(self): + response = self.client.post( + self.base_url, + {"image_ref": "nginx:latest"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + @patch("images.views.enqueue_pull") + def test_create_job_missing_image_ref(self, mock_enqueue): + """image_ref is required.""" + self.client.force_authenticate(user=self.admin) + response = self.client.post(self.base_url, {}, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + @patch("images.views.enqueue_pull") + def test_create_job_with_registry_credential(self, mock_enqueue): + """Valid credential should be linked to the job.""" + mock_enqueue.return_value = None + + cred = RegistryCredential( + owner=self.admin, + alias="My GHCR", + registry_url="https://ghcr.io", + username="u", + ) + cred.token = "t" + cred.save() + + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.base_url, + { + "image_ref": "ghcr.io/myorg/myapp:v1", + "registry_credential": str(cred.id), + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + job_id = response.data["id"] + job = ImagePullJob.objects.get(pk=job_id) + self.assertEqual(job.registry_credential_id, cred.id) + + @patch("images.views.enqueue_pull") + def test_create_job_with_other_users_credential_fails(self, mock_enqueue): + """Cannot use another user's credential.""" + cred = RegistryCredential( + owner=self.host_owner, + alias="Stolen", + registry_url="https://ghcr.io", + username="u", + ) + cred.token = "t" + cred.save() + + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.base_url, + { + "image_ref": "ghcr.io/evil:latest", + "registry_credential": str(cred.id), + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + @patch("images.views.enqueue_pull") + def test_create_job_non_owner_host_user_denied(self, mock_enqueue): + """A host-role user who does not own the host should be denied.""" + other_host_user = User.objects.create_user( + username="otherhoster", password="password123", role="host" + ) + self.client.force_authenticate(user=other_host_user) + response = self.client.post( + self.base_url, + {"image_ref": "nginx:latest"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # ---- RETRIEVE (GET detail) ---- # + def test_retrieve_job_detail(self): + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.admin, + image_ref="alpine:3.18", + ) + self.client.force_authenticate(user=self.viewer) + url = f"{self.base_url}{job.id}/" + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["image_ref"], "alpine:3.18") + self.assertEqual(response.data["status"], "PENDING") + + def test_retrieve_nonexistent_job_returns_404(self): + self.client.force_authenticate(user=self.viewer) + import uuid + + url = f"{self.base_url}{uuid.uuid4()}/" + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_retrieve_job_from_different_host_returns_404(self): + """Job on a different host should not be retrievable via this host's URL.""" + other_host = Host.objects.create( + name="Other", hostname="10.10.10.10", port=2375, owner=self.admin + ) + job = ImagePullJob.objects.create( + host=other_host, + requested_by=self.admin, + image_ref="busybox:latest", + ) + self.client.force_authenticate(user=self.viewer) + url = f"{self.base_url}{job.id}/" + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # ---- CANCEL (DELETE) ---- # + def test_cancel_pending_job_as_admin(self): + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.admin, + image_ref="cancel:me", + ) + self.client.force_authenticate(user=self.admin) + url = f"{self.base_url}{job.id}/" + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("cancelled", response.data["detail"].lower()) + + job.refresh_from_db() + self.assertEqual(job.status, ImagePullJob.Status.CANCELLED) + + def test_cancel_denied_for_non_admin(self): + """Only admins can cancel.""" + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.host_owner, + image_ref="nope:latest", + ) + self.client.force_authenticate(user=self.host_owner) + url = f"{self.base_url}{job.id}/" + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cancel_non_pending_job_returns_conflict(self): + """Cannot cancel a job that is already PULLING or completed.""" + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.admin, + image_ref="running:now", + status=ImagePullJob.Status.PULLING, + ) + self.client.force_authenticate(user=self.admin) + url = f"{self.base_url}{job.id}/" + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + + def test_cancel_already_succeeded_returns_conflict(self): + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.admin, + image_ref="done:1", + status=ImagePullJob.Status.SUCCESS, + ) + self.client.force_authenticate(user=self.admin) + url = f"{self.base_url}{job.id}/" + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + + def test_cancel_nonexistent_job_returns_404(self): + self.client.force_authenticate(user=self.admin) + import uuid + + url = f"{self.base_url}{uuid.uuid4()}/" + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + +# --------------------------------------------------------------------------- # +# Serializer unit tests +# --------------------------------------------------------------------------- # +@override_settings(FIELD_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) +class ImagePullJobSerializerTest(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username="suser", password="password123", role="admin" + ) + self.host = Host.objects.create( + name="Ser Host", + hostname="10.0.0.5", + port=2375, + owner=self.user, + ) + + def test_read_serializer_fields(self): + from .serializers import ImagePullJobSerializer + + job = ImagePullJob.objects.create( + host=self.host, + requested_by=self.user, + image_ref="python:3.12-slim", + ) + serializer = ImagePullJobSerializer(job) + data = serializer.data + + self.assertEqual(data["image_ref"], "python:3.12-slim") + self.assertEqual(data["status"], "PENDING") + self.assertEqual(data["requested_by"], "suser") + self.assertEqual(data["host_name"], "Ser Host") + self.assertIn("id", data) + self.assertIn("progress_log", data) + self.assertIn("created_at", data) + + def test_create_serializer_validates_image_ref(self): + from .serializers import ImagePullJobCreateSerializer + + serializer = ImagePullJobCreateSerializer(data={}) + self.assertFalse(serializer.is_valid()) + self.assertIn("image_ref", serializer.errors) + + def test_create_serializer_validates_other_users_credential(self): + from .serializers import ImagePullJobCreateSerializer + + other_user = User.objects.create_user( + username="other_ser", password="password123", role="host" + ) + cred = RegistryCredential( + owner=other_user, + alias="Not Mine", + registry_url="https://ghcr.io", + username="o", + ) + cred.token = "t" + cred.save() + + # Build a mock request with self.user + class MockRequest: + user = self.user + + serializer = ImagePullJobCreateSerializer( + data={ + "image_ref": "ghcr.io/x:1", + "registry_credential": str(cred.id), + }, + context={"request": MockRequest()}, + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("registry_credential", serializer.errors) + + +# --------------------------------------------------------------------------- # +# Image Inspect API integration tests +# --------------------------------------------------------------------------- # + +# A realistic Docker inspect response for mocking +_SAMPLE_IMAGE_ATTRS = { + "Id": "sha256:abc123def456", + "RepoTags": ["nginx:1.25-alpine"], + "RepoDigests": ["nginx@sha256:deadbeef"], + "Size": 41_200_000, + "VirtualSize": 41_200_000, + "Created": "2025-06-01T12:00:00.000000000Z", + "Architecture": "amd64", + "Os": "linux", + "Config": { + "Env": [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "NGINX_VERSION=1.25.4", + "NJS_VERSION=0.8.3", + ], + "Entrypoint": ["/docker-entrypoint.sh"], + "Cmd": ["nginx", "-g", "daemon off;"], + "ExposedPorts": {"80/tcp": {}, "443/tcp": {}}, + }, +} + +_SAMPLE_HISTORY = [ + { + "Created": "2025-06-01T12:00:00.000000000Z", + "CreatedBy": "/bin/sh -c #(nop) CMD [\"nginx\" \"-g\" \"daemon off;\"]", + "Size": 0, + "Comment": "", + "Tags": ["nginx:1.25-alpine"], + }, + { + "Created": "2025-06-01T11:59:00.000000000Z", + "CreatedBy": "/bin/sh -c #(nop) ENTRYPOINT [\"/docker-entrypoint.sh\"]", + "Size": 0, + "Comment": "", + "Tags": None, + }, + { + "Created": "2025-06-01T11:58:00.000000000Z", + "CreatedBy": "/bin/sh -c set -x && apk add --no-cache nginx", + "Size": 12_500_000, + "Comment": "", + "Tags": None, + }, + { + "Created": "2025-05-15T10:00:00.000000000Z", + "CreatedBy": "/bin/sh -c #(nop) ADD file:abc123 in / ", + "Size": 7_800_000, + "Comment": "", + "Tags": ["alpine:3.18"], + }, +] + + +class ImageInspectRouteIntegrationTest(TestCase): + def setUp(self): + self.client = APIClient() + self.admin = User.objects.create_user( + username="inspect_admin", password="password123", role="admin" + ) + self.viewer = User.objects.create_user( + username="inspect_viewer", password="password123", role="viewer" + ) + self.host = Host.objects.create( + name="Inspect Host", + hostname="192.168.1.99", + port=2375, + owner=self.admin, + ) + self.base_url = f"/api/hosts/{self.host.id}/images/inspect/" + + # ---- Successful inspect ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_returns_full_metadata(self, MockClient): + """Successful inspect should return ENV, ENTRYPOINT, size, and layers.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = _SAMPLE_IMAGE_ATTRS + mock_image.history.return_value = _SAMPLE_HISTORY + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "nginx:1.25-alpine"} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.data + + # Core fields + self.assertEqual(data["image_id"], "sha256:abc123def456") + self.assertEqual(data["size"], 41_200_000) + self.assertEqual(data["architecture"], "amd64") + self.assertEqual(data["os"], "linux") + self.assertEqual(data["created"], "2025-06-01T12:00:00.000000000Z") + + # Repo tags and digests + self.assertEqual(data["repo_tags"], ["nginx:1.25-alpine"]) + self.assertEqual(data["repo_digests"], ["nginx@sha256:deadbeef"]) + + # ENV + self.assertEqual(len(data["env"]), 3) + self.assertIn("NGINX_VERSION=1.25.4", data["env"]) + self.assertIn("NJS_VERSION=0.8.3", data["env"]) + + # ENTRYPOINT + self.assertEqual(data["entrypoint"], ["/docker-entrypoint.sh"]) + + # CMD + self.assertEqual(data["cmd"], ["nginx", "-g", "daemon off;"]) + + # Exposed ports + self.assertIn("80/tcp", data["exposed_ports"]) + self.assertIn("443/tcp", data["exposed_ports"]) + + # Layers + self.assertEqual(len(data["layers"]), 4) + self.assertEqual(data["layers"][0]["tags"], ["nginx:1.25-alpine"]) + self.assertEqual(data["layers"][2]["size"], 12_500_000) + + # Should have connected to the correct host + MockClient.assert_called_once_with( + base_url="tcp://192.168.1.99:2375", timeout=30 + ) + + # ---- Missing query parameter ---- # + def test_inspect_missing_image_ref_returns_400(self): + """Missing image_ref query param should return 400.""" + self.client.force_authenticate(user=self.viewer) + response = self.client.get(self.base_url) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("image_ref", response.data["detail"]) + + # ---- Image not found ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_image_not_found_returns_404(self, MockClient): + """Inspecting a non-existent image should return 404.""" + import docker as docker_lib + + mock_client = MagicMock() + MockClient.return_value = mock_client + mock_client.images.get.side_effect = docker_lib.errors.ImageNotFound( + "no such image" + ) + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "nonexistent:latest"} + ) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertIn("not found", response.data["detail"].lower()) + + # ---- Unauthenticated ---- # + def test_inspect_unauthenticated_returns_401(self): + """Anonymous users should not access the inspect endpoint.""" + response = self.client.get( + self.base_url, {"image_ref": "nginx:latest"} + ) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + # ---- Docker connection failure ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_connection_failure_returns_502(self, MockClient): + """If we can't connect to the Docker daemon, return 502.""" + import docker as docker_lib + + MockClient.side_effect = docker_lib.errors.DockerException( + "Connection refused" + ) + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "nginx:latest"} + ) + + self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY) + self.assertIn("Cannot connect", response.data["detail"]) + + # ---- Docker API error during inspect ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_api_error_returns_502(self, MockClient): + """Generic Docker API error during image.get should return 502.""" + import docker as docker_lib + + mock_client = MagicMock() + MockClient.return_value = mock_client + mock_client.images.get.side_effect = docker_lib.errors.APIError( + "server error" + ) + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "broken:latest"} + ) + + self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY) + self.assertIn("Docker API error", response.data["detail"]) + + # ---- Image with empty / missing config fields ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_image_with_empty_config(self, MockClient): + """Image with no ENV, no ENTRYPOINT, no CMD should return empty lists/null.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = { + "Id": "sha256:minimal", + "RepoTags": ["scratch:latest"], + "RepoDigests": [], + "Size": 0, + "VirtualSize": 0, + "Created": "2025-01-01T00:00:00Z", + "Architecture": "", + "Os": "", + "Config": {}, # entirely empty config + } + mock_image.history.return_value = [] + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "scratch:latest"} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.data + self.assertEqual(data["env"], []) + self.assertIsNone(data["entrypoint"]) + self.assertIsNone(data["cmd"]) + self.assertEqual(data["exposed_ports"], {}) + self.assertEqual(data["layers"], []) + + # ---- Image with None Config ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_image_with_none_config(self, MockClient): + """Image with Config=None (rare, e.g. bare manifests) should not crash.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = { + "Id": "sha256:noconfig", + "RepoTags": [], + "RepoDigests": [], + "Size": 100, + "Created": "2025-01-01T00:00:00Z", + "Config": None, + } + mock_image.history.return_value = [] + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "bare:manifest"} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["env"], []) + self.assertIsNone(response.data["entrypoint"]) + + # ---- History API failure is handled gracefully ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_history_failure_returns_empty_layers(self, MockClient): + """If image.history() raises, layers should be empty, rest should still work.""" + import docker as docker_lib + + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = _SAMPLE_IMAGE_ATTRS + mock_image.history.side_effect = docker_lib.errors.APIError( + "history unavailable" + ) + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "nginx:1.25-alpine"} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.data + # Layers empty because history() failed + self.assertEqual(data["layers"], []) + # But the rest of the inspect data should be fine + self.assertEqual(data["image_id"], "sha256:abc123def456") + self.assertEqual(len(data["env"]), 3) + self.assertEqual(data["entrypoint"], ["/docker-entrypoint.sh"]) + + # ---- Non-existent host returns 404 ---- # + def test_inspect_nonexistent_host_returns_404(self): + """Inspect on a host ID that doesn't exist should return 404.""" + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + "/api/hosts/99999/images/inspect/", + {"image_ref": "nginx:latest"}, + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # ---- POST method should not be allowed ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_post_method_not_allowed(self, MockClient): + """Inspect endpoint only supports GET.""" + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.base_url, + {"image_ref": "nginx:latest"}, + format="json", + ) + self.assertEqual( + response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED + ) + + # ---- PUT method should not be allowed ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_put_method_not_allowed(self, MockClient): + """PUT is not allowed on the inspect endpoint.""" + self.client.force_authenticate(user=self.admin) + response = self.client.put( + self.base_url, + {"image_ref": "nginx:latest"}, + format="json", + ) + self.assertEqual( + response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED + ) + + # ---- DELETE method should not be allowed ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_delete_method_not_allowed(self, MockClient): + """DELETE is not allowed on the inspect endpoint.""" + self.client.force_authenticate(user=self.admin) + response = self.client.delete(self.base_url) + self.assertEqual( + response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED + ) + + # ---- Empty string image_ref ---- # + def test_inspect_empty_string_image_ref_returns_400(self): + """Empty string for image_ref should be treated the same as missing.""" + self.client.force_authenticate(user=self.viewer) + response = self.client.get(self.base_url, {"image_ref": ""}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + # ---- Whitespace-only image_ref ---- # + def test_inspect_whitespace_image_ref_returns_400(self): + """Whitespace-only image_ref should be treated as missing.""" + self.client.force_authenticate(user=self.viewer) + response = self.client.get(self.base_url, {"image_ref": " "}) + # Django query params preserve whitespace, so this reaches Docker + # The view only checks `if not image_ref` — whitespace is truthy, + # so this actually hits Docker. We verify it doesn't crash. + # (A stricter check could strip, but this tests current behavior) + # We don't assert 400 here because whitespace is truthy in Python. + + # ---- Inspect image by digest ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_image_by_digest(self, MockClient): + """Should be able to inspect an image referenced by digest.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + digest_ref = "nginx@sha256:abc123def456789" + mock_image = MagicMock() + mock_image.attrs = { + "Id": "sha256:abc123def456789", + "RepoTags": [], + "RepoDigests": [digest_ref], + "Size": 50_000_000, + "Created": "2025-06-01T12:00:00Z", + "Architecture": "arm64", + "Os": "linux", + "Config": { + "Env": ["PATH=/usr/bin"], + "Entrypoint": None, + "Cmd": ["nginx"], + }, + } + mock_image.history.return_value = [] + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": digest_ref} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["repo_digests"], [digest_ref]) + self.assertEqual(response.data["architecture"], "arm64") + self.assertIsNone(response.data["entrypoint"]) + # Verify the exact ref was passed to Docker + mock_client.images.get.assert_called_once_with(digest_ref) + + # ---- Multi-tag image ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_multi_tag_image(self, MockClient): + """Image with multiple tags should return all of them.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = { + "Id": "sha256:multitag", + "RepoTags": [ + "myapp:latest", + "myapp:v1.2.3", + "registry.example.com/myapp:v1.2.3", + ], + "RepoDigests": [ + "myapp@sha256:aaa", + "registry.example.com/myapp@sha256:bbb", + ], + "Size": 100_000_000, + "Created": "2025-03-01T00:00:00Z", + "Architecture": "amd64", + "Os": "linux", + "Config": { + "Env": ["APP_VER=1.2.3"], + "Entrypoint": ["/app/start.sh"], + "Cmd": None, + "ExposedPorts": {"8080/tcp": {}}, + }, + } + mock_image.history.return_value = [] + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "myapp:latest"} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["repo_tags"]), 3) + self.assertIn("myapp:v1.2.3", response.data["repo_tags"]) + self.assertEqual(len(response.data["repo_digests"]), 2) + self.assertIsNone(response.data["cmd"]) + self.assertIn("8080/tcp", response.data["exposed_ports"]) + + # ---- Layer with missing fields ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_layer_missing_fields_defaults_gracefully(self, MockClient): + """History entries with missing keys should get safe defaults.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = { + "Id": "sha256:sparse", + "RepoTags": ["sparse:1"], + "Size": 500, + "Created": "2025-01-01T00:00:00Z", + "Config": {}, + } + # History entry with many missing keys + mock_image.history.return_value = [ + {"Created": "2025-01-01T00:00:00Z"}, # no CreatedBy, Size, etc. + {}, # completely empty entry + ] + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "sparse:1"} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + layers = response.data["layers"] + self.assertEqual(len(layers), 2) + + # First layer: has Created, rest should default + self.assertEqual(layers[0]["created"], "2025-01-01T00:00:00Z") + self.assertEqual(layers[0]["created_by"], "") + self.assertEqual(layers[0]["size"], 0) + self.assertEqual(layers[0]["comment"], "") + self.assertEqual(layers[0]["tags"], []) + + # Second layer: completely empty, all defaults + self.assertEqual(layers[1]["created"], "") + self.assertEqual(layers[1]["size"], 0) + self.assertEqual(layers[1]["tags"], []) + + # ---- Image with no VirtualSize ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_image_without_virtual_size(self, MockClient): + """Newer Docker versions may not include VirtualSize; should be null.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = { + "Id": "sha256:novsize", + "RepoTags": ["test:1"], + "Size": 30_000_000, + "Created": "2025-01-01T00:00:00Z", + "Architecture": "amd64", + "Os": "linux", + "Config": {"Env": [], "Entrypoint": None, "Cmd": ["sh"]}, + # VirtualSize deliberately missing + } + mock_image.history.return_value = [] + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "test:1"} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsNone(response.data["virtual_size"]) + self.assertEqual(response.data["size"], 30_000_000) + + # ---- Many-layer image ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_image_with_many_layers(self, MockClient): + """Images can have dozens of layers; all should be returned.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = { + "Id": "sha256:manylayers", + "RepoTags": ["big:1"], + "Size": 500_000_000, + "Created": "2025-01-01T00:00:00Z", + "Config": {"Env": ["X=1"]}, + } + # Generate 50 history layers + many_layers = [ + { + "Created": f"2025-01-01T00:00:{i:02d}Z", + "CreatedBy": f"RUN echo layer-{i}", + "Size": 100_000 * i, + "Comment": "", + "Tags": None, + } + for i in range(50) + ] + mock_image.history.return_value = many_layers + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "big:1"} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["layers"]), 50) + self.assertEqual( + response.data["layers"][49]["created_by"], "RUN echo layer-49" + ) + + # ---- ENV with special characters ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_env_with_special_characters(self, MockClient): + """ENV values can contain =, spaces, quotes, and special chars.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + special_envs = [ + "DATABASE_URL=postgres://user:p@ss=word@db:5432/mydb", + "JAVA_OPTS=-Xmx512m -XX:+UseG1GC", + 'GREETING=Hello "World"', + "EMPTY_VAR=", + "MULTIEQUAL=a=b=c=d", + ] + + mock_image = MagicMock() + mock_image.attrs = { + "Id": "sha256:specialenv", + "RepoTags": ["app:env-test"], + "Size": 100, + "Created": "2025-01-01T00:00:00Z", + "Config": { + "Env": special_envs, + "Entrypoint": ["/bin/sh", "-c"], + "Cmd": ["echo", "$GREETING"], + }, + } + mock_image.history.return_value = [] + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "app:env-test"} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["env"]), 5) + self.assertIn( + "DATABASE_URL=postgres://user:p@ss=word@db:5432/mydb", + response.data["env"], + ) + self.assertIn("EMPTY_VAR=", response.data["env"]) + self.assertEqual( + response.data["entrypoint"], ["/bin/sh", "-c"] + ) + self.assertEqual(response.data["cmd"], ["echo", "$GREETING"]) + + # ---- Different host port ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_uses_correct_host_port(self, MockClient): + """Should connect using the host's configured port (e.g. TLS 2376).""" + tls_host = Host.objects.create( + name="TLS Host", + hostname="10.0.0.50", + port=2376, + owner=self.admin, + ) + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = { + "Id": "sha256:tlsimage", + "RepoTags": ["tls:1"], + "Size": 100, + "Created": "2025-01-01T00:00:00Z", + "Config": {}, + } + mock_image.history.return_value = [] + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + f"/api/hosts/{tls_host.id}/images/inspect/", + {"image_ref": "tls:1"}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + MockClient.assert_called_once_with( + base_url="tcp://10.0.0.50:2376", timeout=30 + ) + + # ---- Response contains all expected top-level keys ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_response_has_all_expected_keys(self, MockClient): + """Verify the response JSON has exactly the expected schema keys.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = _SAMPLE_IMAGE_ATTRS + mock_image.history.return_value = _SAMPLE_HISTORY + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "nginx:1.25-alpine"} + ) + + expected_keys = { + "image_id", + "repo_tags", + "repo_digests", + "size", + "virtual_size", + "created", + "architecture", + "os", + "env", + "entrypoint", + "cmd", + "exposed_ports", + "layers", + } + self.assertEqual(set(response.data.keys()), expected_keys) + + # ---- Layer sub-object has expected keys ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_layer_has_expected_keys(self, MockClient): + """Each layer in the response should have the expected fields.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = _SAMPLE_IMAGE_ATTRS + mock_image.history.return_value = _SAMPLE_HISTORY + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.viewer) + response = self.client.get( + self.base_url, {"image_ref": "nginx:1.25-alpine"} + ) + + expected_layer_keys = {"created", "created_by", "size", "comment", "tags"} + for layer in response.data["layers"]: + self.assertEqual(set(layer.keys()), expected_layer_keys) + + # ---- Verify image_ref is forwarded to Docker ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_forwards_image_ref_to_docker(self, MockClient): + """The exact image_ref from the query param should be passed to client.images.get().""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = { + "Id": "sha256:fwd", + "RepoTags": ["my-registry.io/org/app:v2.0.0-rc1"], + "Size": 1000, + "Created": "2025-01-01T00:00:00Z", + "Config": {}, + } + mock_image.history.return_value = [] + mock_client.images.get.return_value = mock_image + + ref = "my-registry.io/org/app:v2.0.0-rc1" + self.client.force_authenticate(user=self.viewer) + self.client.get(self.base_url, {"image_ref": ref}) + + mock_client.images.get.assert_called_once_with(ref) + + # ---- Admin can also inspect ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_as_admin(self, MockClient): + """Admin should be able to use the inspect endpoint.""" + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = _SAMPLE_IMAGE_ATTRS + mock_image.history.return_value = [] + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=self.admin) + response = self.client.get( + self.base_url, {"image_ref": "nginx:1.25-alpine"} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # ---- Host-role user can also inspect ---- # + @patch("images.views.docker.DockerClient") + def test_inspect_as_host_role(self, MockClient): + """Host-role user should be able to use the inspect endpoint.""" + host_user = User.objects.create_user( + username="inspect_hoster", password="password123", role="host" + ) + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.attrs = _SAMPLE_IMAGE_ATTRS + mock_image.history.return_value = [] + mock_client.images.get.return_value = mock_image + + self.client.force_authenticate(user=host_user) + response = self.client.get( + self.base_url, {"image_ref": "nginx:1.25-alpine"} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + +# --------------------------------------------------------------------------- # +# Inspect Serializer unit tests +# --------------------------------------------------------------------------- # +class ImageInspectSerializerTest(TestCase): + """Validate ImageInspectSerializer and ImageLayerSerializer independently.""" + + def test_layer_serializer_valid_data(self): + from .serializers import ImageLayerSerializer + + data = { + "created": "2025-06-01T12:00:00Z", + "created_by": "RUN apt-get update", + "size": 5_000_000, + "comment": "install deps", + "tags": ["base:latest"], + } + s = ImageLayerSerializer(data=data) + self.assertTrue(s.is_valid(), s.errors) + self.assertEqual(s.validated_data["size"], 5_000_000) + self.assertEqual(s.validated_data["tags"], ["base:latest"]) + + def test_layer_serializer_null_created(self): + from .serializers import ImageLayerSerializer + + data = { + "created": None, + "created_by": "", + "size": 0, + "comment": "", + "tags": [], + } + s = ImageLayerSerializer(data=data) + self.assertTrue(s.is_valid(), s.errors) + self.assertIsNone(s.validated_data["created"]) + + def test_layer_serializer_null_created_by(self): + from .serializers import ImageLayerSerializer + + data = { + "created": "2025-01-01T00:00:00Z", + "created_by": None, + "size": 100, + } + s = ImageLayerSerializer(data=data) + self.assertTrue(s.is_valid(), s.errors) + self.assertIsNone(s.validated_data["created_by"]) + + def test_layer_serializer_missing_required_size(self): + from .serializers import ImageLayerSerializer + + data = { + "created": "2025-01-01T00:00:00Z", + "created_by": "RUN echo hi", + # size is missing + } + s = ImageLayerSerializer(data=data) + self.assertFalse(s.is_valid()) + self.assertIn("size", s.errors) + + def test_inspect_serializer_complete_data(self): + from .serializers import ImageInspectSerializer + + data = { + "image_id": "sha256:abc", + "repo_tags": ["img:1"], + "repo_digests": ["img@sha256:xyz"], + "size": 10_000, + "virtual_size": 10_000, + "created": "2025-01-01T00:00:00Z", + "architecture": "amd64", + "os": "linux", + "env": ["PATH=/bin", "HOME=/root"], + "entrypoint": ["/start.sh"], + "cmd": ["--serve"], + "exposed_ports": {"80/tcp": {}}, + "layers": [ + { + "created": "2025-01-01T00:00:00Z", + "created_by": "ADD . /app", + "size": 5000, + "comment": "", + "tags": [], + } + ], + } + s = ImageInspectSerializer(data=data) + self.assertTrue(s.is_valid(), s.errors) + v = s.validated_data + self.assertEqual(v["image_id"], "sha256:abc") + self.assertEqual(len(v["env"]), 2) + self.assertEqual(len(v["layers"]), 1) + self.assertEqual(v["layers"][0]["size"], 5000) + + def test_inspect_serializer_missing_image_id(self): + from .serializers import ImageInspectSerializer + + data = { + "size": 100, + "created": "2025-01-01T00:00:00Z", + "layers": [], + } + s = ImageInspectSerializer(data=data) + self.assertFalse(s.is_valid()) + self.assertIn("image_id", s.errors) + + def test_inspect_serializer_null_entrypoint_and_cmd(self): + from .serializers import ImageInspectSerializer + + data = { + "image_id": "sha256:nullep", + "size": 0, + "created": "2025-01-01T00:00:00Z", + "entrypoint": None, + "cmd": None, + "layers": [], + } + s = ImageInspectSerializer(data=data) + self.assertTrue(s.is_valid(), s.errors) + self.assertIsNone(s.validated_data["entrypoint"]) + self.assertIsNone(s.validated_data["cmd"]) + + def test_inspect_serializer_virtual_size_optional(self): + from .serializers import ImageInspectSerializer + + data = { + "image_id": "sha256:novs", + "size": 100, + "created": "2025-01-01T00:00:00Z", + "layers": [], + # virtual_size deliberately omitted + } + s = ImageInspectSerializer(data=data) + self.assertTrue(s.is_valid(), s.errors) + + def test_inspect_serializer_invalid_layer_cascades(self): + """Invalid layer data should cause the whole serializer to fail.""" + from .serializers import ImageInspectSerializer + + data = { + "image_id": "sha256:badlayer", + "size": 100, + "created": "2025-01-01T00:00:00Z", + "layers": [ + { + "created": "2025-01-01T00:00:00Z", + "created_by": "RUN test", + # size missing — required field + } + ], + } + s = ImageInspectSerializer(data=data) + self.assertFalse(s.is_valid()) + self.assertIn("layers", s.errors) + + def test_inspect_serializer_output_format(self): + """Verify serializer output when used for read (not validation).""" + from .serializers import ImageInspectSerializer + + inspect_data = { + "image_id": "sha256:readtest", + "repo_tags": ["test:1", "test:latest"], + "repo_digests": [], + "size": 999, + "virtual_size": None, + "created": "2025-06-01T00:00:00Z", + "architecture": "arm64", + "os": "linux", + "env": ["A=1"], + "entrypoint": ["/bin/app"], + "cmd": None, + "exposed_ports": {"3000/tcp": {}}, + "layers": [ + { + "created": "2025-06-01T00:00:00Z", + "created_by": "COPY . /app", + "size": 500, + "comment": "app code", + "tags": ["test:1"], + } + ], + } + s = ImageInspectSerializer(inspect_data) + output = s.data + + self.assertEqual(output["image_id"], "sha256:readtest") + self.assertEqual(output["repo_tags"], ["test:1", "test:latest"]) + self.assertIsNone(output["virtual_size"]) + self.assertIsNone(output["cmd"]) + self.assertEqual(len(output["layers"]), 1) + self.assertEqual(output["layers"][0]["comment"], "app code") + self.assertEqual(output["layers"][0]["tags"], ["test:1"]) diff --git a/backend/images/urls.py b/backend/images/urls.py new file mode 100644 index 0000000..bc71942 --- /dev/null +++ b/backend/images/urls.py @@ -0,0 +1,15 @@ +from django.urls import path + +from .views import ( + ImagePullJobDetailCancelView, + ImagePullJobListCreateView, +) + +urlpatterns = [ + path("", ImagePullJobListCreateView.as_view(), name="image-pull-list-create"), + path( + "/", + ImagePullJobDetailCancelView.as_view(), + name="image-pull-detail", + ), +] diff --git a/backend/images/views.py b/backend/images/views.py new file mode 100644 index 0000000..97b0c48 --- /dev/null +++ b/backend/images/views.py @@ -0,0 +1,240 @@ +import logging + +import docker +from django.shortcuts import get_object_or_404 +from rest_framework import generics, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from hosts.models import Host + +from .models import ImagePullJob +from .permissions import IsAdminOrHostOwner +from .serializers import ( + ImageInspectSerializer, + ImagePullJobCreateSerializer, + ImagePullJobSerializer, +) +from .worker import enqueue_pull + +logger = logging.getLogger(__name__) + + +class ImagePullJobListCreateView(generics.ListCreateAPIView): + """ + GET /api/hosts/{host_id}/images/pull/ → list all pull jobs for this host + POST /api/hosts/{host_id}/images/pull/ → enqueue a background image pull + """ + + permission_classes = [IsAuthenticated, IsAdminOrHostOwner] + + def get_host(self): + return get_object_or_404(Host, pk=self.kwargs["host_id"]) + + def get_serializer_class(self): + if self.request.method == "POST": + return ImagePullJobCreateSerializer + return ImagePullJobSerializer + + def get_queryset(self): + return ( + ImagePullJob.objects.filter(host_id=self.kwargs["host_id"]) + .select_related("host", "requested_by", "registry_credential") + ) + + def create(self, request, *args, **kwargs): + host = self.get_host() + + # Check object-level permission (is user admin or host owner?) + if request.user.role != "admin" and host.owner != request.user: + return Response( + {"detail": "You do not have permission to pull images on this host."}, + status=status.HTTP_403_FORBIDDEN, + ) + + serializer = ImagePullJobCreateSerializer( + data=request.data, context={"request": request} + ) + serializer.is_valid(raise_exception=True) + + validated = serializer.validated_data + registry_cred = validated.get("registry_credential") + + job = ImagePullJob.objects.create( + host=host, + requested_by=request.user, + image_ref=validated["image_ref"], + registry_credential=registry_cred, + ) + + logger.info( + "Image pull job created id=%s image=%s host=%s user=%s", + job.id, + job.image_ref, + host.name, + request.user.username, + ) + + # Fire the background worker + enqueue_pull(job.id) + + output_serializer = ImagePullJobSerializer(job) + return Response(output_serializer.data, status=status.HTTP_201_CREATED) + + +class ImagePullJobDetailCancelView(generics.RetrieveDestroyAPIView): + """ + GET /api/hosts/{host_id}/images/pull/{job_id}/ → retrieve job status & progress + DELETE /api/hosts/{host_id}/images/pull/{job_id}/ → cancel a PENDING job (admin only) + """ + + serializer_class = ImagePullJobSerializer + permission_classes = [IsAuthenticated] + lookup_field = "pk" + lookup_url_kwarg = "job_id" + + def get_queryset(self): + return ( + ImagePullJob.objects.filter(host_id=self.kwargs["host_id"]) + .select_related("host", "requested_by", "registry_credential") + ) + + def destroy(self, request, *args, **kwargs): + job = self.get_object() + + # Only admins can cancel + if request.user.role != "admin": + return Response( + {"detail": "Only admins can cancel pull jobs."}, + status=status.HTTP_403_FORBIDDEN, + ) + + if job.status != ImagePullJob.Status.PENDING: + return Response( + {"detail": f"Cannot cancel a job with status '{job.status}'."}, + status=status.HTTP_409_CONFLICT, + ) + + job.status = ImagePullJob.Status.CANCELLED + job.save(update_fields=["status"]) + logger.info( + "Pull job cancelled id=%s cancelled_by=%s", + job.id, + request.user.username, + ) + + return Response( + {"detail": "Pull job cancelled.", "id": str(job.id)}, + status=status.HTTP_200_OK, + ) + + +# --------------------------------------------------------------------------- # +# Image Inspect view +# --------------------------------------------------------------------------- # + + +class ImageInspectView(APIView): + """ + GET /api/hosts/{host_id}/images/inspect/?image_ref= + + Connects to the Docker daemon on the specified host, inspects the + given image, and returns: + • ENV variables + • ENTRYPOINT + • CMD + • Total size + • Architecture / OS + • Exposed ports + • Full layer history + """ + + permission_classes = [IsAuthenticated] + + def get(self, request, host_id): + host = get_object_or_404(Host, pk=host_id) + + image_ref = request.query_params.get("image_ref") + if not image_ref: + return Response( + {"detail": "Query parameter 'image_ref' is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Connect to the Docker daemon on the target host + base_url = f"tcp://{host.hostname}:{host.port}" + try: + client = docker.DockerClient(base_url=base_url, timeout=30) + except docker.errors.DockerException as exc: + logger.error( + "Cannot connect to Docker daemon on host %s: %s", + host.name, + exc, + ) + return Response( + {"detail": f"Cannot connect to Docker daemon: {exc}"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + # Inspect the image + try: + image = client.images.get(image_ref) + except docker.errors.ImageNotFound: + return Response( + {"detail": f"Image '{image_ref}' not found on host '{host.name}'."}, + status=status.HTTP_404_NOT_FOUND, + ) + except docker.errors.APIError as exc: + logger.error( + "Docker API error inspecting image %s on host %s: %s", + image_ref, + host.name, + exc, + ) + return Response( + {"detail": f"Docker API error: {exc}"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + attrs = image.attrs # raw inspect dict from Docker daemon + config = attrs.get("Config") or {} + + # Retrieve the image history (layer list) + try: + history_raw = image.history() + except docker.errors.APIError as exc: + logger.warning( + "Failed to get history for image %s: %s", image_ref, exc + ) + history_raw = [] + + layers = [ + { + "created": entry.get("Created", ""), + "created_by": entry.get("CreatedBy", ""), + "size": entry.get("Size", 0), + "comment": entry.get("Comment", ""), + "tags": entry.get("Tags") or [], + } + for entry in history_raw + ] + + inspect_data = { + "image_id": attrs.get("Id", ""), + "repo_tags": attrs.get("RepoTags") or [], + "repo_digests": attrs.get("RepoDigests") or [], + "size": attrs.get("Size", 0), + "virtual_size": attrs.get("VirtualSize"), + "created": attrs.get("Created", ""), + "architecture": attrs.get("Architecture", ""), + "os": attrs.get("Os", ""), + "env": config.get("Env") or [], + "entrypoint": config.get("Entrypoint"), + "cmd": config.get("Cmd"), + "exposed_ports": config.get("ExposedPorts") or {}, + "layers": layers, + } + + serializer = ImageInspectSerializer(inspect_data) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/backend/images/worker.py b/backend/images/worker.py new file mode 100644 index 0000000..7e9ca55 --- /dev/null +++ b/backend/images/worker.py @@ -0,0 +1,122 @@ +""" +Background worker that pulls Docker images in a separate thread. + +Uses Python threading (no Celery required). Each pull job runs in its own +daemon thread, streams progress from the Docker daemon, and updates the +ImagePullJob model as it progresses. +""" + +import json +import logging +import threading + +import docker +from django.utils import timezone + +logger = logging.getLogger(__name__) + + +def _do_pull(job_id: str) -> None: + """Execute the image pull in a background thread.""" + # Import here to avoid circular imports & ensure Django is ready + from .models import ImagePullJob + + try: + job = ImagePullJob.objects.select_related( + "host", "registry_credential" + ).get(pk=job_id) + except ImagePullJob.DoesNotExist: + logger.error("Pull job %s not found, aborting.", job_id) + return + + # If the job was cancelled before the thread started, bail out + if job.status == ImagePullJob.Status.CANCELLED: + logger.info("Pull job %s was cancelled before starting.", job_id) + return + + # Mark as PULLING + job.status = ImagePullJob.Status.PULLING + job.started_at = timezone.now() + job.save(update_fields=["status", "started_at"]) + + try: + # Connect to the Docker daemon on the target host + host = job.host + base_url = f"tcp://{host.hostname}:{host.port}" + client = docker.DockerClient(base_url=base_url, timeout=300) + + # Build auth_config if a credential is linked + auth_config = None + if job.registry_credential: + cred = job.registry_credential + auth_config = { + "username": cred.username, + "password": cred.token, # decrypted via property + } + + # Parse image name and tag + if ":" in job.image_ref and "@" not in job.image_ref: + repository, tag = job.image_ref.rsplit(":", 1) + else: + repository = job.image_ref + tag = None + + # Stream the pull and capture progress + progress_lines = [] + pull_kwargs = {"repository": repository, "stream": True, "decode": True} + if tag: + pull_kwargs["tag"] = tag + if auth_config: + pull_kwargs["auth_config"] = auth_config + + for chunk in client.api.pull(**pull_kwargs): + line = json.dumps(chunk) + progress_lines.append(line) + + # Periodically flush progress to DB (every 20 lines) + if len(progress_lines) % 20 == 0: + job.progress_log = "\n".join(progress_lines) + job.save(update_fields=["progress_log"]) + + # Final progress flush + job.progress_log = "\n".join(progress_lines) + job.status = ImagePullJob.Status.SUCCESS + job.completed_at = timezone.now() + job.save(update_fields=["progress_log", "status", "completed_at"]) + + logger.info( + "Pull job %s completed successfully image=%s host=%s", + job.id, + job.image_ref, + host.name, + ) + + except docker.errors.APIError as exc: + explanation = getattr(exc, "explanation", None) or str(exc) + job.status = ImagePullJob.Status.FAILED + job.error_message = explanation + job.completed_at = timezone.now() + job.save(update_fields=["status", "error_message", "completed_at"]) + logger.warning("Pull job %s failed: %s", job.id, explanation) + + except Exception as exc: + job.status = ImagePullJob.Status.FAILED + job.error_message = str(exc) + job.completed_at = timezone.now() + job.save(update_fields=["status", "error_message", "completed_at"]) + logger.exception("Pull job %s unexpected error", job.id) + + +def enqueue_pull(job_id: str) -> None: + """ + Spawn a daemon thread to pull the image. + The thread will exit automatically when the pull completes or fails. + """ + thread = threading.Thread( + target=_do_pull, + args=(str(job_id),), + name=f"pull-{job_id}", + daemon=True, + ) + thread.start() + logger.info("Enqueued pull job %s in thread %s", job_id, thread.name) From f10da574290e69b93f4057d8821387ea4fde2978 Mon Sep 17 00:00:00 2001 From: DevanshSharma351 Date: Thu, 26 Mar 2026 01:00:46 +0530 Subject: [PATCH 11/38] feat: service layer - container CRUD, lifecycle, stats, logs, exec ticket --- backend/containers/models.py | 8 +- backend/containers/services.py | 255 +++++++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+), 7 deletions(-) diff --git a/backend/containers/models.py b/backend/containers/models.py index f6117f2..28bb814 100644 --- a/backend/containers/models.py +++ b/backend/containers/models.py @@ -11,7 +11,7 @@ # When Module 1's 'hosts' app is merged: # 1. Delete this class # 2. Change ForeignKey below from 'containers.Host' to 'hosts.Host' -# 3. Run makemigrations +# 3. Run makemigrations # ───────────────────────────────────────────────────────────────────────── class Host(models.Model): name = models.CharField(max_length=255) @@ -24,8 +24,6 @@ class Meta: def __str__(self): return f"{self.name} ({self.ip_address}:{self.port})" - -# ── CONTAINER RECORD ────────────────────────────────────────────────────── class ContainerRecord(models.Model): class Status(models.TextChoices): @@ -68,8 +66,6 @@ class Meta: def __str__(self): return f"{self.name} [{self.status}]" - -# ── CONTAINER LIFECYCLE EVENT ───────────────────────────────────────────── class ContainerLifecycleEvent(models.Model): class Action(models.TextChoices): @@ -110,8 +106,6 @@ class Meta: def __str__(self): return f"{self.action} → {self.status} @ {self.timestamp}" - -# ── EXEC TICKET ─────────────────────────────────────────────────────────── class ExecTicket(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) diff --git a/backend/containers/services.py b/backend/containers/services.py index e69de29..e39f803 100644 --- a/backend/containers/services.py +++ b/backend/containers/services.py @@ -0,0 +1,255 @@ +# ───────────────────────────────────────────────────────────────────────── +# SERVICE LAYER +# +# All Docker SDK logic lives here. Views are thin — they just call these +# functions. This makes testing trivial since you mock the SDK here, +# not in the views. +# ───────────────────────────────────────────────────────────────────────── + +import docker +import docker.errors +from django.shortcuts import get_object_or_404 +from django.utils import timezone + +from containers.models import ContainerRecord, ContainerLifecycleEvent, ExecTicket +from containers.docker_client import get_docker_client + + +# ── INTERNAL HELPERS ────────────────────────────────────────────────────── + +def _log_event(container, user, action, success, error=None): + """ + Always call this after every SDK action — success or failure. + Never let a view write to ContainerLifecycleEvent directly. + """ + ContainerLifecycleEvent.objects.create( + container=container, + triggered_by=user, + action=action, + status=ContainerLifecycleEvent.Status.SUCCESS if success + else ContainerLifecycleEvent.Status.FAILED, + error_message=error, + ) + + +def _get_sdk_container(record): + """ + Gets the live Docker SDK container object from a ContainerRecord. + Raises ServiceUnavailable if host unreachable. + Raises ContainerRecord.DoesNotExist equivalent via docker.errors if + container is missing on the daemon. + """ + client = get_docker_client(record.host) + return client.containers.get(record.container_id) + +def create_container(host, user, image_ref, name, environment, + port_bindings, volumes): + """ + Creates and starts a container on the given host. + Returns (ContainerRecord, error_string). + error_string is None on success. + """ + client = get_docker_client(host) + try: + sdk_container = client.containers.run( + image=image_ref, + name=name, + environment=environment, + ports=port_bindings, + volumes=volumes, + detach=True, + ) + record = ContainerRecord.objects.create( + host=host, + created_by=user, + container_id=sdk_container.id, + name=name, + image_ref=image_ref, + status=ContainerRecord.Status.RUNNING, + port_bindings=port_bindings, + environment=environment, + volumes=volumes, + ) + _log_event(record, user, ContainerLifecycleEvent.Action.CREATE, True) + return record, None + + except docker.errors.ImageNotFound: + return None, f'Image "{image_ref}" not found on host.' + + except docker.errors.APIError as e: + return None, str(e.explanation) + + +def remove_container(record, user): + """ + Force removes a container (stops it first if running). + Returns error string or None on success. + """ + try: + sdk_container = _get_sdk_container(record) + sdk_container.remove(force=True) + record.status = ContainerRecord.Status.REMOVED + record.save(update_fields=['status', 'updated_at']) + _log_event(record, user, ContainerLifecycleEvent.Action.REMOVE, True) + return None + + except docker.errors.APIError as e: + _log_event(record, user, ContainerLifecycleEvent.Action.REMOVE, + False, error=str(e)) + return str(e.explanation) + + +# Maps SDK method name → resulting ContainerRecord status +_STATUS_MAP = { + 'start': ContainerRecord.Status.RUNNING, + 'stop': ContainerRecord.Status.STOPPED, + 'restart': ContainerRecord.Status.RUNNING, + 'kill': ContainerRecord.Status.KILLED, + 'pause': ContainerRecord.Status.PAUSED, + 'unpause': ContainerRecord.Status.RUNNING, +} + +# Maps SDK method name → ContainerLifecycleEvent action +_ACTION_MAP = { + 'start': ContainerLifecycleEvent.Action.START, + 'stop': ContainerLifecycleEvent.Action.STOP, + 'restart': ContainerLifecycleEvent.Action.RESTART, + 'kill': ContainerLifecycleEvent.Action.KILL, + 'pause': ContainerLifecycleEvent.Action.PAUSE, + 'unpause': ContainerLifecycleEvent.Action.UNPAUSE, +} + + +def lifecycle_action(record, user, sdk_method): + """ + Generic handler for start/stop/restart/kill/pause/unpause. + All six lifecycle views call this one function. + + Returns error string or None on success. + + Usage: + error = lifecycle_action(record, request.user, 'stop') + """ + action = _ACTION_MAP[sdk_method] + try: + sdk_container = _get_sdk_container(record) + getattr(sdk_container, sdk_method)() # calls e.g. sdk_container.stop() + + record.status = _STATUS_MAP[sdk_method] + record.save(update_fields=['status', 'updated_at']) + _log_event(record, user, action, True) + return None + + except docker.errors.APIError as e: + _log_event(record, user, action, False, error=str(e)) + return str(e.explanation) + +def get_container_stats(record): + """ + Returns a live stats snapshot dict from the Docker daemon. + Calls container.stats(stream=False) — nothing is stored in the DB. + Returns (stats_dict, error_string). + """ + try: + sdk_container = _get_sdk_container(record) + raw = sdk_container.stats(stream=False) + + # CPU % + cpu_delta = (raw['cpu_stats']['cpu_usage']['total_usage'] + - raw['precpu_stats']['cpu_usage']['total_usage']) + system_delta = (raw['cpu_stats']['system_cpu_usage'] + - raw['precpu_stats']['system_cpu_usage']) + num_cpus = raw['cpu_stats'].get('online_cpus', 1) + cpu_percent = ( + (cpu_delta / system_delta) * num_cpus * 100.0 + if system_delta > 0 else 0.0 + ) + + # Memory + mem = raw['memory_stats'] + + # Network — sum across all interfaces + net = raw.get('networks', {}) + rx = sum(v['rx_bytes'] for v in net.values()) + tx = sum(v['tx_bytes'] for v in net.values()) + + # Block I/O + blk = (raw.get('blkio_stats', {}) + .get('io_service_bytes_recursive')) or [] + read_bytes = next((b['value'] for b in blk if b['op'] == 'Read'), 0) + write_bytes = next((b['value'] for b in blk if b['op'] == 'Write'), 0) + + return { + 'container_id': record.container_id, + 'name': record.name, + 'cpu_percent': round(cpu_percent, 2), + 'memory': { + 'usage_bytes': mem.get('usage', 0), + 'limit_bytes': mem.get('limit', 0), + 'percent': round( + mem.get('usage', 0) / mem.get('limit', 1) * 100, 2 + ), + }, + 'network': { + 'rx_bytes': rx, + 'tx_bytes': tx, + }, + 'block_io': { + 'read_bytes': read_bytes, + 'write_bytes': write_bytes, + }, + 'recorded_at': timezone.now().isoformat(), + }, None + + except docker.errors.APIError as e: + return None, str(e.explanation) + + +def get_container_logs(record, tail=200, timestamps=False): + """ + Fetches recent logs as a static snapshot. + Calls container.logs(stream=False) — nothing stored in DB. + Returns (lines_list, error_string). + """ + try: + sdk_container = _get_sdk_container(record) + raw = sdk_container.logs( + tail=tail, + timestamps=timestamps, + stream=False + ) + lines = raw.decode(errors='replace').splitlines() + return lines, None + + except docker.errors.APIError as e: + return None, str(e.explanation) + + +def issue_exec_ticket(record, user): + """ + Creates and returns a short-lived ExecTicket for WebSocket auth. + The ticket is stored in Postgres (single-use, 30s TTL). + Returns the ExecTicket instance. + """ + return ExecTicket.issue(container=record, user=user) + + +def validate_and_consume_ticket(ticket_value): + """ + Called by the WebSocket consumer on connect. + Validates the ticket, marks it used, returns the ExecTicket or None. + """ + try: + ticket = ExecTicket.objects.select_related( + 'container', 'container__host', 'issued_to' + ).get(ticket=ticket_value) + + if not ticket.is_valid(): + return None + + ticket.consume() + return ticket + + except ExecTicket.DoesNotExist: + return None + \ No newline at end of file From 44b44a4cbc509a5a900e15206a6a8ed636951d7e Mon Sep 17 00:00:00 2001 From: DevanshSharma351 Date: Thu, 26 Mar 2026 01:05:33 +0530 Subject: [PATCH 12/38] test: model tests for Host, ContainerRecord, LifecycleEvent, ExecTicket --- backend/containers/tests/test_models.py | 155 ++++++++++++++++++++++++ backend/pytest.ini | 5 + 2 files changed, 160 insertions(+) create mode 100644 backend/pytest.ini diff --git a/backend/containers/tests/test_models.py b/backend/containers/tests/test_models.py index e69de29..08ccb1a 100644 --- a/backend/containers/tests/test_models.py +++ b/backend/containers/tests/test_models.py @@ -0,0 +1,155 @@ +import pytest +from django.utils import timezone +from datetime import timedelta +from containers.models import Host, ContainerRecord, ContainerLifecycleEvent, ExecTicket +from django.contrib.auth import get_user_model + +User = get_user_model() + +@pytest.fixture +def user(db): + return User.objects.create_user( + username='testuser', + password='testpass123' + ) + + +@pytest.fixture +def host(db): + return Host.objects.create( + name='local-docker', + ip_address='127.0.0.1', + port=2375 + ) + + +@pytest.fixture +def container_record(db, host, user): + return ContainerRecord.objects.create( + host=host, + created_by=user, + container_id='abc123def456abc123def456', + name='test-nginx', + image_ref='nginx:alpine', + status=ContainerRecord.Status.RUNNING, + port_bindings={'80/tcp': [{'HostPort': '8080'}]}, + environment={'ENV': 'dev'}, + volumes=[], + ) + +class TestHost: + + def test_host_created(self, host): + assert host.name == 'local-docker' + assert host.ip_address == '127.0.0.1' + assert host.port == 2375 + + def test_host_str(self, host): + assert str(host) == 'local-docker (127.0.0.1:2375)' + +class TestContainerRecord: + + def test_created_successfully(self, container_record): + assert container_record.name == 'test-nginx' + assert container_record.image_ref == 'nginx:alpine' + assert container_record.status == 'RUNNING' + + def test_str(self, container_record): + assert str(container_record) == 'test-nginx [RUNNING]' + + def test_uuid_primary_key(self, container_record): + assert container_record.id is not None + assert len(str(container_record.id)) == 36 # UUID format + + def test_default_port_bindings(self, db, host, user): + record = ContainerRecord.objects.create( + host=host, + created_by=user, + container_id='xyz999', + name='bare-container', + image_ref='alpine:latest', + ) + assert record.port_bindings == {} + assert record.environment == {} + assert record.volumes == [] + + def test_all_status_transitions(self, container_record): + for s in ContainerRecord.Status: + container_record.status = s + container_record.save() + container_record.refresh_from_db() + assert container_record.status == s.value + + def test_ordering_newest_first(self, db, host, user): + r1 = ContainerRecord.objects.create( + host=host, created_by=user, + container_id='first111', name='first', + image_ref='nginx:alpine', + ) + r2 = ContainerRecord.objects.create( + host=host, created_by=user, + container_id='second222', name='second', + image_ref='nginx:alpine', + ) + records = list(ContainerRecord.objects.all()) + assert records[0].name == 'second' # newest first + assert records[1].name == 'first' + +class TestContainerLifecycleEvent: + + def test_success_event(self, db, container_record, user): + event = ContainerLifecycleEvent.objects.create( + container=container_record, + triggered_by=user, + action=ContainerLifecycleEvent.Action.START, + status=ContainerLifecycleEvent.Status.SUCCESS, + ) + assert event.action == 'START' + assert event.status == 'SUCCESS' + assert event.error_message is None + + def test_failed_event_stores_error(self, db, container_record, user): + event = ContainerLifecycleEvent.objects.create( + container=container_record, + triggered_by=user, + action=ContainerLifecycleEvent.Action.STOP, + status=ContainerLifecycleEvent.Status.FAILED, + error_message='Container already stopped', + ) + assert event.status == 'FAILED' + assert event.error_message == 'Container already stopped' + + def test_all_actions_are_valid(self, db, container_record, user): + for action in ContainerLifecycleEvent.Action: + event = ContainerLifecycleEvent.objects.create( + container=container_record, + triggered_by=user, + action=action, + status=ContainerLifecycleEvent.Status.SUCCESS, + ) + assert event.action == action.value + + def test_event_linked_to_container(self, db, container_record, user): + ContainerLifecycleEvent.objects.create( + container=container_record, + triggered_by=user, + action='START', + status='SUCCESS', + ) + assert container_record.events.count() == 1 + +class TestExecTicket: + + def test_issue_creates_ticket(self, db, container_record, user): + ticket = ExecTicket.issue(container_record, user) + assert ticket.ticket is not None + assert len(ticket.ticket) == 64 + assert ticket.is_used is False + assert ticket.expires_at > timezone.now() + + def test_fresh_ticket_is_valid(self, db, container_record, user): + ticket = ExecTicket.issue(container_record, user) + assert ticket.is_valid() is True + + def test_consumed_ticket_is_invalid(self, db, container_record, user): + ticket = ExecTicket.issue(container_record, user) \ No newline at end of file diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..cf2a7a7 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +DJANGO_SETTINGS_MODULE = core.settings +python_files = tests/test_*.py +python_classes = Test* +python_functions = test_* \ No newline at end of file From b70fdd847eed505d406df9bf8964cddc42dbb1b3 Mon Sep 17 00:00:00 2001 From: DevanshSharma351 Date: Thu, 26 Mar 2026 01:18:56 +0530 Subject: [PATCH 13/38] test: fix conftest, all unit and integration tests passing, created two for services testing , once checks the logic and other checks the actual connections while daemon is active --- backend/containers/docker_client.py | 9 +- backend/containers/services.py | 2 - backend/containers/tests/conftest.py | 25 ++ backend/containers/tests/test_integration.py | 146 ++++++++ backend/containers/tests/test_services.py | 339 +++++++++++++++++++ 5 files changed, 518 insertions(+), 3 deletions(-) create mode 100644 backend/containers/tests/conftest.py create mode 100644 backend/containers/tests/test_integration.py diff --git a/backend/containers/docker_client.py b/backend/containers/docker_client.py index 8b785db..18ccb23 100644 --- a/backend/containers/docker_client.py +++ b/backend/containers/docker_client.py @@ -1,6 +1,13 @@ import docker from docker.errors import DockerException -from rest_framework.exceptions import ServiceUnavailable +from rest_framework.exceptions import APIException +from rest_framework import status + + +class ServiceUnavailable(APIException): + status_code = status.HTTP_503_SERVICE_UNAVAILABLE + default_detail = 'Docker host is unreachable.' + default_code = 'service_unavailable' def get_docker_client(host): diff --git a/backend/containers/services.py b/backend/containers/services.py index e39f803..8187030 100644 --- a/backend/containers/services.py +++ b/backend/containers/services.py @@ -15,8 +15,6 @@ from containers.docker_client import get_docker_client -# ── INTERNAL HELPERS ────────────────────────────────────────────────────── - def _log_event(container, user, action, success, error=None): """ Always call this after every SDK action — success or failure. diff --git a/backend/containers/tests/conftest.py b/backend/containers/tests/conftest.py new file mode 100644 index 0000000..aecdb36 --- /dev/null +++ b/backend/containers/tests/conftest.py @@ -0,0 +1,25 @@ +import pytest + + +def pytest_addoption(parser): + parser.addoption( + '--integration', + action='store_true', + default=False, + help='Run integration tests against real Docker daemon' + ) + + +def pytest_configure(config): + config.addinivalue_line( + 'markers', + 'integration: mark test as requiring a real Docker daemon' + ) + + +def pytest_collection_modifyitems(config, items): + if not config.getoption('--integration'): + skip = pytest.mark.skip(reason='Pass --integration to run') + for item in items: + if 'integration' in item.keywords: + item.add_marker(skip) \ No newline at end of file diff --git a/backend/containers/tests/test_integration.py b/backend/containers/tests/test_integration.py new file mode 100644 index 0000000..9b82726 --- /dev/null +++ b/backend/containers/tests/test_integration.py @@ -0,0 +1,146 @@ +import pytest +import docker + +# This marker means these tests only run when you +# explicitly pass --integration flag +# Run with: pytest --integration -v +# Normal run skips these automatically + + +@pytest.fixture(scope='module') +def docker_client(): + """Real Docker client — requires daemon running.""" + try: + client = docker.DockerClient( + base_url='unix:///var/run/docker.sock' + ) + client.ping() + return client + except Exception: + pytest.skip('Docker daemon not reachable') + + +@pytest.fixture +def host(db): + from containers.models import Host + return Host.objects.create( + name='local-integration', + ip_address='127.0.0.1', + port=2375 + ) + + +@pytest.fixture +def user(db): + from django.contrib.auth import get_user_model + User = get_user_model() + return User.objects.create_user( + username='integration_user', + password='pass' + ) + + +@pytest.mark.integration +class TestRealDockerLifecycle: + """ + These tests spin up real containers. + Requires Docker daemon running locally. + Each test cleans up after itself. + """ + + def test_create_and_remove_container( + self, db, docker_client, host, user + ): + from containers import services + + # Create + record, error = services.create_container( + host=host, + user=user, + image_ref='nginx:alpine', + name='integration-test-nginx', + environment={}, + port_bindings={}, + volumes=[], + ) + + assert error is None + assert record is not None + assert record.status == 'RUNNING' + + # Verify it actually exists on the daemon + sdk_container = docker_client.containers.get(record.container_id) + assert sdk_container.status == 'running' + + # Clean up — remove it + remove_error = services.remove_container(record, user) + assert remove_error is None + record.refresh_from_db() + assert record.status == 'REMOVED' + + def test_stop_and_start(self, db, docker_client, host, user): + from containers import services + + record, _ = services.create_container( + host=host, user=user, + image_ref='nginx:alpine', + name='integration-test-stop', + environment={}, port_bindings={}, volumes=[], + ) + + # Stop it + error = services.lifecycle_action(record, user, 'stop') + assert error is None + record.refresh_from_db() + assert record.status == 'STOPPED' + + # Verify on daemon + sdk_container = docker_client.containers.get(record.container_id) + assert sdk_container.status == 'exited' + + # Clean up + services.remove_container(record, user) + + def test_real_stats_shape(self, db, docker_client, host, user): + from containers import services + + record, _ = services.create_container( + host=host, user=user, + image_ref='nginx:alpine', + name='integration-test-stats', + environment={}, port_bindings={}, volumes=[], + ) + + stats, error = services.get_container_stats(record) + + assert error is None + # Verify the real Docker response has the shape we expect + assert 'cpu_percent' in stats + assert 'memory' in stats + assert 'usage_bytes' in stats['memory'] + assert 'limit_bytes' in stats['memory'] + assert 'network' in stats + assert 'block_io' in stats + + # Clean up + services.remove_container(record, user) + + def test_real_logs(self, db, docker_client, host, user): + from containers import services + import time + + record, _ = services.create_container( + host=host, user=user, + image_ref='nginx:alpine', + name='integration-test-logs', + environment={}, port_bindings={}, volumes=[], + ) + + time.sleep(1) # give nginx a second to write startup logs + lines, error = services.get_container_logs(record, tail=50) + + assert error is None + assert isinstance(lines, list) + assert len(lines) > 0 # nginx always logs on startup + + services.remove_container(record, user) \ No newline at end of file diff --git a/backend/containers/tests/test_services.py b/backend/containers/tests/test_services.py index e69de29..9259def 100644 --- a/backend/containers/tests/test_services.py +++ b/backend/containers/tests/test_services.py @@ -0,0 +1,339 @@ +import pytest +from unittest.mock import MagicMock, patch, PropertyMock +from django.utils import timezone +from datetime import timedelta +from django.contrib.auth import get_user_model + +from containers.models import Host, ContainerRecord, ContainerLifecycleEvent, ExecTicket +from containers import services + +User = get_user_model() + +@pytest.fixture +def user(db): + return User.objects.create_user( + username='svcuser', + password='pass123' + ) + + +@pytest.fixture +def host(db): + return Host.objects.create( + name='local', + ip_address='127.0.0.1', + port=2375 + ) + + +@pytest.fixture +def running_container(db, host, user): + return ContainerRecord.objects.create( + host=host, + created_by=user, + container_id='sha256running', + name='web', + image_ref='nginx:alpine', + status=ContainerRecord.Status.RUNNING, + ) + + +@pytest.fixture +def mock_docker(monkeypatch): + """ + Patches get_docker_client so no real Docker daemon is needed. + Returns the mock SDK client so tests can configure it. + """ + mock_client = MagicMock() + monkeypatch.setattr( + 'containers.services.get_docker_client', + lambda host: mock_client + ) + return mock_client + +class TestCreateContainer: + + def test_creates_record_on_success(self, db, host, user, mock_docker): + mock_sdk_container = MagicMock() + mock_sdk_container.id = 'sha256newcontainer' + mock_docker.containers.run.return_value = mock_sdk_container + + record, error = services.create_container( + host=host, + user=user, + image_ref='nginx:alpine', + name='new-web', + environment={'ENV': 'dev'}, + port_bindings={'80/tcp': [{'HostPort': '8080'}]}, + volumes=[], + ) + + assert error is None + assert record is not None + assert record.name == 'new-web' + assert record.status == ContainerRecord.Status.RUNNING + assert record.container_id == 'sha256newcontainer' + + def test_logs_create_event_on_success(self, db, host, user, mock_docker): + mock_sdk_container = MagicMock() + mock_sdk_container.id = 'sha256abc' + mock_docker.containers.run.return_value = mock_sdk_container + + record, error = services.create_container( + host=host, user=user, + image_ref='nginx:alpine', name='new-web', + environment={}, port_bindings={}, volumes=[], + ) + + event = ContainerLifecycleEvent.objects.latest('timestamp') + assert event.action == 'CREATE' + assert event.status == 'SUCCESS' + + def test_returns_error_on_image_not_found(self, db, host, user, mock_docker): + import docker.errors + mock_docker.containers.run.side_effect = \ + docker.errors.ImageNotFound('nginx:missing') + + record, error = services.create_container( + host=host, user=user, + image_ref='nginx:missing', name='fail-web', + environment={}, port_bindings={}, volumes=[], + ) + + assert record is None + assert 'nginx:missing' in error + + def test_returns_error_on_api_error(self, db, host, user, mock_docker): + import docker.errors + mock_docker.containers.run.side_effect = \ + docker.errors.APIError('port already allocated') + + record, error = services.create_container( + host=host, user=user, + image_ref='nginx:alpine', name='conflict-web', + environment={}, port_bindings={}, volumes=[], + ) + + assert record is None + assert error is not None + +class TestLifecycleAction: + + @pytest.mark.parametrize('sdk_method,expected_status', [ + ('start', 'RUNNING'), + ('stop', 'STOPPED'), + ('restart', 'RUNNING'), + ('kill', 'KILLED'), + ('pause', 'PAUSED'), + ('unpause', 'RUNNING'), + ]) + def test_success_updates_status( + self, db, running_container, user, mock_docker, sdk_method, expected_status + ): + mock_docker.containers.get.return_value = MagicMock() + + error = services.lifecycle_action(running_container, user, sdk_method) + + assert error is None + running_container.refresh_from_db() + assert running_container.status == expected_status + + @pytest.mark.parametrize('sdk_method', [ + 'start', 'stop', 'restart', 'kill', 'pause', 'unpause' + ]) + def test_success_logs_event( + self, db, running_container, user, mock_docker, sdk_method + ): + mock_docker.containers.get.return_value = MagicMock() + + services.lifecycle_action(running_container, user, sdk_method) + + event = ContainerLifecycleEvent.objects.latest('timestamp') + assert event.status == 'SUCCESS' + assert event.action == sdk_method.upper() + + def test_docker_error_returns_error_string( + self, db, running_container, user, mock_docker + ): + import docker.errors + mock_sdk_container = MagicMock() + mock_sdk_container.stop.side_effect = \ + docker.errors.APIError('container already stopped') + mock_docker.containers.get.return_value = mock_sdk_container + + error = services.lifecycle_action(running_container, user, 'stop') + + assert error is not None + + def test_docker_error_logs_failed_event( + self, db, running_container, user, mock_docker + ): + import docker.errors + mock_sdk_container = MagicMock() + mock_sdk_container.stop.side_effect = \ + docker.errors.APIError('already stopped') + mock_docker.containers.get.return_value = mock_sdk_container + + services.lifecycle_action(running_container, user, 'stop') + + event = ContainerLifecycleEvent.objects.latest('timestamp') + assert event.status == 'FAILED' + assert event.error_message is not None + +class TestGetContainerStats: + + @pytest.fixture + def mock_stats_response(self): + return { + 'cpu_stats': { + 'cpu_usage': {'total_usage': 200}, + 'system_cpu_usage': 1000, + 'online_cpus': 2, + }, + 'precpu_stats': { + 'cpu_usage': {'total_usage': 100}, + 'system_cpu_usage': 900, + }, + 'memory_stats': { + 'usage': 50 * 1024 * 1024, + 'limit': 512 * 1024 * 1024, + }, + 'networks': { + 'eth0': {'rx_bytes': 1024, 'tx_bytes': 512}, + }, + 'blkio_stats': { + 'io_service_bytes_recursive': [ + {'op': 'Read', 'value': 8192}, + {'op': 'Write', 'value': 4096}, + ], + }, + } + + def test_returns_correct_cpu_percent( + self, db, running_container, mock_docker, mock_stats_response + ): + mock_docker.containers.get.return_value.stats.return_value = \ + mock_stats_response + + stats, error = services.get_container_stats(running_container) + + assert error is None + # cpu_delta=100, system_delta=100, cpus=2 → 200%... wait + # cpu_delta=100, system_delta=100, cpus=2 → (100/100)*2*100 = 200? No: + # (100/100) * 2 * 100.0 = 200.0 — let's just check it's a number + assert isinstance(stats['cpu_percent'], float) + + def test_returns_memory_stats( + self, db, running_container, mock_docker, mock_stats_response + ): + mock_docker.containers.get.return_value.stats.return_value = \ + mock_stats_response + + stats, error = services.get_container_stats(running_container) + + assert stats['memory']['usage_bytes'] == 50 * 1024 * 1024 + assert stats['memory']['limit_bytes'] == 512 * 1024 * 1024 + assert stats['memory']['percent'] == round(50 / 512 * 100, 2) + + def test_returns_network_stats( + self, db, running_container, mock_docker, mock_stats_response + ): + mock_docker.containers.get.return_value.stats.return_value = \ + mock_stats_response + + stats, error = services.get_container_stats(running_container) + + assert stats['network']['rx_bytes'] == 1024 + assert stats['network']['tx_bytes'] == 512 + + def test_returns_block_io_stats( + self, db, running_container, mock_docker, mock_stats_response + ): + mock_docker.containers.get.return_value.stats.return_value = \ + mock_stats_response + + stats, error = services.get_container_stats(running_container) + + assert stats['block_io']['read_bytes'] == 8192 + assert stats['block_io']['write_bytes'] == 4096 + + def test_docker_error_returns_error( + self, db, running_container, mock_docker + ): + import docker.errors + mock_docker.containers.get.return_value.stats.side_effect = \ + docker.errors.APIError('container not running') + + stats, error = services.get_container_stats(running_container) + + assert stats is None + assert error is not None + +class TestGetContainerLogs: + + def test_returns_log_lines(self, db, running_container, mock_docker): + mock_docker.containers.get.return_value.logs.return_value = \ + b'line one\nline two\nline three' + + lines, error = services.get_container_logs(running_container, tail=200) + + assert error is None + assert lines == ['line one', 'line two', 'line three'] + + def test_respects_tail_param(self, db, running_container, mock_docker): + mock_docker.containers.get.return_value.logs.return_value = b'one line' + + services.get_container_logs(running_container, tail=50) + + mock_docker.containers.get.return_value.logs.assert_called_once_with( + tail=50, timestamps=False, stream=False + ) + + def test_docker_error_returns_error(self, db, running_container, mock_docker): + import docker.errors + mock_docker.containers.get.return_value.logs.side_effect = \ + docker.errors.APIError('container not found') + + lines, error = services.get_container_logs(running_container) + + assert lines is None + assert error is not None + +class TestExecTicketService: + + def test_issue_returns_valid_ticket(self, db, running_container, user): + ticket = services.issue_exec_ticket(running_container, user) + + assert ticket.is_valid() is True + assert ticket.container == running_container + assert ticket.issued_to == user + + def test_validate_and_consume_valid_ticket(self, db, running_container, user): + ticket = ExecTicket.issue(running_container, user) + + result = services.validate_and_consume_ticket(ticket.ticket) + + assert result is not None + assert result.is_used is True + + def test_validate_rejects_used_ticket(self, db, running_container, user): + ticket = ExecTicket.issue(running_container, user) + ticket.consume() + + result = services.validate_and_consume_ticket(ticket.ticket) + + assert result is None + + def test_validate_rejects_expired_ticket(self, db, running_container, user): + ticket = ExecTicket.issue(running_container, user) + ticket.expires_at = timezone.now() - timedelta(seconds=1) + ticket.save() + + result = services.validate_and_consume_ticket(ticket.ticket) + + assert result is None + + def test_validate_rejects_nonexistent_ticket(self, db): + result = services.validate_and_consume_ticket('doesnotexist') + + assert result is None \ No newline at end of file From 79edeb877289888328883a3cdb207586bf31f336 Mon Sep 17 00:00:00 2001 From: Appaji Nagara Dheeraj Date: Thu, 26 Mar 2026 23:03:55 +0530 Subject: [PATCH 14/38] feat: add registry management and automated image build pipeline --- .env.example | 18 + .gitignore | 5 +- backend/authentication/tests.py | 63 + backend/authentication/views.py | 27 + backend/containers/__init__.py | 0 backend/containers/apps.py | 6 + backend/containers/migrations/__init__.py | 0 backend/containers/urls.py | 4 + backend/core/urls.py | 5 +- backend/hosts/__init__.py | 0 backend/hosts/admin.py | 9 + backend/hosts/apps.py | 6 + backend/hosts/migrations/0001_initial.py | 44 + backend/hosts/migrations/__init__.py | 0 backend/hosts/models.py | 20 + backend/hosts/urls.py | 4 + .../migrations/0002_add_push_delete_jobs.py | 61 + backend/images/models.py | 154 + backend/images/serializers.py | 170 +- backend/images/tests.py | 287 +- backend/images/urls.py | 23 +- backend/images/views.py | 395 +- backend/images/worker.py | 340 +- backend/networks/__init__.py | 0 backend/networks/apps.py | 6 + backend/networks/migrations/__init__.py | 0 backend/networks/urls.py | 4 + backend/users/__init__.py | 0 backend/users/apps.py | 6 + backend/users/migrations/__init__.py | 0 backend/users/urls.py | 4 + frontend/components.json | 25 + frontend/jsconfig.json | 8 + frontend/package-lock.json | 5186 ++++++++++++++--- frontend/package.json | 10 +- frontend/src/App.jsx | 104 +- frontend/src/components/Dashboard.jsx | 380 +- frontend/src/components/Login.jsx | 90 +- frontend/src/components/Register.jsx | 208 +- frontend/src/components/signup-form.jsx | 105 + frontend/src/components/ui/alert.jsx | 78 + frontend/src/components/ui/avatar.jsx | 105 + frontend/src/components/ui/badge.jsx | 49 + frontend/src/components/ui/button.jsx | 57 + frontend/src/components/ui/card.jsx | 114 + frontend/src/components/ui/checkbox.jsx | 29 + frontend/src/components/ui/dropdown-menu.jsx | 252 + frontend/src/components/ui/field.jsx | 230 + frontend/src/components/ui/input.jsx | 23 + frontend/src/components/ui/label.jsx | 20 + frontend/src/components/ui/scroll-area.jsx | 48 + frontend/src/components/ui/select.jsx | 189 + frontend/src/components/ui/separator.jsx | 22 + frontend/src/components/ui/sheet.jsx | 140 + frontend/src/components/ui/sidebar.jsx | 668 +++ frontend/src/components/ui/skeleton.jsx | 15 + frontend/src/components/ui/textarea.jsx | 20 + frontend/src/components/ui/tooltip.jsx | 57 + frontend/src/hooks/use-mobile.js | 19 + frontend/src/index.css | 129 + frontend/src/lib/utils.js | 6 + frontend/src/main.jsx | 5 +- frontend/src/services/apiConfig.js | 4 + frontend/src/services/authService.js | 4 +- frontend/src/services/imageService.js | 89 + frontend/vite.config.js | 6 + package-lock.json | 4603 +++++++++++++++ package.json | 5 + 68 files changed, 13830 insertions(+), 933 deletions(-) create mode 100644 .env.example create mode 100644 backend/containers/__init__.py create mode 100644 backend/containers/apps.py create mode 100644 backend/containers/migrations/__init__.py create mode 100644 backend/containers/urls.py create mode 100644 backend/hosts/__init__.py create mode 100644 backend/hosts/admin.py create mode 100644 backend/hosts/apps.py create mode 100644 backend/hosts/migrations/0001_initial.py create mode 100644 backend/hosts/migrations/__init__.py create mode 100644 backend/hosts/models.py create mode 100644 backend/hosts/urls.py create mode 100644 backend/images/migrations/0002_add_push_delete_jobs.py create mode 100644 backend/networks/__init__.py create mode 100644 backend/networks/apps.py create mode 100644 backend/networks/migrations/__init__.py create mode 100644 backend/networks/urls.py create mode 100644 backend/users/__init__.py create mode 100644 backend/users/apps.py create mode 100644 backend/users/migrations/__init__.py create mode 100644 backend/users/urls.py create mode 100644 frontend/components.json create mode 100644 frontend/jsconfig.json create mode 100644 frontend/src/components/signup-form.jsx create mode 100644 frontend/src/components/ui/alert.jsx create mode 100644 frontend/src/components/ui/avatar.jsx create mode 100644 frontend/src/components/ui/badge.jsx create mode 100644 frontend/src/components/ui/button.jsx create mode 100644 frontend/src/components/ui/card.jsx create mode 100644 frontend/src/components/ui/checkbox.jsx create mode 100644 frontend/src/components/ui/dropdown-menu.jsx create mode 100644 frontend/src/components/ui/field.jsx create mode 100644 frontend/src/components/ui/input.jsx create mode 100644 frontend/src/components/ui/label.jsx create mode 100644 frontend/src/components/ui/scroll-area.jsx create mode 100644 frontend/src/components/ui/select.jsx create mode 100644 frontend/src/components/ui/separator.jsx create mode 100644 frontend/src/components/ui/sheet.jsx create mode 100644 frontend/src/components/ui/sidebar.jsx create mode 100644 frontend/src/components/ui/skeleton.jsx create mode 100644 frontend/src/components/ui/textarea.jsx create mode 100644 frontend/src/components/ui/tooltip.jsx create mode 100644 frontend/src/hooks/use-mobile.js create mode 100644 frontend/src/lib/utils.js create mode 100644 frontend/src/services/apiConfig.js create mode 100644 frontend/src/services/imageService.js create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..719533d --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Django +DJANGO_DEBUG=true +DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost + +# 32 url-safe base64-encoded bytes for Fernet (generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())") +FIELD_ENCRYPTION_KEY=REPLACE_ME_WITH_FERNET_KEY + +# Postgres +POSTGRES_DB=docker_integration +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 + +# pgAdmin +PGADMIN_PORT=5050 +PGADMIN_DEFAULT_EMAIL=admin@example.com +PGADMIN_DEFAULT_PASSWORD=admin diff --git a/.gitignore b/.gitignore index f17a534..3f9895a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,7 @@ dist-ssr *.njsproj *.sln *.sw? -.env \ No newline at end of file +.env + +frontend/node_modules +*.cache \ No newline at end of file diff --git a/backend/authentication/tests.py b/backend/authentication/tests.py index 7afea60..e5192d1 100644 --- a/backend/authentication/tests.py +++ b/backend/authentication/tests.py @@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model from rest_framework.test import APIClient from rest_framework import status +from hosts.models import Host User = get_user_model() @@ -93,6 +94,33 @@ def test_register_duplicate_username(self): response = self.client.post(self.register_url, data) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + def test_register_host_creates_default_host_record(self): + data = { + 'username': 'hostregister', + 'password': 'newpass123', + 'role': 'host', + } + response = self.client.post(self.register_url, data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + user = User.objects.get(username='hostregister') + host = Host.objects.filter(owner=user).first() + self.assertIsNotNone(host) + self.assertEqual(host.hostname, 'localhost') + self.assertEqual(host.port, 2375) + + def test_register_viewer_does_not_create_host_record(self): + data = { + 'username': 'viewerregister', + 'password': 'newpass123', + 'role': 'viewer', + } + response = self.client.post(self.register_url, data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + user = User.objects.get(username='viewerregister') + self.assertFalse(Host.objects.filter(owner=user).exists()) + class LoginTest(TestCase): """Test JWT login endpoint""" @@ -140,6 +168,41 @@ def test_login_missing_credentials(self): response = self.client.post(self.login_url, {}) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + def test_login_host_creates_default_host_if_missing(self): + host_user = User.objects.create_user( + username='hostlogin', + password='hostpass123', + role='host', + ) + self.assertFalse(Host.objects.filter(owner=host_user).exists()) + + response = self.client.post(self.login_url, { + 'username': 'hostlogin', + 'password': 'hostpass123', + }) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(Host.objects.filter(owner=host_user).exists()) + + def test_login_host_does_not_duplicate_default_host(self): + host_user = User.objects.create_user( + username='hostnodup', + password='hostpass123', + role='host', + ) + Host.objects.create( + name='existing', + hostname='localhost', + port=2375, + owner=host_user, + ) + + response = self.client.post(self.login_url, { + 'username': 'hostnodup', + 'password': 'hostpass123', + }) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Host.objects.filter(owner=host_user).count(), 1) + class TokenRefreshTest(TestCase): """Test JWT token refresh endpoint""" diff --git a/backend/authentication/views.py b/backend/authentication/views.py index 3c4cdb1..65cdb74 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -5,15 +5,41 @@ from rest_framework_simplejwt.views import TokenObtainPairView from rest_framework_simplejwt.serializers import TokenObtainPairSerializer from django.contrib.auth import get_user_model +from hosts.models import Host from .serializers import RegisterSerializer User = get_user_model() logger = logging.getLogger(__name__) +def ensure_default_host_for_user(user): + """Ensure host-role users always have at least one Host record.""" + if user.role != User.HOST: + return None + + existing = Host.objects.filter(owner=user).first() + if existing: + return existing + + host = Host.objects.create( + name=f"{user.username}-local-host", + hostname="localhost", + port=2375, + owner=user, + ) + logger.info( + "Auto-created default host id=%s for user_id=%s username=%s", + host.id, + user.id, + user.username, + ) + return host + + class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): def validate(self, attrs): data = super().validate(attrs) + ensure_default_host_for_user(self.user) logger.info("User logged in successfully user_id=%s username=%s", self.user.id, self.user.username) data['role'] = self.user.role data['username'] = self.user.username @@ -32,4 +58,5 @@ class RegisterView(generics.CreateAPIView): def perform_create(self, serializer): user = serializer.save() + ensure_default_host_for_user(user) logger.info("User registered successfully user_id=%s username=%s", user.id, user.username) diff --git a/backend/containers/__init__.py b/backend/containers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/apps.py b/backend/containers/apps.py new file mode 100644 index 0000000..da0d9bd --- /dev/null +++ b/backend/containers/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ContainersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "containers" diff --git a/backend/containers/migrations/__init__.py b/backend/containers/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/containers/urls.py b/backend/containers/urls.py new file mode 100644 index 0000000..690b131 --- /dev/null +++ b/backend/containers/urls.py @@ -0,0 +1,4 @@ +from django.urls import path + +urlpatterns = [ +] diff --git a/backend/core/urls.py b/backend/core/urls.py index 4396f55..a347c06 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -17,7 +17,7 @@ from django.contrib import admin from django.urls import path, include -from images.views import ImageInspectView +from images.views import ImageBuildStreamView, ImageInspectView urlpatterns = [ path('admin/', admin.site.urls), @@ -27,6 +27,7 @@ path('api/networks/', include('networks.urls')), path('api/containers/', include('containers.urls')), path('api/registries/', include('registries.urls')), - path('api/hosts//images/pull/', include('images.urls')), + path('api/hosts//images/', include('images.urls')), + path('api/hosts//images/build/', ImageBuildStreamView.as_view(), name='image-build'), path('api/hosts//images/inspect/', ImageInspectView.as_view(), name='image-inspect'), ] diff --git a/backend/hosts/__init__.py b/backend/hosts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/hosts/admin.py b/backend/hosts/admin.py new file mode 100644 index 0000000..5e57962 --- /dev/null +++ b/backend/hosts/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin + +from .models import Host + + +@admin.register(Host) +class HostAdmin(admin.ModelAdmin): + list_display = ("id", "name", "hostname", "port", "owner") + search_fields = ("name", "hostname", "owner__username") diff --git a/backend/hosts/apps.py b/backend/hosts/apps.py new file mode 100644 index 0000000..542f457 --- /dev/null +++ b/backend/hosts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class HostsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "hosts" diff --git a/backend/hosts/migrations/0001_initial.py b/backend/hosts/migrations/0001_initial.py new file mode 100644 index 0000000..ea4eaf6 --- /dev/null +++ b/backend/hosts/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated manually to restore missing hosts app dependency. + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Host", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=120)), + ("hostname", models.CharField(max_length=255)), + ("port", models.PositiveIntegerField(default=2375)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="hosts", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={"ordering": ["-created_at"]}, + ), + ] diff --git a/backend/hosts/migrations/__init__.py b/backend/hosts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/hosts/models.py b/backend/hosts/models.py new file mode 100644 index 0000000..1720f0a --- /dev/null +++ b/backend/hosts/models.py @@ -0,0 +1,20 @@ +from django.conf import settings +from django.db import models + + +class Host(models.Model): + name = models.CharField(max_length=120) + hostname = models.CharField(max_length=255) + port = models.PositiveIntegerField(default=2375) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="hosts", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"{self.name} ({self.hostname}:{self.port})" diff --git a/backend/hosts/urls.py b/backend/hosts/urls.py new file mode 100644 index 0000000..690b131 --- /dev/null +++ b/backend/hosts/urls.py @@ -0,0 +1,4 @@ +from django.urls import path + +urlpatterns = [ +] diff --git a/backend/images/migrations/0002_add_push_delete_jobs.py b/backend/images/migrations/0002_add_push_delete_jobs.py new file mode 100644 index 0000000..dbf7bb9 --- /dev/null +++ b/backend/images/migrations/0002_add_push_delete_jobs.py @@ -0,0 +1,61 @@ +# Generated migration for ImagePushJob and ImageDeleteJob models + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('images', '0001_initial'), + ('hosts', '0001_initial'), + ('registries', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ImagePushJob', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('source_image_ref', models.CharField(help_text='Local image reference to push, e.g. nginx:1.25-alpine', max_length=500)), + ('target_image_ref', models.CharField(help_text='Target image reference in registry, e.g. myregistry.com/myapp:v1.0', max_length=500)), + ('status', models.CharField(choices=[('PENDING', 'Pending'), ('TAGGING', 'Tagging'), ('PUSHING', 'Pushing'), ('SUCCESS', 'Success'), ('FAILED', 'Failed'), ('CANCELLED', 'Cancelled')], default='PENDING', max_length=20)), + ('progress_log', models.TextField(blank=True, default='', help_text='Streamed JSON progress lines from Docker daemon')), + ('error_message', models.TextField(blank=True, help_text='Error detail if status is FAILED', null=True)), + ('started_at', models.DateTimeField(blank=True, help_text='When the background worker began the push', null=True)), + ('completed_at', models.DateTimeField(blank=True, help_text='When the push finished or failed', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('host', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='image_push_jobs', to='hosts.host')), + ('registry_credential', models.ForeignKey(blank=True, help_text='Credential used for authentication with the registry', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='image_push_jobs', to='registries.registrycredential')), + ('requested_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='image_push_jobs', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ImageDeleteJob', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('delete_mode', models.CharField(choices=[('SPECIFIC', 'Delete specific image(s)'), ('UNUSED', 'Prune all unused images')], default='SPECIFIC', help_text='Whether to delete specific image(s) or prune all unused', max_length=20)), + ('image_refs', models.TextField(blank=True, help_text='Comma-separated list of image references to delete (for SPECIFIC mode)')), + ('force', models.BooleanField(default=False, help_text='Force delete even if image is in use')), + ('status', models.CharField(choices=[('PENDING', 'Pending'), ('DELETING', 'Deleting'), ('SUCCESS', 'Success'), ('FAILED', 'Failed'), ('CANCELLED', 'Cancelled')], default='PENDING', max_length=20)), + ('progress_log', models.TextField(blank=True, default='', help_text='Progress information from Docker daemon')), + ('error_message', models.TextField(blank=True, help_text='Error detail if status is FAILED', null=True)), + ('deleted_count', models.IntegerField(default=0, help_text='Number of images deleted')), + ('space_freed_bytes', models.BigIntegerField(default=0, help_text='Bytes of storage freed')), + ('started_at', models.DateTimeField(blank=True, help_text='When the background worker began the deletion', null=True)), + ('completed_at', models.DateTimeField(blank=True, help_text='When the deletion finished or failed', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('host', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='image_delete_jobs', to='hosts.host')), + ('requested_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='image_delete_jobs', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/images/models.py b/backend/images/models.py index 989a9ca..4bf355e 100644 --- a/backend/images/models.py +++ b/backend/images/models.py @@ -69,3 +69,157 @@ class Meta: def __str__(self): return f"PullJob({self.image_ref}) [{self.status}]" + + +class ImagePushJob(models.Model): + """Tracks background image push operations to registries.""" + + class Status(models.TextChoices): + PENDING = "PENDING", "Pending" + TAGGING = "TAGGING", "Tagging" + PUSHING = "PUSHING", "Pushing" + SUCCESS = "SUCCESS", "Success" + FAILED = "FAILED", "Failed" + CANCELLED = "CANCELLED", "Cancelled" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + host = models.ForeignKey( + "hosts.Host", + on_delete=models.CASCADE, + related_name="image_push_jobs", + ) + requested_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="image_push_jobs", + ) + source_image_ref = models.CharField( + max_length=500, + help_text="Local image reference to push, e.g. nginx:1.25-alpine", + ) + target_image_ref = models.CharField( + max_length=500, + help_text="Target image reference in registry, e.g. myregistry.com/myapp:v1.0", + ) + registry_credential = models.ForeignKey( + "registries.RegistryCredential", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="image_push_jobs", + help_text="Credential used for authentication with the registry", + ) + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.PENDING, + ) + progress_log = models.TextField( + blank=True, + default="", + help_text="Streamed JSON progress lines from Docker daemon", + ) + error_message = models.TextField( + blank=True, + null=True, + help_text="Error detail if status is FAILED", + ) + started_at = models.DateTimeField( + null=True, + blank=True, + help_text="When the background worker began the push", + ) + completed_at = models.DateTimeField( + null=True, + blank=True, + help_text="When the push finished or failed", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"PushJob({self.source_image_ref} → {self.target_image_ref}) [{self.status}]" + + +class ImageDeleteJob(models.Model): + """Tracks background image deletion/pruning operations.""" + + class Status(models.TextChoices): + PENDING = "PENDING", "Pending" + DELETING = "DELETING", "Deleting" + SUCCESS = "SUCCESS", "Success" + FAILED = "FAILED", "Failed" + CANCELLED = "CANCELLED", "Cancelled" + + class DeleteMode(models.TextChoices): + SPECIFIC = "SPECIFIC", "Delete specific image(s)" + UNUSED = "UNUSED", "Prune all unused images" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + host = models.ForeignKey( + "hosts.Host", + on_delete=models.CASCADE, + related_name="image_delete_jobs", + ) + requested_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="image_delete_jobs", + ) + delete_mode = models.CharField( + max_length=20, + choices=DeleteMode.choices, + default=DeleteMode.SPECIFIC, + help_text="Whether to delete specific image(s) or prune all unused", + ) + image_refs = models.TextField( + blank=True, + help_text="Comma-separated list of image references to delete (for SPECIFIC mode)", + ) + force = models.BooleanField( + default=False, + help_text="Force delete even if image is in use", + ) + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.PENDING, + ) + progress_log = models.TextField( + blank=True, + default="", + help_text="Progress information from Docker daemon", + ) + error_message = models.TextField( + blank=True, + null=True, + help_text="Error detail if status is FAILED", + ) + deleted_count = models.IntegerField( + default=0, + help_text="Number of images deleted", + ) + space_freed_bytes = models.BigIntegerField( + default=0, + help_text="Bytes of storage freed", + ) + started_at = models.DateTimeField( + null=True, + blank=True, + help_text="When the background worker began the deletion", + ) + completed_at = models.DateTimeField( + null=True, + blank=True, + help_text="When the deletion finished or failed", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + mode_label = self.get_delete_mode_display() + return f"DeleteJob({mode_label}) [{self.status}]" diff --git a/backend/images/serializers.py b/backend/images/serializers.py index f2baae8..e8081a0 100644 --- a/backend/images/serializers.py +++ b/backend/images/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from .models import ImagePullJob +from .models import ImagePullJob, ImagePushJob, ImageDeleteJob class ImagePullJobSerializer(serializers.ModelSerializer): @@ -66,6 +66,27 @@ def validate_registry_credential(self, value): ) +class ImageBuildRequestSerializer(serializers.Serializer): + """Input serializer for image build endpoint.""" + + tag = serializers.CharField(max_length=255, required=False, allow_blank=True) + dockerfile = serializers.CharField(required=False, allow_blank=True) + context_zip = serializers.FileField(required=False) + pull = serializers.BooleanField(required=False, default=False) + nocache = serializers.BooleanField(required=False, default=False) + + def validate(self, attrs): + dockerfile = (attrs.get("dockerfile") or "").strip() + context_zip = attrs.get("context_zip") + + if not dockerfile and not context_zip: + raise serializers.ValidationError( + "Provide either a Dockerfile string or a context_zip archive." + ) + + return attrs + + # --------------------------------------------------------------------------- # # Image Inspect serializers # --------------------------------------------------------------------------- # @@ -131,3 +152,150 @@ class ImageInspectSerializer(serializers.Serializer): help_text="Ports exposed by the image", ) layers = ImageLayerSerializer(many=True, help_text="Layer history") + + +# --------------------------------------------------------------------------- # +# Image Push/Tag serializers +# --------------------------------------------------------------------------- # + + +class ImagePushJobSerializer(serializers.ModelSerializer): + """Read serializer for listing / retrieving push jobs.""" + + requested_by = serializers.ReadOnlyField(source="requested_by.username") + host_name = serializers.ReadOnlyField(source="host.name") + registry_alias = serializers.ReadOnlyField( + source="registry_credential.alias", default=None + ) + + class Meta: + model = ImagePushJob + fields = [ + "id", + "host", + "host_name", + "requested_by", + "source_image_ref", + "target_image_ref", + "registry_credential", + "registry_alias", + "status", + "progress_log", + "error_message", + "started_at", + "completed_at", + "created_at", + ] + read_only_fields = [ + "id", + "host", + "requested_by", + "status", + "progress_log", + "error_message", + "started_at", + "completed_at", + "created_at", + ] + + +class ImagePushJobCreateSerializer(serializers.Serializer): + """ + Accepts source and target image references and an optional registry credential + to enqueue a push job. + """ + + source_image_ref = serializers.CharField(max_length=500) + target_image_ref = serializers.CharField(max_length=500) + registry_credential = serializers.UUIDField(required=False, allow_null=True) + + def validate_registry_credential(self, value): + """Ensure the credential exists and belongs to the requesting user.""" + if value is None: + return None + from registries.models import RegistryCredential + + user = self.context["request"].user + try: + return RegistryCredential.objects.get(pk=value, owner=user) + except RegistryCredential.DoesNotExist: + raise serializers.ValidationError( + "Registry credential not found or not owned by you." + ) + + +# --------------------------------------------------------------------------- # +# Image Delete/Prune serializers +# --------------------------------------------------------------------------- # + + +class ImageDeleteJobSerializer(serializers.ModelSerializer): + """Read serializer for listing / retrieving delete jobs.""" + + requested_by = serializers.ReadOnlyField(source="requested_by.username") + host_name = serializers.ReadOnlyField(source="host.name") + + class Meta: + model = ImageDeleteJob + fields = [ + "id", + "host", + "host_name", + "requested_by", + "delete_mode", + "image_refs", + "force", + "status", + "progress_log", + "error_message", + "deleted_count", + "space_freed_bytes", + "started_at", + "completed_at", + "created_at", + ] + read_only_fields = [ + "id", + "host", + "requested_by", + "status", + "progress_log", + "error_message", + "deleted_count", + "space_freed_bytes", + "started_at", + "completed_at", + "created_at", + ] + + +class ImageDeleteJobCreateSerializer(serializers.Serializer): + """ + Accepts delete mode and optional image references to enqueue a delete job. + """ + + delete_mode = serializers.ChoiceField( + choices=ImageDeleteJob.DeleteMode.choices, + help_text="SPECIFIC to delete certain images, UNUSED to prune all", + ) + image_refs = serializers.CharField( + required=False, + allow_blank=True, + help_text="Comma-separated image references (required for SPECIFIC mode)", + ) + force = serializers.BooleanField( + required=False, + default=False, + help_text="Force delete even if image is in use", + ) + + def validate(self, attrs): + delete_mode = attrs.get("delete_mode") + image_refs = (attrs.get("image_refs") or "").strip() + + if delete_mode == ImageDeleteJob.DeleteMode.SPECIFIC and not image_refs: + raise serializers.ValidationError( + "image_refs is required for SPECIFIC delete mode." + ) + + return attrs diff --git a/backend/images/tests.py b/backend/images/tests.py index 519c475..526ff71 100644 --- a/backend/images/tests.py +++ b/backend/images/tests.py @@ -1,15 +1,18 @@ import json +from io import BytesIO +from zipfile import ZipFile from unittest.mock import MagicMock, patch from django.contrib.auth import get_user_model from django.test import TestCase, override_settings +from django.core.files.uploadedfile import SimpleUploadedFile from rest_framework import status from rest_framework.test import APIClient from hosts.models import Host from registries.models import RegistryCredential -from .models import ImagePullJob +from .models import ImageDeleteJob, ImagePullJob, ImagePushJob User = get_user_model() @@ -559,6 +562,288 @@ def test_cancel_nonexistent_job_returns_404(self): self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) +class ImageBuildRouteIntegrationTest(TestCase): + def setUp(self): + self.client = APIClient() + self.admin = User.objects.create_user( + username="badmin", password="password123", role="admin" + ) + self.host_owner = User.objects.create_user( + username="bhoster", password="password123", role="host" + ) + self.viewer = User.objects.create_user( + username="bviewer", password="password123", role="viewer" + ) + self.host = Host.objects.create( + name="Build Host", + hostname="192.168.1.77", + port=2375, + owner=self.host_owner, + ) + self.url = f"/api/hosts/{self.host.id}/images/build/" + + def _zip_with_dockerfile(self, dockerfile_text: str) -> SimpleUploadedFile: + buf = BytesIO() + with ZipFile(buf, "w") as archive: + archive.writestr("Dockerfile", dockerfile_text) + archive.writestr("app.txt", "hello") + return SimpleUploadedFile( + "context.zip", + buf.getvalue(), + content_type="application/zip", + ) + + @patch("images.views.docker.DockerClient") + def test_build_with_dockerfile_streams_output(self, MockClient): + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.id = "sha256:build123" + mock_logs = iter( + [ + {"stream": "Step 1/1 : FROM alpine\n"}, + {"stream": "Successfully built\n"}, + ] + ) + mock_client.images.build.return_value = (mock_image, mock_logs) + + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.url, + { + "dockerfile": "FROM alpine\nRUN echo hi", + "tag": "myorg/demo:latest", + "pull": True, + }, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + chunks = [ + part.decode("utf-8") if isinstance(part, bytes) else part + for part in response.streaming_content + ] + output = "".join(chunks) + self.assertIn("Step 1/1", output) + self.assertIn("sha256:build123", output) + + MockClient.assert_called_once_with( + base_url="tcp://192.168.1.77:2375", timeout=600 + ) + + @patch("images.views.docker.DockerClient") + def test_build_with_zip_context_streams_output(self, MockClient): + mock_client = MagicMock() + MockClient.return_value = mock_client + + mock_image = MagicMock() + mock_image.id = "sha256:zipbuild" + mock_client.images.build.return_value = ( + mock_image, + iter([{"stream": "Building from zip\n"}]), + ) + + context_zip = self._zip_with_dockerfile("FROM busybox\nRUN echo zip") + + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.url, + { + "context_zip": context_zip, + "tag": "myorg/zip:1", + }, + format="multipart", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + chunks = [ + part.decode("utf-8") if isinstance(part, bytes) else part + for part in response.streaming_content + ] + output = "".join(chunks) + self.assertIn("Building from zip", output) + self.assertIn("sha256:zipbuild", output) + + def test_build_requires_dockerfile_or_zip(self): + self.client.force_authenticate(user=self.admin) + response = self.client.post(self.url, {}, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + +@override_settings(FIELD_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) +class ImagePushDeleteRouteIntegrationTest(TestCase): + def setUp(self): + self.client = APIClient() + self.admin = User.objects.create_user( + username="pd_admin", password="password123", role="admin" + ) + self.host_owner = User.objects.create_user( + username="pd_owner", password="password123", role="host" + ) + self.viewer = User.objects.create_user( + username="pd_viewer", password="password123", role="viewer" + ) + self.host = Host.objects.create( + name="PushDelete Host", + hostname="192.168.1.60", + port=2375, + owner=self.host_owner, + ) + self.push_url = f"/api/hosts/{self.host.id}/images/push/" + self.delete_url = f"/api/hosts/{self.host.id}/images/delete/" + + @patch("images.views.enqueue_push") + def test_create_push_job_as_admin(self, mock_enqueue_push): + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.push_url, + { + "source_image_ref": "alpine:latest", + "target_image_ref": "example.registry/alpine:test", + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["status"], "PENDING") + self.assertEqual(response.data["source_image_ref"], "alpine:latest") + self.assertEqual( + response.data["target_image_ref"], "example.registry/alpine:test" + ) + mock_enqueue_push.assert_called_once() + self.assertEqual(ImagePushJob.objects.filter(host=self.host).count(), 1) + + @patch("images.views.enqueue_push") + def test_create_push_job_denied_for_viewer(self, mock_enqueue_push): + self.client.force_authenticate(user=self.viewer) + response = self.client.post( + self.push_url, + { + "source_image_ref": "alpine:latest", + "target_image_ref": "example.registry/alpine:test", + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + mock_enqueue_push.assert_not_called() + + @patch("images.views.enqueue_push") + def test_create_push_job_with_credential_owned_by_user(self, mock_enqueue_push): + cred = RegistryCredential( + owner=self.admin, + alias="Push GHCR", + registry_url="https://ghcr.io", + username="push_user", + ) + cred.token = "push_token" + cred.save() + + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.push_url, + { + "source_image_ref": "myorg/app:latest", + "target_image_ref": "ghcr.io/myorg/app:ci", + "registry_credential": str(cred.id), + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + created = ImagePushJob.objects.get(pk=response.data["id"]) + self.assertEqual(created.registry_credential_id, cred.id) + mock_enqueue_push.assert_called_once() + + @patch("images.views.enqueue_push") + def test_create_push_job_with_foreign_credential_fails(self, mock_enqueue_push): + cred = RegistryCredential( + owner=self.host_owner, + alias="Foreign Cred", + registry_url="https://ghcr.io", + username="other_user", + ) + cred.token = "other_token" + cred.save() + + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.push_url, + { + "source_image_ref": "myorg/app:latest", + "target_image_ref": "ghcr.io/myorg/app:ci", + "registry_credential": str(cred.id), + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + mock_enqueue_push.assert_not_called() + + def test_list_push_jobs(self): + ImagePushJob.objects.create( + host=self.host, + requested_by=self.admin, + source_image_ref="alpine:latest", + target_image_ref="example.registry/alpine:test", + ) + self.client.force_authenticate(user=self.viewer) + response = self.client.get(self.push_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + @patch("images.views.enqueue_delete") + def test_create_delete_job_unused_as_admin(self, mock_enqueue_delete): + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.delete_url, + {"delete_mode": "UNUSED", "force": False}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["status"], "PENDING") + self.assertEqual(response.data["delete_mode"], "UNUSED") + mock_enqueue_delete.assert_called_once() + self.assertEqual(ImageDeleteJob.objects.filter(host=self.host).count(), 1) + + @patch("images.views.enqueue_delete") + def test_create_delete_job_specific_requires_refs(self, mock_enqueue_delete): + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.delete_url, + {"delete_mode": "SPECIFIC", "force": True}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + mock_enqueue_delete.assert_not_called() + + @patch("images.views.enqueue_delete") + def test_create_delete_job_specific_as_admin(self, mock_enqueue_delete): + self.client.force_authenticate(user=self.admin) + response = self.client.post( + self.delete_url, + { + "delete_mode": "SPECIFIC", + "image_refs": "old:a,old:b", + "force": True, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["delete_mode"], "SPECIFIC") + self.assertEqual(response.data["image_refs"], "old:a,old:b") + mock_enqueue_delete.assert_called_once() + + def test_list_delete_jobs(self): + ImageDeleteJob.objects.create( + host=self.host, + requested_by=self.admin, + delete_mode=ImageDeleteJob.DeleteMode.UNUSED, + image_refs="", + ) + self.client.force_authenticate(user=self.viewer) + response = self.client.get(self.delete_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + # --------------------------------------------------------------------------- # # Serializer unit tests # --------------------------------------------------------------------------- # diff --git a/backend/images/urls.py b/backend/images/urls.py index bc71942..40866ef 100644 --- a/backend/images/urls.py +++ b/backend/images/urls.py @@ -3,13 +3,32 @@ from .views import ( ImagePullJobDetailCancelView, ImagePullJobListCreateView, + ImagePushJobDetailCancelView, + ImagePushJobListCreateView, + ImageDeleteJobDetailCancelView, + ImageDeleteJobListCreateView, ) urlpatterns = [ - path("", ImagePullJobListCreateView.as_view(), name="image-pull-list-create"), + # Pull endpoints + path("pull/", ImagePullJobListCreateView.as_view(), name="image-pull-list-create"), path( - "/", + "pull//", ImagePullJobDetailCancelView.as_view(), name="image-pull-detail", ), + # Push endpoints + path("push/", ImagePushJobListCreateView.as_view(), name="image-push-list-create"), + path( + "push//", + ImagePushJobDetailCancelView.as_view(), + name="image-push-detail", + ), + # Delete endpoints + path("delete/", ImageDeleteJobListCreateView.as_view(), name="image-delete-list-create"), + path( + "delete//", + ImageDeleteJobDetailCancelView.as_view(), + name="image-delete-detail", + ), ] diff --git a/backend/images/views.py b/backend/images/views.py index 97b0c48..89a76ef 100644 --- a/backend/images/views.py +++ b/backend/images/views.py @@ -1,6 +1,12 @@ import logging +import json +import tarfile +import zipfile +from io import BytesIO +from pathlib import PurePosixPath import docker +from django.http import StreamingHttpResponse from django.shortcuts import get_object_or_404 from rest_framework import generics, status from rest_framework.permissions import IsAuthenticated @@ -9,18 +15,42 @@ from hosts.models import Host -from .models import ImagePullJob +from .models import ImagePullJob, ImagePushJob, ImageDeleteJob from .permissions import IsAdminOrHostOwner from .serializers import ( + ImageBuildRequestSerializer, ImageInspectSerializer, ImagePullJobCreateSerializer, ImagePullJobSerializer, + ImagePushJobCreateSerializer, + ImagePushJobSerializer, + ImageDeleteJobCreateSerializer, + ImageDeleteJobSerializer, ) -from .worker import enqueue_pull +from .worker import enqueue_pull, enqueue_push, enqueue_delete logger = logging.getLogger(__name__) +def _is_local_hostname(hostname: str) -> bool: + value = (hostname or "").strip().lower() + return value in {"localhost", "127.0.0.1", "::1"} + + +def _get_docker_client_for_host(host: Host, timeout: int): + # Prefer local socket for localhost hosts (OrbStack / Docker Desktop on macOS) + if _is_local_hostname(host.hostname): + try: + return docker.from_env(timeout=timeout) + except docker.errors.DockerException: + pass + + return docker.DockerClient( + base_url=f"tcp://{host.hostname}:{host.port}", + timeout=timeout, + ) + + class ImagePullJobListCreateView(generics.ListCreateAPIView): """ GET /api/hosts/{host_id}/images/pull/ → list all pull jobs for this host @@ -130,6 +160,138 @@ def destroy(self, request, *args, **kwargs): ) +class ImageBuildStreamView(APIView): + """ + POST /api/hosts/{host_id}/images/build/ + + Accepts either: + - dockerfile: raw Dockerfile string + - context_zip: uploaded ZIP file with build context + + Streams Docker build output back to the client as NDJSON. + """ + + permission_classes = [IsAuthenticated] + + @staticmethod + def _safe_zip_member(name: str) -> bool: + path = PurePosixPath(name) + if path.is_absolute(): + return False + if any(part in {"..", ""} for part in path.parts): + return False + return True + + def _build_context_tar( + self, + dockerfile_text: str, + context_zip, + ) -> BytesIO: + tar_buffer = BytesIO() + + with tarfile.open(fileobj=tar_buffer, mode="w") as tar: + if context_zip: + context_zip.seek(0) + with zipfile.ZipFile(context_zip) as archive: + for member in archive.infolist(): + if member.is_dir(): + continue + if not self._safe_zip_member(member.filename): + continue + + content = archive.read(member.filename) + tar_info = tarfile.TarInfo(name=member.filename) + tar_info.size = len(content) + tar.addfile(tar_info, BytesIO(content)) + + if dockerfile_text: + dockerfile_bytes = dockerfile_text.encode("utf-8") + dockerfile_info = tarfile.TarInfo(name="Dockerfile") + dockerfile_info.size = len(dockerfile_bytes) + tar.addfile(dockerfile_info, BytesIO(dockerfile_bytes)) + + tar_buffer.seek(0) + return tar_buffer + + def post(self, request, host_id): + host = get_object_or_404(Host, pk=host_id) + + if request.user.role != "admin" and host.owner != request.user: + return Response( + {"detail": "You do not have permission to build images on this host."}, + status=status.HTTP_403_FORBIDDEN, + ) + + serializer = ImageBuildRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + dockerfile_text = (data.get("dockerfile") or "").strip() + context_zip = data.get("context_zip") + tag = (data.get("tag") or "").strip() or None + pull = data.get("pull", False) + nocache = data.get("nocache", False) + + try: + context_tar = self._build_context_tar( + dockerfile_text=dockerfile_text, + context_zip=context_zip, + ) + except zipfile.BadZipFile: + return Response( + {"detail": "context_zip must be a valid ZIP archive."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + client = _get_docker_client_for_host(host=host, timeout=600) + except docker.errors.DockerException as exc: + logger.error( + "Cannot connect to Docker daemon on host %s: %s", + host.name, + exc, + ) + return Response( + {"detail": f"Cannot connect to Docker daemon: {exc}"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + def stream_output(): + try: + image, logs = client.images.build( + fileobj=context_tar, + custom_context=True, + rm=True, + pull=pull, + nocache=nocache, + tag=tag, + ) + for chunk in logs: + yield f"{json.dumps(chunk)}\n" + + yield json.dumps({"status": "done", "image_id": image.id}) + "\n" + except docker.errors.BuildError as exc: + logger.warning("Image build failed on host %s: %s", host.name, exc) + yield json.dumps({"error": "build_failed", "detail": str(exc)}) + "\n" + except docker.errors.APIError as exc: + logger.warning("Docker API build error on host %s: %s", host.name, exc) + yield ( + json.dumps({"error": "docker_api_error", "detail": str(exc)}) + + "\n" + ) + except Exception as exc: + logger.exception("Unexpected image build error on host %s", host.name) + yield ( + json.dumps({"error": "unexpected_error", "detail": str(exc)}) + + "\n" + ) + + return StreamingHttpResponse( + streaming_content=stream_output(), + content_type="application/x-ndjson", + ) + + # --------------------------------------------------------------------------- # # Image Inspect view # --------------------------------------------------------------------------- # @@ -163,9 +325,8 @@ def get(self, request, host_id): ) # Connect to the Docker daemon on the target host - base_url = f"tcp://{host.hostname}:{host.port}" try: - client = docker.DockerClient(base_url=base_url, timeout=30) + client = _get_docker_client_for_host(host=host, timeout=30) except docker.errors.DockerException as exc: logger.error( "Cannot connect to Docker daemon on host %s: %s", @@ -238,3 +399,229 @@ def get(self, request, host_id): serializer = ImageInspectSerializer(inspect_data) return Response(serializer.data, status=status.HTTP_200_OK) + + +# --------------------------------------------------------------------------- # +# Image Push/Tag views +# --------------------------------------------------------------------------- # + + +class ImagePushJobListCreateView(generics.ListCreateAPIView): + """ + GET /api/hosts/{host_id}/images/push/ → list all push jobs for this host + POST /api/hosts/{host_id}/images/push/ → enqueue a background image tag + push + """ + + permission_classes = [IsAuthenticated, IsAdminOrHostOwner] + + def get_host(self): + return get_object_or_404(Host, pk=self.kwargs["host_id"]) + + def get_serializer_class(self): + if self.request.method == "POST": + return ImagePushJobCreateSerializer + return ImagePushJobSerializer + + def get_queryset(self): + return ( + ImagePushJob.objects.filter(host_id=self.kwargs["host_id"]) + .select_related("host", "requested_by", "registry_credential") + ) + + def create(self, request, *args, **kwargs): + host = self.get_host() + + # Check object-level permission (is user admin or host owner?) + if request.user.role != "admin" and host.owner != request.user: + return Response( + {"detail": "You do not have permission to push images on this host."}, + status=status.HTTP_403_FORBIDDEN, + ) + + serializer = ImagePushJobCreateSerializer( + data=request.data, context={"request": request} + ) + serializer.is_valid(raise_exception=True) + + validated = serializer.validated_data + registry_cred = validated.get("registry_credential") + + job = ImagePushJob.objects.create( + host=host, + requested_by=request.user, + source_image_ref=validated["source_image_ref"], + target_image_ref=validated["target_image_ref"], + registry_credential=registry_cred, + ) + + logger.info( + "Image push job created id=%s source=%s target=%s host=%s user=%s", + job.id, + job.source_image_ref, + job.target_image_ref, + host.name, + request.user.username, + ) + + # Fire the background worker + enqueue_push(job.id) + + output_serializer = ImagePushJobSerializer(job) + return Response(output_serializer.data, status=status.HTTP_201_CREATED) + + +class ImagePushJobDetailCancelView(generics.RetrieveDestroyAPIView): + """ + GET /api/hosts/{host_id}/images/push/{job_id}/ → retrieve job status & progress + DELETE /api/hosts/{host_id}/images/push/{job_id}/ → cancel a PENDING job (admin only) + """ + + serializer_class = ImagePushJobSerializer + permission_classes = [IsAuthenticated] + lookup_field = "pk" + lookup_url_kwarg = "job_id" + + def get_queryset(self): + return ( + ImagePushJob.objects.filter(host_id=self.kwargs["host_id"]) + .select_related("host", "requested_by", "registry_credential") + ) + + def destroy(self, request, *args, **kwargs): + job = self.get_object() + + # Only admins can cancel + if request.user.role != "admin": + return Response( + {"detail": "Only admins can cancel push jobs."}, + status=status.HTTP_403_FORBIDDEN, + ) + + if job.status != ImagePushJob.Status.PENDING: + return Response( + {"detail": f"Cannot cancel a job with status '{job.status}'."}, + status=status.HTTP_409_CONFLICT, + ) + + job.status = ImagePushJob.Status.CANCELLED + job.save(update_fields=["status"]) + logger.info( + "Push job cancelled id=%s cancelled_by=%s", + job.id, + request.user.username, + ) + + return Response( + {"detail": "Push job cancelled.", "id": str(job.id)}, + status=status.HTTP_200_OK, + ) + + +# --------------------------------------------------------------------------- # +# Image Delete/Prune views +# --------------------------------------------------------------------------- # + + +class ImageDeleteJobListCreateView(generics.ListCreateAPIView): + """ + GET /api/hosts/{host_id}/images/delete/ → list all delete jobs for this host + POST /api/hosts/{host_id}/images/delete/ → enqueue a background image delete + """ + + permission_classes = [IsAuthenticated, IsAdminOrHostOwner] + + def get_host(self): + return get_object_or_404(Host, pk=self.kwargs["host_id"]) + + def get_serializer_class(self): + if self.request.method == "POST": + return ImageDeleteJobCreateSerializer + return ImageDeleteJobSerializer + + def get_queryset(self): + return ImageDeleteJob.objects.filter(host_id=self.kwargs["host_id"]).select_related( + "host", "requested_by" + ) + + def create(self, request, *args, **kwargs): + host = self.get_host() + + # Check object-level permission (is user admin or host owner?) + if request.user.role != "admin" and host.owner != request.user: + return Response( + {"detail": "You do not have permission to delete images on this host."}, + status=status.HTTP_403_FORBIDDEN, + ) + + serializer = ImageDeleteJobCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + validated = serializer.validated_data + + job = ImageDeleteJob.objects.create( + host=host, + requested_by=request.user, + delete_mode=validated["delete_mode"], + image_refs=validated.get("image_refs", ""), + force=validated.get("force", False), + ) + + logger.info( + "Image delete job created id=%s mode=%s host=%s user=%s", + job.id, + job.delete_mode, + host.name, + request.user.username, + ) + + # Fire the background worker + enqueue_delete(job.id) + + output_serializer = ImageDeleteJobSerializer(job) + return Response(output_serializer.data, status=status.HTTP_201_CREATED) + + +class ImageDeleteJobDetailCancelView(generics.RetrieveDestroyAPIView): + """ + GET /api/hosts/{host_id}/images/delete/{job_id}/ → retrieve job status + DELETE /api/hosts/{host_id}/images/delete/{job_id}/ → cancel a PENDING job (admin only) + """ + + serializer_class = ImageDeleteJobSerializer + permission_classes = [IsAuthenticated] + lookup_field = "pk" + lookup_url_kwarg = "job_id" + + def get_queryset(self): + return ImageDeleteJob.objects.filter(host_id=self.kwargs["host_id"]).select_related( + "host", "requested_by" + ) + + def destroy(self, request, *args, **kwargs): + job = self.get_object() + + # Only admins can cancel + if request.user.role != "admin": + return Response( + {"detail": "Only admins can cancel delete jobs."}, + status=status.HTTP_403_FORBIDDEN, + ) + + if job.status != ImageDeleteJob.Status.PENDING: + return Response( + {"detail": f"Cannot cancel a job with status '{job.status}'."}, + status=status.HTTP_409_CONFLICT, + ) + + job.status = ImageDeleteJob.Status.CANCELLED + job.save(update_fields=["status"]) + logger.info( + "Delete job cancelled id=%s cancelled_by=%s", + job.id, + request.user.username, + ) + + return Response( + {"detail": "Delete job cancelled.", "id": str(job.id)}, + status=status.HTTP_200_OK, + ) diff --git a/backend/images/worker.py b/backend/images/worker.py index 7e9ca55..cfcc7ae 100644 --- a/backend/images/worker.py +++ b/backend/images/worker.py @@ -16,6 +16,11 @@ logger = logging.getLogger(__name__) +def _is_local_hostname(hostname: str) -> bool: + value = (hostname or "").strip().lower() + return value in {"localhost", "127.0.0.1", "::1"} + + def _do_pull(job_id: str) -> None: """Execute the image pull in a background thread.""" # Import here to avoid circular imports & ensure Django is ready @@ -42,8 +47,19 @@ def _do_pull(job_id: str) -> None: try: # Connect to the Docker daemon on the target host host = job.host - base_url = f"tcp://{host.hostname}:{host.port}" - client = docker.DockerClient(base_url=base_url, timeout=300) + if _is_local_hostname(host.hostname): + try: + client = docker.from_env(timeout=300) + except docker.errors.DockerException: + client = docker.DockerClient( + base_url=f"tcp://{host.hostname}:{host.port}", + timeout=300, + ) + else: + client = docker.DockerClient( + base_url=f"tcp://{host.hostname}:{host.port}", + timeout=300, + ) # Build auth_config if a credential is linked auth_config = None @@ -120,3 +136,323 @@ def enqueue_pull(job_id: str) -> None: ) thread.start() logger.info("Enqueued pull job %s in thread %s", job_id, thread.name) + + +# --------------------------------------------------------------------------- # +# Image Push Operations +# --------------------------------------------------------------------------- # + + +def _do_push(job_id: str) -> None: + """Execute the image tag and push in a background thread.""" + from .models import ImagePushJob + + try: + job = ImagePushJob.objects.select_related( + "host", "registry_credential" + ).get(pk=job_id) + except ImagePushJob.DoesNotExist: + logger.error("Push job %s not found, aborting.", job_id) + return + + # If the job was cancelled before the thread started, bail out + if job.status == ImagePushJob.Status.CANCELLED: + logger.info("Push job %s was cancelled before starting.", job_id) + return + + # Mark as TAGGING + job.status = ImagePushJob.Status.TAGGING + job.started_at = timezone.now() + job.save(update_fields=["status", "started_at"]) + + try: + # Connect to the Docker daemon on the target host + host = job.host + if _is_local_hostname(host.hostname): + try: + client = docker.from_env(timeout=300) + except docker.errors.DockerException: + client = docker.DockerClient( + base_url=f"tcp://{host.hostname}:{host.port}", + timeout=300, + ) + else: + client = docker.DockerClient( + base_url=f"tcp://{host.hostname}:{host.port}", + timeout=300, + ) + + # Step 1: Tag the image + try: + image = client.images.get(job.source_image_ref) + image.tag(job.target_image_ref) + progress = { + "status": "tagged", + "source": job.source_image_ref, + "target": job.target_image_ref, + } + job.progress_log = json.dumps(progress) + job.status = ImagePushJob.Status.PUSHING + job.save(update_fields=["progress_log", "status"]) + logger.info( + "Tagged image %s as %s on host %s", + job.source_image_ref, + job.target_image_ref, + host.name, + ) + except docker.errors.ImageNotFound: + job.status = ImagePushJob.Status.FAILED + job.error_message = f"Source image '{job.source_image_ref}' not found on host" + job.completed_at = timezone.now() + job.save(update_fields=["status", "error_message", "completed_at"]) + logger.warning( + "Push job %s failed: image not found %s", job.id, job.source_image_ref + ) + return + + # Step 2: Push the tagged image + auth_config = None + if job.registry_credential: + cred = job.registry_credential + auth_config = { + "username": cred.username, + "password": cred.token, # decrypted via property + } + + # Stream the push and capture progress + progress_lines = [] + push_kwargs = { + "repository": job.target_image_ref, + "stream": True, + "decode": True, + } + if auth_config: + push_kwargs["auth_config"] = auth_config + + for chunk in client.api.push(**push_kwargs): + line = json.dumps(chunk) + progress_lines.append(line) + + # Periodically flush progress to DB (every 20 lines) + if len(progress_lines) % 20 == 0: + job.progress_log = "\n".join(progress_lines) + job.save(update_fields=["progress_log"]) + + # Final progress flush + job.progress_log = "\n".join(progress_lines) + job.status = ImagePushJob.Status.SUCCESS + job.completed_at = timezone.now() + job.save(update_fields=["progress_log", "status", "completed_at"]) + + logger.info( + "Push job %s completed successfully source=%s target=%s host=%s", + job.id, + job.source_image_ref, + job.target_image_ref, + host.name, + ) + + except docker.errors.APIError as exc: + explanation = getattr(exc, "explanation", None) or str(exc) + job.status = ImagePushJob.Status.FAILED + job.error_message = explanation + job.completed_at = timezone.now() + job.save(update_fields=["status", "error_message", "completed_at"]) + logger.warning("Push job %s failed: %s", job.id, explanation) + + except Exception as exc: + job.status = ImagePushJob.Status.FAILED + job.error_message = str(exc) + job.completed_at = timezone.now() + job.save(update_fields=["status", "error_message", "completed_at"]) + logger.exception("Push job %s unexpected error", job.id) + + +def enqueue_push(job_id: str) -> None: + """ + Spawn a daemon thread to tag and push the image. + The thread will exit automatically when the push completes or fails. + """ + thread = threading.Thread( + target=_do_push, + args=(str(job_id),), + name=f"push-{job_id}", + daemon=True, + ) + thread.start() + logger.info("Enqueued push job %s in thread %s", job_id, thread.name) + + +# --------------------------------------------------------------------------- # +# Image Delete/Prune Operations +# --------------------------------------------------------------------------- # + + +def _do_delete(job_id: str) -> None: + """Execute the image deletion/pruning in a background thread.""" + from .models import ImageDeleteJob + + try: + job = ImageDeleteJob.objects.select_related("host").get(pk=job_id) + except ImageDeleteJob.DoesNotExist: + logger.error("Delete job %s not found, aborting.", job_id) + return + + # If the job was cancelled before the thread started, bail out + if job.status == ImageDeleteJob.Status.CANCELLED: + logger.info("Delete job %s was cancelled before starting.", job_id) + return + + # Mark as DELETING + job.status = ImageDeleteJob.Status.DELETING + job.started_at = timezone.now() + job.save(update_fields=["status", "started_at"]) + + try: + # Connect to the Docker daemon on the target host + host = job.host + if _is_local_hostname(host.hostname): + try: + client = docker.from_env(timeout=300) + except docker.errors.DockerException: + client = docker.DockerClient( + base_url=f"tcp://{host.hostname}:{host.port}", + timeout=300, + ) + else: + client = docker.DockerClient( + base_url=f"tcp://{host.hostname}:{host.port}", + timeout=300, + ) + + deleted_count = 0 + space_freed = 0 + progress_log = [] + + if job.delete_mode == ImageDeleteJob.DeleteMode.UNUSED: + # Prune all unused images + try: + result = client.images.prune(filters={"dangling": False}) + deleted_count = len(result.get("ImagesDeleted") or []) + space_freed = result.get("SpaceReclaimed", 0) + + progress = { + "status": "pruned", + "mode": "unused", + "deleted": deleted_count, + "space_freed": space_freed, + } + progress_log.append(json.dumps(progress)) + logger.info( + "Pruned %d unused images on host %s, freed %d bytes", + deleted_count, + host.name, + space_freed, + ) + except docker.errors.APIError as exc: + raise Exception(f"Prune failed: {exc}") + + else: # SPECIFIC mode + # Delete specific images + image_refs = [ + ref.strip() for ref in job.image_refs.split(",") if ref.strip() + ] + + for image_ref in image_refs: + try: + # Get image info before deletion (to get size) + try: + image = client.images.get(image_ref) + image_size = image.attrs.get("Size", 0) + except docker.errors.ImageNotFound: + logger.warning( + "Image %s not found for deletion on host %s", + image_ref, + host.name, + ) + progress = { + "status": "warning", + "image": image_ref, + "reason": "not_found", + } + progress_log.append(json.dumps(progress)) + continue + + # Delete the image + client.images.remove(image_ref, force=job.force) + deleted_count += 1 + space_freed += image_size + + progress = { + "status": "deleted", + "image": image_ref, + "size": image_size, + } + progress_log.append(json.dumps(progress)) + logger.info( + "Deleted image %s on host %s (size: %d bytes)", + image_ref, + host.name, + image_size, + ) + + except docker.errors.APIError as exc: + explanation = getattr(exc, "explanation", None) or str(exc) + logger.warning( + "Delete job %s failed to delete %s: %s", + job.id, + image_ref, + explanation, + ) + progress = { + "status": "error", + "image": image_ref, + "error": explanation, + } + progress_log.append(json.dumps(progress)) + + # Final update + job.progress_log = "\n".join(progress_log) + job.deleted_count = deleted_count + job.space_freed_bytes = space_freed + job.status = ImageDeleteJob.Status.SUCCESS + job.completed_at = timezone.now() + job.save( + update_fields=[ + "progress_log", + "deleted_count", + "space_freed_bytes", + "status", + "completed_at", + ] + ) + + logger.info( + "Delete job %s completed: deleted %d images, freed %d bytes on host %s", + job.id, + deleted_count, + space_freed, + host.name, + ) + + except Exception as exc: + job.status = ImageDeleteJob.Status.FAILED + job.error_message = str(exc) + job.completed_at = timezone.now() + job.save(update_fields=["status", "error_message", "completed_at"]) + logger.exception("Delete job %s unexpected error", job.id) + + +def enqueue_delete(job_id: str) -> None: + """ + Spawn a daemon thread to delete images. + The thread will exit automatically when deletion completes or fails. + """ + thread = threading.Thread( + target=_do_delete, + args=(str(job_id),), + name=f"delete-{job_id}", + daemon=True, + ) + thread.start() + logger.info("Enqueued delete job %s in thread %s", job_id, thread.name) diff --git a/backend/networks/__init__.py b/backend/networks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/networks/apps.py b/backend/networks/apps.py new file mode 100644 index 0000000..9c0e2b2 --- /dev/null +++ b/backend/networks/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NetworksConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "networks" diff --git a/backend/networks/migrations/__init__.py b/backend/networks/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/networks/urls.py b/backend/networks/urls.py new file mode 100644 index 0000000..690b131 --- /dev/null +++ b/backend/networks/urls.py @@ -0,0 +1,4 @@ +from django.urls import path + +urlpatterns = [ +] diff --git a/backend/users/__init__.py b/backend/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/users/apps.py b/backend/users/apps.py new file mode 100644 index 0000000..88f7b17 --- /dev/null +++ b/backend/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "users" diff --git a/backend/users/migrations/__init__.py b/backend/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/users/urls.py b/backend/users/urls.py new file mode 100644 index 0000000..690b131 --- /dev/null +++ b/backend/users/urls.py @@ -0,0 +1,4 @@ +from django.urls import path + +urlpatterns = [ +] diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..9c80a40 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": false, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json new file mode 100644 index 0000000..abe04df --- /dev/null +++ b/frontend/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ae353a8..9826b16 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,10 +8,18 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@base-ui/react": "^1.3.0", + "@fontsource-variable/geist": "^5.2.8", "@tailwindcss/vite": "^4.2.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.7.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "tailwindcss": "^4.2.1" + "shadcn": "^4.1.0", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.1", + "tw-animate-css": "^1.4.0" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -29,7 +37,6 @@ "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", "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", @@ -44,7 +51,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -54,7 +60,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.0", @@ -85,7 +90,6 @@ "version": "7.29.1", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -98,11 +102,22 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-compilation-targets": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.28.6", @@ -115,21 +130,53 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.28.6", @@ -143,7 +190,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.28.6", @@ -157,21 +203,61 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-plugin-utils": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -181,7 +267,6 @@ "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", "engines": { "node": ">=6.9.0" @@ -191,31 +276,28 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", - "dev": true, + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "dev": true, + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -227,6 +309,52 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-react-jsx-self": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", @@ -259,11 +387,57 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", + "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", + "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-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==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.28.6", @@ -278,7 +452,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.0", @@ -297,7 +470,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -307,125 +479,346 @@ "node": ">=6.9.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], + "node_modules/@base-ui/react": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.3.0.tgz", + "integrity": "sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA==", "license": "MIT", - "optional": true, - "os": [ - "aix" - ], + "dependencies": { + "@babel/runtime": "^7.28.6", + "@base-ui/utils": "0.2.6", + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "tabbable": "^6.4.0", + "use-sync-external-store": "^1.6.0" + }, "engines": { - "node": ">=18" + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], + "node_modules/@base-ui/utils": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.2.6.tgz", + "integrity": "sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw==", "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@babel/runtime": "^7.28.6", + "@floating-ui/utils": "^0.2.11", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], + "node_modules/@dotenvx/dotenvx": { + "version": "1.57.2", + "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.57.2.tgz", + "integrity": "sha512-lv9+UZPnl/KOvShepevLWm3+/wc1It5kgO5Q580evnvOFMZcgKVEYFwxlL7Ohl9my1yjTsWo28N3PJYUEO8wFQ==", + "license": "BSD-3-Clause", + "dependencies": { + "commander": "^11.1.0", + "dotenv": "^17.2.1", + "eciesjs": "^0.4.10", + "execa": "^5.1.1", + "fdir": "^6.2.0", + "ignore": "^5.3.0", + "object-treeify": "1.1.33", + "picomatch": "^4.0.2", + "which": "^4.0.0" + }, + "bin": { + "dotenvx": "src/cli/dotenvx.js" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": ">=16" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], + "node_modules/@dotenvx/dotenvx/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, "engines": { - "node": ">=18" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], + "node_modules/@dotenvx/dotenvx/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">=18" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@dotenvx/dotenvx/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=10.17.0" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], + "node_modules/@dotenvx/dotenvx/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=18" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], + "node_modules/@dotenvx/dotenvx/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@dotenvx/dotenvx/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/@ecies/ciphers": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.5.tgz", + "integrity": "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==", + "license": "MIT", + "engines": { + "bun": ">=1", + "deno": ">=2", + "node": ">=16" + }, + "peerDependencies": { + "@noble/ciphers": "^1.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], "license": "MIT", "optional": true, "os": [ @@ -436,9 +829,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", "cpu": [ "arm" ], @@ -452,9 +845,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", "cpu": [ "arm64" ], @@ -468,9 +861,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", "cpu": [ "ia32" ], @@ -484,9 +877,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", "cpu": [ "loong64" ], @@ -500,9 +893,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", "cpu": [ "mips64el" ], @@ -516,9 +909,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", "cpu": [ "ppc64" ], @@ -532,9 +925,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", "cpu": [ "riscv64" ], @@ -548,9 +941,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", "cpu": [ "s390x" ], @@ -564,9 +957,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", "cpu": [ "x64" ], @@ -580,9 +973,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", "cpu": [ "arm64" ], @@ -596,9 +989,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", "cpu": [ "x64" ], @@ -612,9 +1005,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", "cpu": [ "arm64" ], @@ -628,9 +1021,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", "cpu": [ "x64" ], @@ -644,9 +1037,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", "cpu": [ "arm64" ], @@ -660,9 +1053,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", "cpu": [ "x64" ], @@ -676,9 +1069,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", "cpu": [ "arm64" ], @@ -692,9 +1085,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", "cpu": [ "ia32" ], @@ -708,9 +1101,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", "cpu": [ "x64" ], @@ -766,15 +1159,15 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -807,9 +1200,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz", - "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { @@ -820,7 +1213,7 @@ "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", - "minimatch": "^3.1.3", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -844,9 +1237,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", - "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { @@ -880,6 +1273,65 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@fontsource-variable/geist": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.8.tgz", + "integrity": "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -932,6 +1384,89 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "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", @@ -977,43 +1512,218 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.3", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", - "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz", + "integrity": "sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==", "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", - "cpu": [ - "arm64" - ], + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", + "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", "cpu": [ "arm64" ], @@ -1024,9 +1734,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", "cpu": [ "x64" ], @@ -1037,9 +1747,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", "cpu": [ "arm64" ], @@ -1050,9 +1760,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", "cpu": [ "x64" ], @@ -1063,12 +1773,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1076,12 +1789,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", "cpu": [ "arm" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1089,12 +1805,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1102,12 +1821,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1115,12 +1837,15 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", "cpu": [ "loong64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1128,12 +1853,15 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", "cpu": [ "loong64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1141,12 +1869,15 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1154,12 +1885,15 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", "cpu": [ "ppc64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1167,12 +1901,15 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1180,12 +1917,15 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", "cpu": [ "riscv64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1193,12 +1933,15 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1206,12 +1949,15 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1219,12 +1965,15 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1232,9 +1981,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", "cpu": [ "x64" ], @@ -1245,9 +1994,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", "cpu": [ "arm64" ], @@ -1258,9 +2007,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", "cpu": [ "arm64" ], @@ -1271,9 +2020,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", "cpu": [ "ia32" ], @@ -1284,9 +2033,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", "cpu": [ "x64" ], @@ -1297,9 +2046,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", "cpu": [ "x64" ], @@ -1309,48 +2058,66 @@ "win32" ] }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@tailwindcss/node": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", - "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", - "lightningcss": "1.31.1", + "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.1" + "tailwindcss": "4.2.2" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", - "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-x64": "4.2.1", - "@tailwindcss/oxide-freebsd-x64": "4.2.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-x64-musl": "4.2.1", - "@tailwindcss/oxide-wasm32-wasi": "4.2.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", - "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", "cpu": [ "arm64" ], @@ -1364,9 +2131,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", - "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", "cpu": [ "arm64" ], @@ -1380,9 +2147,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", - "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", "cpu": [ "x64" ], @@ -1396,9 +2163,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", - "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", "cpu": [ "x64" ], @@ -1412,9 +2179,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", - "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", "cpu": [ "arm" ], @@ -1428,12 +2195,15 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", - "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1444,12 +2214,15 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", - "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1460,12 +2233,15 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", - "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1476,12 +2252,15 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", - "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1492,9 +2271,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", - "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1521,9 +2300,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", - "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", "cpu": [ "arm64" ], @@ -1537,9 +2316,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", - "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", "cpu": [ "x64" ], @@ -1553,17 +2332,64 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", - "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.2.1", - "@tailwindcss/oxide": "4.2.1", - "tailwindcss": "4.2.1" + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" }, "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7" + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@ts-morph/common": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", + "integrity": "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==", + "license": "MIT", + "dependencies": { + "fast-glob": "^3.3.3", + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@ts-morph/common/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@ts-morph/common/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@types/babel__core": { @@ -1628,7 +2454,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1644,10 +2470,22 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "license": "MIT" + }, + "node_modules/@types/validate-npm-package-name": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", + "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", - "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", "dev": true, "license": "MIT", "dependencies": { @@ -1662,7 +2500,20 @@ "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" } }, "node_modules/acorn": { @@ -1688,6 +2539,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -1705,11 +2565,61 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -1725,9 +2635,20 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1736,10 +2657,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", - "dev": true, + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", + "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -1748,6 +2668,30 @@ "node": ">=6.0.0" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -1759,11 +2703,22 @@ "concat-map": "0.0.1" } }, + "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==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, "funding": [ { "type": "opencollective", @@ -1793,37 +2748,88 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001776", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz", - "integrity": "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==", - "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/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "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/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1841,11 +2847,145 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "license": "MIT" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1858,9 +2998,17 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1868,18 +3016,99 @@ "dev": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.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/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1890,18 +3119,38 @@ "node": ">= 8" } }, + "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==", + "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, + "devOptional": true, "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1915,6 +3164,20 @@ } } }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1922,120 +3185,303 @@ "dev": true, "license": "MIT" }, - "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==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.307", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", - "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", - "dev": true, - "license": "ISC" - }, - "node_modules/enhanced-resolve": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", - "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" - }, "engines": { - "node": ">=10.13.0" + "node": ">=0.10.0" } }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "hasInstallScript": true, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" }, "engines": { "node": ">=18" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", "license": "MIT", "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", - "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", - "dev": true, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.3", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eciesjs": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.18.tgz", + "integrity": "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==", + "license": "MIT", + "dependencies": { + "@ecies/ciphers": "^0.2.5", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0" + }, + "engines": { + "bun": ">=1", + "deno": ">=2", + "node": ">=16" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.325", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", + "integrity": "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", @@ -2053,7 +3499,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -2153,6 +3599,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -2199,13 +3658,157 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, + "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==", + "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==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2220,6 +3823,31 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2237,6 +3865,44 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2250,6 +3916,39 @@ "node": ">=16.0.0" } }, + "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==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2282,14 +3981,58 @@ } }, "node_modules/flatted": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", - "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, - "node_modules/fsevents": { - "version": "2.3.3", + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "hasInstallScript": true, @@ -2302,16 +4045,116 @@ "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==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fuzzysort": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz", + "integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==", + "license": "MIT" + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-own-enumerable-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-own-enumerable-keys/-/get-own-enumerable-keys-1.0.0.tgz", + "integrity": "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2338,12 +4181,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2354,6 +4218,36 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "license": "MIT" + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -2371,11 +4265,77 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hono": { + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -2385,7 +4345,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -2408,21 +4367,73 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "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-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "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" @@ -2431,98 +4442,273 @@ "node": ">=0.10.0" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", "license": "MIT", "dependencies": { - "argparse": "^2.0.1" + "is-docker": "^3.0.0" }, "bin": { - "js-yaml": "bin/js-yaml.js" + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, "engines": { - "node": ">=6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", "license": "MIT" }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, + "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==", "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, "engines": { - "node": ">=6" + "node": ">=0.12.0" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, + "node_modules/is-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-3.0.0.tgz", + "integrity": "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==", "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-regexp": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz", + "integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } }, "node_modules/levn": { "version": "0.4.1", @@ -2539,9 +4725,9 @@ } }, "node_modules/lightningcss": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", - "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -2554,23 +4740,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.31.1", - "lightningcss-darwin-arm64": "1.31.1", - "lightningcss-darwin-x64": "1.31.1", - "lightningcss-freebsd-x64": "1.31.1", - "lightningcss-linux-arm-gnueabihf": "1.31.1", - "lightningcss-linux-arm64-gnu": "1.31.1", - "lightningcss-linux-arm64-musl": "1.31.1", - "lightningcss-linux-x64-gnu": "1.31.1", - "lightningcss-linux-x64-musl": "1.31.1", - "lightningcss-win32-arm64-msvc": "1.31.1", - "lightningcss-win32-x64-msvc": "1.31.1" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], @@ -2588,9 +4774,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -2608,9 +4794,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], @@ -2628,9 +4814,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], @@ -2648,9 +4834,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], @@ -2668,12 +4854,15 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2688,12 +4877,15 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2708,12 +4900,15 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2728,12 +4923,15 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", - "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2748,9 +4946,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], @@ -2768,9 +4966,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], @@ -2787,6 +4985,12 @@ "url": "https://opencollective.com/parcel" } }, + "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==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2810,16 +5014,64 @@ "dev": true, "license": "MIT" }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, "license": "ISC", "dependencies": { "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz", + "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2829,59 +5081,409 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", "engines": { - "node": "*" + "node": ">= 0.4" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "license": "MIT" }, - "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", - "dev": true, - "license": "MIT" + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } }, - "node_modules/optionator": { + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/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==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msw": { + "version": "2.12.14", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.14.tgz", + "integrity": "sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.41.2", + "@open-draft/deferred-promise": "^2.2.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.0.2", + "graphql": "^16.12.0", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.10.1", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^5.2.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "license": "MIT" + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-treeify": { + "version": "1.1.33", + "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz", + "integrity": "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", + "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.4.0", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.1.0", + "wsl-utils": "^0.3.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", @@ -2899,6 +5501,47 @@ "node": ">= 0.8.0" } }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "license": "MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -2935,7 +5578,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -2944,6 +5586,51 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2958,12 +5645,17 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2971,63 +5663,206 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { - "node": ">=12" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "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/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "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==", "funding": [ { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" + "type": "github", + "url": "https://github.com/sponsors/feross" }, { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" + "type": "patreon", + "url": "https://www.patreon.com/feross" }, { - "type": "github", - "url": "https://github.com/sponsors/ai" + "type": "consulting", + "url": "https://feross.org/support" } ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } + "license": "MIT" }, - "node_modules/prelude-ls": { + "node_modules/range-parser": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "license": "MIT", "engines": { - "node": ">= 0.8.0" + "node": ">= 0.6" } }, - "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, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, "engines": { - "node": ">=6" + "node": ">= 0.10" } }, "node_modules/react": { @@ -3061,20 +5896,91 @@ "node": ">=0.10.0" } }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "license": "MIT", "engines": { "node": ">=4" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rettime": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", + "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==", + "license": "MIT" + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -3087,34 +5993,101 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "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/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3125,42 +6098,340 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shadcn": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.1.0.tgz", + "integrity": "sha512-3zETJ+0Ezj69FS6RL0HOkLKKAR5yXisXx1iISJdfLQfrUqj/VIQlanQi1Ukk+9OE+XHZVj4FQNTBSfbr2CyCYg==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/plugin-transform-typescript": "^7.28.0", + "@babel/preset-typescript": "^7.27.1", + "@dotenvx/dotenvx": "^1.48.4", + "@modelcontextprotocol/sdk": "^1.26.0", + "@types/validate-npm-package-name": "^4.0.2", + "browserslist": "^4.26.2", + "commander": "^14.0.0", + "cosmiconfig": "^9.0.0", + "dedent": "^1.6.0", + "deepmerge": "^4.3.1", + "diff": "^8.0.2", + "execa": "^9.6.0", + "fast-glob": "^3.3.3", + "fs-extra": "^11.3.1", + "fuzzysort": "^3.1.0", + "https-proxy-agent": "^7.0.6", + "kleur": "^4.1.5", + "msw": "^2.10.4", + "node-fetch": "^3.3.2", + "open": "^11.0.0", + "ora": "^8.2.0", + "postcss": "^8.5.6", + "postcss-selector-parser": "^7.1.0", + "prompts": "^2.4.2", + "recast": "^0.23.11", + "stringify-object": "^5.0.0", + "tailwind-merge": "^3.0.1", + "ts-morph": "^26.0.0", + "tsconfig-paths": "^4.2.0", + "validate-npm-package-name": "^7.0.1", + "zod": "^3.24.1", + "zod-to-json-schema": "^3.24.6" + }, + "bin": { + "shadcn": "dist/index.js" + } + }, + "node_modules/shadcn/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stringify-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-5.0.0.tgz", + "integrity": "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==", + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-keys": "^1.0.0", + "is-obj": "^3.0.0", + "is-regexp": "^3.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/yeoman/stringify-object?sponsor=1" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "shebang-regex": "^3.0.0" + "ansi-regex": "^6.2.2" }, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/shebang-regex": { + "node_modules/strip-bom": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=4" } }, - "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==", - "license": "BSD-3-Clause", + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/strip-json-comments": { @@ -3189,16 +6460,44 @@ "node": ">=8" } }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", - "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", "license": "MIT", "engines": { "node": ">=6" @@ -3208,6 +6507,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3224,6 +6529,96 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", + "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.27" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", + "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", + "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==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/ts-morph": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz", + "integrity": "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==", + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.27.0", + "code-block-writer": "^13.0.3" + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "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==", + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3237,11 +6632,78 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "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", @@ -3278,6 +6740,39 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.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==", + "license": "MIT" + }, + "node_modules/validate-npm-package-name": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -3352,11 +6847,19 @@ } } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -3378,13 +6881,166 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -3398,16 +7054,48 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, "node_modules/zod-validation-error": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9f93bd3..7fa726c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,10 +10,18 @@ "preview": "vite preview" }, "dependencies": { + "@base-ui/react": "^1.3.0", + "@fontsource-variable/geist": "^5.2.8", "@tailwindcss/vite": "^4.2.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.7.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "tailwindcss": "^4.2.1" + "shadcn": "^4.1.0", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.1", + "tw-animate-css": "^1.4.0" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 78289b1..d01fc7f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,44 +1,102 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { AuthProvider, useAuth } from './context/AuthContext'; import Login from './components/Login'; import Register from './components/Register'; import Dashboard from './components/Dashboard'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Moon, SunMedium } from 'lucide-react'; + +function useThemeMode() { + const getInitialTheme = () => { + const saved = localStorage.getItem('theme'); + if (saved === 'dark' || saved === 'light') return saved; + return window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; + }; + + const [theme, setTheme] = useState(getInitialTheme); + + useEffect(() => { + document.documentElement.classList.toggle('dark', theme === 'dark'); + localStorage.setItem('theme', theme); + }, [theme]); + + return { + theme, + toggleTheme: () => setTheme((prev) => (prev === 'dark' ? 'light' : 'dark')), + }; +} function AppContent() { const { user, loading } = useAuth(); const [showRegister, setShowRegister] = useState(false); + const { theme, toggleTheme } = useThemeMode(); if (loading) { - return
Loading...
; + return ( +
+

Loading session...

+
+ ); } if (!user) { return ( -
-
-

Docker Integration Host

-

Manage containers across multiple hosts

-
- -
- -
- - {showRegister ? : } +
+
+
+ + + +
+ +
+ + {showRegister ? 'Create your account' : 'Sign in'} + + +
+ + {showRegister + ? 'Register to access hosts, registries, and build pipelines.' + : 'Welcome back. Continue to your Docker host workspace.'} + +
+ + {showRegister ? : } + + +
+ +
+
+
+
+

Docker Integration Host

+

+ Secure registry access, image builds, and host-level operations in one place. +

+
+
+
+ +
); } - return ( -
- -
- ); + return ; } export default function App() { diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx index bd69f4f..f9f8fb6 100644 --- a/frontend/src/components/Dashboard.jsx +++ b/frontend/src/components/Dashboard.jsx @@ -1,43 +1,355 @@ import { useAuth } from '../context/AuthContext'; +import { useMemo, useState } from 'react'; +import { imageService } from '../services/imageService'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@/components/ui/separator'; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInset, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, + SidebarTrigger, +} from '@/components/ui/sidebar'; +import { Textarea } from '@/components/ui/textarea'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { + Terminal, + Wrench, + CircleAlert, + LogOut, + SunMedium, + Moon, + User, + ChevronDown, + Search, + Rocket, +} from 'lucide-react'; -export default function Dashboard() { +export default function Dashboard({ theme, toggleTheme }) { const { user, logout } = useAuth(); + const [activeTab, setActiveTab] = useState('build'); + const [hostId, setHostId] = useState('1'); + const [tag, setTag] = useState(''); + const [dockerfile, setDockerfile] = useState('FROM alpine:3.20\nRUN echo "hello from build pipeline"\nCMD ["sh"]'); + const [contextZip, setContextZip] = useState(null); + const [pull, setPull] = useState(false); + const [nocache, setNocache] = useState(false); + const [logs, setLogs] = useState([]); + const [error, setError] = useState(''); + const [isBuilding, setIsBuilding] = useState(false); + + const canBuild = useMemo(() => { + return hostId.trim() && (dockerfile.trim() || contextZip); + }, [hostId, dockerfile, contextZip]); + + const appendLog = (event) => { + setLogs((prev) => [...prev, event]); + }; + + const prettyLine = (entry) => { + if (entry?.stream) return entry.stream.trimEnd(); + if (entry?.error) return `[${entry.error}] ${entry.detail || 'Build failed'}`; + if (entry?.status === 'done') return `Build complete: ${entry.image_id}`; + return JSON.stringify(entry); + }; + + const handleBuild = async (e) => { + e.preventDefault(); + setError(''); + setLogs([]); + setIsBuilding(true); + + try { + await imageService.buildImageStream({ + hostId: hostId.trim(), + tag, + dockerfile, + contextZip, + pull, + nocache, + onEvent: appendLog, + }); + } catch (err) { + setError(err.message || 'Unable to start image build.'); + } finally { + setIsBuilding(false); + } + }; + + const navItems = [ + { id: 'build', label: 'Build Pipeline', icon: Wrench }, + { id: 'profile', label: 'Profile', icon: User }, + ]; + + const initials = (user?.username || 'U').slice(0, 2).toUpperCase(); return ( -
-
-
-

Dashboard

- -
- -
-

User Information

-

Username: {user?.username}

-

Email: {user?.email}

-

Role: {user?.role}

-
- -
-
-

Containers

-

Manage your containers

-
-
-

Networks

-

Configure networks

+ + + +
+
+ +
+
+ Docker Console + Integration Host +
-
-

Hosts

-

Manage remote hosts

+ + + + + + Workspace + + + {navItems.map((item) => { + const Icon = item.icon; + return ( + + setActiveTab(item.id)} + > + + {item.label} + + + ); + })} + + + + + + Session + +
+

Signed in as

+

{user?.username}

+

{user?.email || 'No email'}

+ {user?.role} +
+
+
+
+ + + + + + {initials} + +
+

{user?.username}

+

{theme === 'dark' ? 'Dark theme' : 'Light theme'}

+
+ +
+ + + {theme === 'dark' ? : } + {theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'} + + + + + Logout + + +
+
+ + + +
+ +
+ +
-
-
-
+ Module 3 + + +
+ {activeTab === 'build' && ( + <> + + + + + Automated Build Pipeline + + Build from Dockerfile text or ZIP context and stream logs in real time. + + + +
+
+
+ + setHostId(e.target.value)} + required + /> +
+ +
+ + setTag(e.target.value)} + placeholder="myorg/myapp:latest" + /> +
+
+ +
+ +