diff --git a/.gitignore b/.gitignore index 0b1a881cb..206d66084 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ go.work.sum /local-dev/ *.tmp .envrc +.DS_Store # Ignore preflight bundles generated during local dev preflightbundle* diff --git a/Makefile b/Makefile index cb6ad9dbb..cf1c4713d 100644 --- a/Makefile +++ b/Makefile @@ -337,6 +337,7 @@ list-distros: .PHONY: create-node% create-node%: DISTRO = debian-bookworm create-node%: NODE_PORT = 30000 +create-node%: MANAGER_NODE_PORT = 30080 create-node%: K0S_DATA_DIR = /var/lib/embedded-cluster/k0s create-node%: @docker run -d \ @@ -348,7 +349,9 @@ create-node%: -v $(shell pwd):/replicatedhq/embedded-cluster \ -v $(shell dirname $(shell pwd))/kots:/replicatedhq/kots \ $(if $(filter node0,node$*),-p $(NODE_PORT):$(NODE_PORT)) \ + $(if $(filter node0,node$*),-p $(MANAGER_NODE_PORT):$(MANAGER_NODE_PORT)) \ $(if $(filter node0,node$*),-p 30003:30003) \ + -e EC_PUBLIC_ADDRESS=localhost \ replicated/ec-distro:$(DISTRO) @$(MAKE) ssh-node$* diff --git a/api/api.go b/api/api.go new file mode 100644 index 000000000..77227e088 --- /dev/null +++ b/api/api.go @@ -0,0 +1,119 @@ +package api + +import ( + "errors" + "fmt" + "net/http" + + "github.com/gorilla/mux" + "github.com/sirupsen/logrus" + + "github.com/replicatedhq/embedded-cluster/api/controllers/auth" + "github.com/replicatedhq/embedded-cluster/api/controllers/console" + "github.com/replicatedhq/embedded-cluster/api/controllers/install" + "github.com/replicatedhq/embedded-cluster/api/types" +) + +type API struct { + authController auth.Controller + consoleController console.Controller + installController install.Controller + configChan chan<- *types.InstallationConfig + logger logrus.FieldLogger +} + +type APIOption func(*API) + +func WithAuthController(authController auth.Controller) APIOption { + return func(a *API) { + a.authController = authController + } +} + +func WithConsoleController(consoleController console.Controller) APIOption { + return func(a *API) { + a.consoleController = consoleController + } +} + +func WithInstallController(installController install.Controller) APIOption { + return func(a *API) { + a.installController = installController + } +} + +func WithLogger(logger logrus.FieldLogger) APIOption { + return func(a *API) { + a.logger = logger + } +} + +func WithConfigChan(configChan chan<- *types.InstallationConfig) APIOption { + return func(a *API) { + a.configChan = configChan + } +} + +func New(password string, opts ...APIOption) (*API, error) { + api := &API{} + for _, opt := range opts { + opt(api) + } + + if api.authController == nil { + authController, err := auth.NewAuthController(password) + if err != nil { + return nil, fmt.Errorf("new auth controller: %w", err) + } + api.authController = authController + } + + if api.consoleController == nil { + consoleController, err := console.NewConsoleController() + if err != nil { + return nil, fmt.Errorf("new console controller: %w", err) + } + api.consoleController = consoleController + } + + if api.installController == nil { + installController, err := install.NewInstallController() + if err != nil { + return nil, fmt.Errorf("new install controller: %w", err) + } + api.installController = installController + } + + if api.logger == nil { + api.logger = NewDiscardLogger() + } + + return api, nil +} + +func (a *API) RegisterRoutes(router *mux.Router) { + router.HandleFunc("/health", a.getHealth).Methods("GET") + + router.HandleFunc("/auth/login", a.postAuthLogin).Methods("POST") + router.HandleFunc("/branding", a.getBranding).Methods("GET") + + authenticatedRouter := router.PathPrefix("").Subrouter() + authenticatedRouter.Use(a.authMiddleware) + + installRouter := authenticatedRouter.PathPrefix("/install").Subrouter() + installRouter.HandleFunc("", a.getInstall).Methods("GET") + installRouter.HandleFunc("/config", a.setInstallConfig).Methods("POST") + installRouter.HandleFunc("/status", a.setInstallStatus).Methods("POST") + installRouter.HandleFunc("/status", a.getInstallStatus).Methods("GET") + + consoleRouter := authenticatedRouter.PathPrefix("/console").Subrouter() + consoleRouter.HandleFunc("/available-network-interfaces", a.getListAvailableNetworkInterfaces).Methods("GET") +} + +func handleError(w http.ResponseWriter, err error) { + var apiErr *types.APIError + if !errors.As(err, &apiErr) { + apiErr = types.NewInternalServerError(err) + } + apiErr.JSON(w) +} diff --git a/api/auth.go b/api/auth.go new file mode 100644 index 000000000..a3e9da475 --- /dev/null +++ b/api/auth.go @@ -0,0 +1,82 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/replicatedhq/embedded-cluster/api/controllers/auth" + "github.com/replicatedhq/embedded-cluster/api/types" +) + +type AuthRequest struct { + Password string `json:"password"` +} + +type AuthResponse struct { + Token string `json:"token"` +} + +func (a *API) authMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") + if token == "" { + err := errors.New("authorization header is required") + a.logger.WithFields(logrusFieldsFromRequest(r)).WithError(err). + Error("failed to authenticate") + types.NewUnauthorizedError(err).JSON(w) + return + } + + if !strings.HasPrefix(token, "Bearer ") { + err := errors.New("authorization header must start with Bearer ") + a.logger.WithFields(logrusFieldsFromRequest(r)).WithError(err). + Error("failed to authenticate") + types.NewUnauthorizedError(err).JSON(w) + return + } + + token = token[len("Bearer "):] + + err := a.authController.ValidateToken(r.Context(), token) + if err != nil { + a.logger.WithFields(logrusFieldsFromRequest(r)).WithError(err). + Error("failed to validate token") + types.NewUnauthorizedError(err).JSON(w) + return + } + + next.ServeHTTP(w, r) + }) +} + +func (a *API) postAuthLogin(w http.ResponseWriter, r *http.Request) { + var request AuthRequest + err := json.NewDecoder(r.Body).Decode(&request) + if err != nil { + a.logger.WithFields(logrusFieldsFromRequest(r)).WithError(err). + Error("failed to decode auth request") + types.NewBadRequestError(err).JSON(w) + return + } + + token, err := a.authController.Authenticate(r.Context(), request.Password) + if errors.Is(err, auth.ErrInvalidPassword) { + types.NewUnauthorizedError(err).JSON(w) + return + } + + if err != nil { + a.logger.WithFields(logrusFieldsFromRequest(r)).WithError(err). + Error("failed to authenticate") + types.NewInternalServerError(err).JSON(w) + return + } + + response := AuthResponse{ + Token: token, + } + + json.NewEncoder(w).Encode(response) +} diff --git a/api/client/auth.go b/api/client/auth.go new file mode 100644 index 000000000..f5c1c423c --- /dev/null +++ b/api/client/auth.go @@ -0,0 +1,48 @@ +package client + +import ( + "bytes" + "encoding/json" + "net/http" +) + +// Login sends a login request to the API server with the provided password and retrieves a session token. The token is stored in the client struct for subsequent requests. +func (c *client) Login(password string) error { + loginReq := struct { + Password string `json:"password"` + }{ + Password: password, + } + + b, err := json.Marshal(loginReq) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", c.apiURL+"/api/auth/login", bytes.NewBuffer(b)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return errorFromResponse(resp) + } + + var loginResp struct { + Token string `json:"token"` + } + err = json.NewDecoder(resp.Body).Decode(&loginResp) + if err != nil { + return err + } + + c.token = loginResp.Token + return nil +} diff --git a/api/client/client.go b/api/client/client.go new file mode 100644 index 000000000..77210400a --- /dev/null +++ b/api/client/client.go @@ -0,0 +1,77 @@ +package client + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/replicatedhq/embedded-cluster/api/types" +) + +var defaultHTTPClient = &http.Client{ + Transport: &http.Transport{ + Proxy: nil, // This is a local client so no proxy is needed + }, +} + +type Client interface { + Login(password string) error + GetInstall() (*types.Install, error) + SetInstallConfig(config types.InstallationConfig) (*types.Install, error) + SetInstallStatus(status types.InstallationStatus) (*types.Install, error) +} + +type client struct { + apiURL string + httpClient *http.Client + token string +} + +type ClientOption func(*client) + +func WithHTTPClient(httpClient *http.Client) ClientOption { + return func(c *client) { + c.httpClient = httpClient + } +} + +func WithToken(token string) ClientOption { + return func(c *client) { + c.token = token + } +} + +func New(apiURL string, opts ...ClientOption) Client { + c := &client{ + apiURL: apiURL, + } + for _, opt := range opts { + opt(c) + } + + if c.httpClient == nil { + c.httpClient = defaultHTTPClient + } + + return c +} + +func setAuthorizationHeader(req *http.Request, token string) { + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } +} + +func errorFromResponse(resp *http.Response) error { + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("unexpected response: status=%d", resp.StatusCode) + } + var apiError types.APIError + err = json.Unmarshal(body, &apiError) + if err != nil { + return fmt.Errorf("unexpected response: status=%d, body=%q", resp.StatusCode, string(body)) + } + return &apiError +} diff --git a/api/client/client_test.go b/api/client/client_test.go new file mode 100644 index 000000000..bfda499aa --- /dev/null +++ b/api/client/client_test.go @@ -0,0 +1,286 @@ +package client + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNew(t *testing.T) { + // Test default client creation + c := New("http://example.com") + clientImpl, ok := c.(*client) + assert.True(t, ok, "Expected c to be of type *client") + assert.Equal(t, "http://example.com", clientImpl.apiURL) + assert.Equal(t, defaultHTTPClient, clientImpl.httpClient) + assert.Empty(t, clientImpl.token) + + // Test with custom HTTP client + customHTTPClient := &http.Client{} + c = New("http://example.com", WithHTTPClient(customHTTPClient)) + clientImpl, ok = c.(*client) + assert.True(t, ok, "Expected c to be of type *client") + assert.Equal(t, customHTTPClient, clientImpl.httpClient) + + // Test with token + c = New("http://example.com", WithToken("test-token")) + clientImpl, ok = c.(*client) + assert.True(t, ok, "Expected c to be of type *client") + assert.Equal(t, "test-token", clientImpl.token) + + // Test with multiple options + c = New("http://example.com", WithHTTPClient(customHTTPClient), WithToken("test-token")) + clientImpl, ok = c.(*client) + assert.True(t, ok, "Expected c to be of type *client") + assert.Equal(t, customHTTPClient, clientImpl.httpClient) + assert.Equal(t, "test-token", clientImpl.token) +} + +func TestLogin(t *testing.T) { + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/auth/login", r.URL.Path) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + // Decode request body + var loginReq struct { + Password string `json:"password"` + } + err := json.NewDecoder(r.Body).Decode(&loginReq) + require.NoError(t, err, "Failed to decode request body") + + // Check password + if loginReq.Password == "correct-password" { + // Return successful response + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(struct { + Token string `json:"token"` + }{ + Token: "test-token", + }) + } else { + // Return error response + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(types.APIError{ + StatusCode: http.StatusUnauthorized, + Message: "Invalid password", + }) + } + })) + defer server.Close() + + // Test successful login + c := New(server.URL) + err := c.Login("correct-password") + assert.NoError(t, err) + + // Check that token was set + clientImpl, ok := c.(*client) + require.True(t, ok, "Expected c to be of type *client") + assert.Equal(t, "test-token", clientImpl.token) + + // Test failed login + c = New(server.URL) + err = c.Login("wrong-password") + assert.Error(t, err) + + // Check that error is of type APIError + apiErr, ok := err.(*types.APIError) + require.True(t, ok, "Expected err to be of type *types.APIError") + assert.Equal(t, http.StatusUnauthorized, apiErr.StatusCode) + assert.Equal(t, "Invalid password", apiErr.Message) +} + +func TestGetInstall(t *testing.T) { + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Equal(t, "/api/install", r.URL.Path) + + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + + // Return successful response + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.Install{ + Config: types.InstallationConfig{ + GlobalCIDR: "10.0.0.0/24", + AdminConsolePort: 8080, + }, + }) + })) + defer server.Close() + + // Test successful get + c := New(server.URL, WithToken("test-token")) + install, err := c.GetInstall() + assert.NoError(t, err) + assert.NotNil(t, install) + assert.Equal(t, "10.0.0.0/24", install.Config.GlobalCIDR) + assert.Equal(t, 8080, install.Config.AdminConsolePort) + + // Test error response + errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(types.APIError{ + StatusCode: http.StatusInternalServerError, + Message: "Internal Server Error", + }) + })) + defer errorServer.Close() + + c = New(errorServer.URL, WithToken("test-token")) + install, err = c.GetInstall() + assert.Error(t, err) + assert.Nil(t, install) + + apiErr, ok := err.(*types.APIError) + require.True(t, ok, "Expected err to be of type *types.APIError") + assert.Equal(t, http.StatusInternalServerError, apiErr.StatusCode) + assert.Equal(t, "Internal Server Error", apiErr.Message) +} + +func TestSetInstallConfig(t *testing.T) { + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check request method and path + assert.Equal(t, "POST", r.Method) // Corrected from PUT to POST based on implementation + assert.Equal(t, "/api/install/config", r.URL.Path) + + // Check headers + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + + // Decode request body + var config types.InstallationConfig + err := json.NewDecoder(r.Body).Decode(&config) + require.NoError(t, err, "Failed to decode request body") + + // Return successful response + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.Install{ + Config: config, + }) + })) + defer server.Close() + + // Test successful set + c := New(server.URL, WithToken("test-token")) + config := types.InstallationConfig{ + GlobalCIDR: "20.0.0.0/24", + LocalArtifactMirrorPort: 9081, + } + install, err := c.SetInstallConfig(config) + assert.NoError(t, err) + assert.NotNil(t, install) + assert.Equal(t, config, install.Config) + + // Test error response + errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(types.APIError{ + StatusCode: http.StatusBadRequest, + Message: "Bad Request", + }) + })) + defer errorServer.Close() + + c = New(errorServer.URL, WithToken("test-token")) + install, err = c.SetInstallConfig(config) + assert.Error(t, err) + assert.Nil(t, install) + + apiErr, ok := err.(*types.APIError) + require.True(t, ok, "Expected err to be of type *types.APIError") + assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode) + assert.Equal(t, "Bad Request", apiErr.Message) +} + +func TestSetInstallStatus(t *testing.T) { + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/api/install/status", r.URL.Path) + + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + + // Decode request body + var status types.InstallationStatus + err := json.NewDecoder(r.Body).Decode(&status) + require.NoError(t, err, "Failed to decode request body") + + // Return successful response + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(types.Install{ + Status: status, + }) + })) + defer server.Close() + + // Test successful set + c := New(server.URL, WithToken("test-token")) + status := types.InstallationStatus{ + State: types.InstallationStateSucceeded, + Description: "Installation successful", + } + install, err := c.SetInstallStatus(status) + assert.NoError(t, err) + assert.NotNil(t, install) + assert.Equal(t, status, install.Status) + + // Test error response + errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(types.APIError{ + StatusCode: http.StatusBadRequest, + Message: "Bad Request", + }) + })) + defer errorServer.Close() + + c = New(errorServer.URL, WithToken("test-token")) + install, err = c.SetInstallStatus(status) + assert.Error(t, err) + assert.Nil(t, install) + + apiErr, ok := err.(*types.APIError) + require.True(t, ok, "Expected err to be of type *types.APIError") + assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode) + assert.Equal(t, "Bad Request", apiErr.Message) +} + +func TestErrorFromResponse(t *testing.T) { + // Create a response with an error + resp := &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(bytes.NewBufferString(`{"status_code": 400, "message": "Bad Request"}`)), + } + + err := errorFromResponse(resp) + assert.Error(t, err) + + // Check that error is of type APIError + apiErr, ok := err.(*types.APIError) + require.True(t, ok, "Expected err to be of type *types.APIError") + assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode) + assert.Equal(t, "Bad Request", apiErr.Message) + + // Test with malformed JSON + resp = &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(bytes.NewBufferString(`not a json`)), + } + + err = errorFromResponse(resp) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unexpected response") +} diff --git a/api/client/install.go b/api/client/install.go new file mode 100644 index 000000000..902f47e08 --- /dev/null +++ b/api/client/install.go @@ -0,0 +1,100 @@ +package client + +import ( + "bytes" + "encoding/json" + "net/http" + + "github.com/replicatedhq/embedded-cluster/api/types" +) + +func (c *client) GetInstall() (*types.Install, error) { + req, err := http.NewRequest("GET", c.apiURL+"/api/install", nil) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + setAuthorizationHeader(req, c.token) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errorFromResponse(resp) + } + + var install types.Install + err = json.NewDecoder(resp.Body).Decode(&install) + if err != nil { + return nil, err + } + + return &install, nil +} + +func (c *client) SetInstallConfig(config types.InstallationConfig) (*types.Install, error) { + b, err := json.Marshal(config) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", c.apiURL+"/api/install/config", bytes.NewBuffer(b)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + setAuthorizationHeader(req, c.token) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errorFromResponse(resp) + } + + var install types.Install + err = json.NewDecoder(resp.Body).Decode(&install) + if err != nil { + return nil, err + } + + return &install, nil +} + +func (c *client) SetInstallStatus(status types.InstallationStatus) (*types.Install, error) { + b, err := json.Marshal(status) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", c.apiURL+"/api/install/status", bytes.NewBuffer(b)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + setAuthorizationHeader(req, c.token) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errorFromResponse(resp) + } + + var install types.Install + err = json.NewDecoder(resp.Body).Decode(&install) + if err != nil { + return nil, err + } + + return &install, nil +} diff --git a/api/console.go b/api/console.go new file mode 100644 index 000000000..8181f3a59 --- /dev/null +++ b/api/console.go @@ -0,0 +1,64 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/replicatedhq/embedded-cluster/api/types" +) + +type getBrandingResponse struct { + Branding types.Branding `json:"branding"` +} + +func (a *API) getBranding(w http.ResponseWriter, r *http.Request) { + branding, err := a.consoleController.GetBranding() + if err != nil { + a.logger.WithFields(logrusFieldsFromRequest(r)).WithError(err). + Error("failed to get branding") + handleError(w, err) + return + } + + response := getBrandingResponse{ + Branding: branding, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + err = json.NewEncoder(w).Encode(response) + if err != nil { + a.logger.WithFields(logrusFieldsFromRequest(r)).WithError(err). + Error("failed to encode branding") + } +} + +type getListAvailableNetworkInterfacesResponse struct { + NetworkInterfaces []string `json:"networkInterfaces"` +} + +func (a *API) getListAvailableNetworkInterfaces(w http.ResponseWriter, r *http.Request) { + interfaces, err := a.consoleController.ListAvailableNetworkInterfaces() + if err != nil { + a.logger.WithFields(logrusFieldsFromRequest(r)).WithError(err). + Error("failed to list available network interfaces") + handleError(w, err) + return + } + + a.logger.WithFields(logrusFieldsFromRequest(r)). + WithField("interfaces", interfaces). + Info("got available network interfaces") + + response := getListAvailableNetworkInterfacesResponse{ + NetworkInterfaces: interfaces, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + err = json.NewEncoder(w).Encode(response) + if err != nil { + a.logger.WithFields(logrusFieldsFromRequest(r)).WithError(err). + Error("failed to encode available network interfaces") + } +} diff --git a/api/controllers/auth/controller.go b/api/controllers/auth/controller.go new file mode 100644 index 000000000..ede25d5cd --- /dev/null +++ b/api/controllers/auth/controller.go @@ -0,0 +1,60 @@ +package auth + +import ( + "context" + "errors" + "fmt" +) + +var ErrInvalidPassword = errors.New("invalid password") + +type Controller interface { + Authenticate(ctx context.Context, password string) (string, error) + ValidateToken(ctx context.Context, token string) error +} + +var _ Controller = &AuthController{} + +type AuthController struct { + password string +} + +type AuthControllerOption func(*AuthController) + +func NewAuthController(password string, opts ...AuthControllerOption) (*AuthController, error) { + controller := &AuthController{ + password: password, + } + + for _, opt := range opts { + opt(controller) + } + + if controller.password == "" { + return nil, fmt.Errorf("password is required") + } + + return controller, nil +} + +func (c *AuthController) Authenticate(ctx context.Context, password string) (string, error) { + if password != c.password { + return "", ErrInvalidPassword + } + + token, err := getToken("admin") + if err != nil { + return "", fmt.Errorf("failed to create session token: %w", err) + } + + return token, nil +} + +func (c *AuthController) ValidateToken(ctx context.Context, token string) error { + _, err := validateToken(token) + if err != nil { + return err + } + + return nil +} diff --git a/api/controllers/auth/token.go b/api/controllers/auth/token.go new file mode 100644 index 000000000..fa3850e3c --- /dev/null +++ b/api/controllers/auth/token.go @@ -0,0 +1,35 @@ +package auth + +import ( + "fmt" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +var signingKey []byte + +func init() { + signingKey = []byte(uuid.New().String()) +} + +func getToken(name string) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "name": name, + }) + tokenString, err := token.SignedString(signingKey) + return tokenString, err +} + +func validateToken(tokenString string) (jwt.Claims, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) { + return signingKey, nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})) + if err != nil { + return nil, err + } + if !token.Valid { + return nil, fmt.Errorf("invalid token") + } + return token.Claims, nil +} diff --git a/api/controllers/console/controller.go b/api/controllers/console/controller.go new file mode 100644 index 000000000..94d706969 --- /dev/null +++ b/api/controllers/console/controller.go @@ -0,0 +1,56 @@ +package console + +import ( + "fmt" + + "github.com/replicatedhq/embedded-cluster/api/pkg/utils" + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/release" +) + +type Controller interface { + GetBranding() (types.Branding, error) + ListAvailableNetworkInterfaces() ([]string, error) +} + +type ConsoleController struct { + utils.NetUtils +} + +type ConsoleControllerOption func(*ConsoleController) + +func WithNetUtils(netUtils utils.NetUtils) ConsoleControllerOption { + return func(c *ConsoleController) { + c.NetUtils = netUtils + } +} + +func NewConsoleController(opts ...ConsoleControllerOption) (*ConsoleController, error) { + controller := &ConsoleController{} + + for _, opt := range opts { + opt(controller) + } + + if controller.NetUtils == nil { + controller.NetUtils = utils.NewNetUtils() + } + + return controller, nil +} + +func (c *ConsoleController) GetBranding() (types.Branding, error) { + app := release.GetApplication() + if app == nil { + return types.Branding{}, fmt.Errorf("application not found") + } + + return types.Branding{ + AppTitle: app.Spec.Title, + AppIcon: app.Spec.Icon, + }, nil +} + +func (c *ConsoleController) ListAvailableNetworkInterfaces() ([]string, error) { + return c.NetUtils.ListValidNetworkInterfaces() +} diff --git a/api/controllers/install/controller.go b/api/controllers/install/controller.go new file mode 100644 index 000000000..93d1c5582 --- /dev/null +++ b/api/controllers/install/controller.go @@ -0,0 +1,113 @@ +package install + +import ( + "context" + "fmt" + + "github.com/replicatedhq/embedded-cluster/api/pkg/installation" + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/netutils" +) + +type Controller interface { + Get(ctx context.Context) (*types.Install, error) + SetConfig(ctx context.Context, config *types.InstallationConfig) error + SetStatus(ctx context.Context, status *types.InstallationStatus) error + ReadStatus(ctx context.Context) (*types.InstallationStatus, error) +} + +var _ Controller = &InstallController{} + +type InstallController struct { + installationManager installation.InstallationManager +} + +type InstallControllerOption func(*InstallController) + +func WithInstallationManager(installationManager installation.InstallationManager) InstallControllerOption { + return func(c *InstallController) { + c.installationManager = installationManager + } +} + +func NewInstallController(opts ...InstallControllerOption) (*InstallController, error) { + controller := &InstallController{} + + for _, opt := range opts { + opt(controller) + } + + if controller.installationManager == nil { + controller.installationManager = installation.NewInstallationManager() + } + + return controller, nil +} + +func (c *InstallController) Get(ctx context.Context) (*types.Install, error) { + config, err := c.installationManager.ReadConfig() + if err != nil { + return nil, err + } + + if err := c.installationManager.SetDefaults(config); err != nil { + return nil, fmt.Errorf("set defaults: %w", err) + } + + if err := c.installationManager.ValidateConfig(config); err != nil { + return nil, fmt.Errorf("validate: %w", err) + } + + status, err := c.installationManager.ReadStatus() + if err != nil { + return nil, fmt.Errorf("read status: %w", err) + } + + install := &types.Install{ + Config: *config, + Status: *status, + } + + return install, nil +} + +func (c *InstallController) SetConfig(ctx context.Context, config *types.InstallationConfig) error { + if err := c.installationManager.ValidateConfig(config); err != nil { + return fmt.Errorf("validate: %w", err) + } + + if err := c.computeCIDRs(config); err != nil { + return fmt.Errorf("compute cidrs: %w", err) + } + + if err := c.installationManager.WriteConfig(*config); err != nil { + return fmt.Errorf("write: %w", err) + } + + return nil +} + +func (c *InstallController) SetStatus(ctx context.Context, status *types.InstallationStatus) error { + if err := c.installationManager.WriteStatus(*status); err != nil { + return fmt.Errorf("write: %w", err) + } + + return nil +} + +func (c *InstallController) ReadStatus(ctx context.Context) (*types.InstallationStatus, error) { + return c.installationManager.ReadStatus() +} + +func (c *InstallController) computeCIDRs(config *types.InstallationConfig) error { + if config.GlobalCIDR != "" { + podCIDR, serviceCIDR, err := netutils.SplitNetworkCIDR(config.GlobalCIDR) + if err != nil { + return fmt.Errorf("split network cidr: %w", err) + } + config.PodCIDR = podCIDR + config.ServiceCIDR = serviceCIDR + } + + return nil +} diff --git a/api/health.go b/api/health.go new file mode 100644 index 000000000..394bbf618 --- /dev/null +++ b/api/health.go @@ -0,0 +1,9 @@ +package api + +import ( + "net/http" +) + +func (a *API) getHealth(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) +} diff --git a/api/install.go b/api/install.go new file mode 100644 index 000000000..e93e05683 --- /dev/null +++ b/api/install.go @@ -0,0 +1,99 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/sirupsen/logrus" +) + +type InstallationConfigRequest struct { + DataDirectory string `json:"dataDirectory"` +} + +func (a *API) getInstall(w http.ResponseWriter, r *http.Request) { + install, err := a.installController.Get(r.Context()) + if err != nil { + a.logger.WithFields(logrusFieldsFromRequest(r)).WithError(err). + Error("failed to get installation") + handleError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + err = json.NewEncoder(w).Encode(install) + if err != nil { + a.logger.WithFields(logrusFieldsFromRequest(r)).WithError(err). + Error("failed to encode installation") + } +} + +func (a *API) setInstallConfig(w http.ResponseWriter, r *http.Request) { + var config types.InstallationConfig + if err := json.NewDecoder(r.Body).Decode(&config); err != nil { + a.logger.WithFields(logrusFieldsFromRequest(r)).WithError(err). + Info("failed to decode installation config") + types.NewBadRequestError(err).JSON(w) + return + } + + if err := a.installController.SetConfig(r.Context(), &config); err != nil { + a.logger.WithFields(logrusFieldsFromRequest(r)).WithError(err). + Error("failed to set installation config") + handleError(w, err) + return + } + + a.getInstall(w, r) + + // TODO: this is a hack to get the config to the CLI + if a.configChan != nil { + a.configChan <- &config + } +} + +func (a *API) setInstallStatus(w http.ResponseWriter, r *http.Request) { + var status types.InstallationStatus + if err := json.NewDecoder(r.Body).Decode(&status); err != nil { + a.logger.WithFields(logrusFieldsFromRequest(r)).WithError(err). + Info("failed to decode installation status") + types.NewBadRequestError(err).JSON(w) + return + } + + if err := a.installController.SetStatus(r.Context(), &status); err != nil { + a.logger.WithFields(logrusFieldsFromRequest(r)).WithError(err). + Error("failed to set installation status") + handleError(w, err) + return + } + + a.getInstall(w, r) +} + +func (a *API) getInstallStatus(w http.ResponseWriter, r *http.Request) { + status, err := a.installController.ReadStatus(r.Context()) + if err != nil { + a.logger.WithFields(logrusFieldsFromRequest(r)).WithError(err). + Error("failed to get installation status") + handleError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + err = json.NewEncoder(w).Encode(status) + if err != nil { + a.logger.WithFields(logrusFieldsFromRequest(r)).WithError(err). + Error("failed to encode installation status") + } +} + +func logrusFieldsFromRequest(r *http.Request) logrus.Fields { + return logrus.Fields{ + "method": r.Method, + "path": r.URL.Path, + } +} diff --git a/api/integration/auth_controller.go b/api/integration/auth_controller.go new file mode 100644 index 000000000..679e504aa --- /dev/null +++ b/api/integration/auth_controller.go @@ -0,0 +1,25 @@ +package integration + +import ( + "context" + "fmt" + + "github.com/replicatedhq/embedded-cluster/api/controllers/auth" +) + +var _ auth.Controller = &staticAuthController{} + +type staticAuthController struct { + token string +} + +func (s *staticAuthController) Authenticate(ctx context.Context, password string) (string, error) { + return s.token, nil +} + +func (s *staticAuthController) ValidateToken(ctx context.Context, token string) error { + if token != s.token { + return fmt.Errorf("invalid token") + } + return nil +} diff --git a/api/integration/auth_controller_test.go b/api/integration/auth_controller_test.go new file mode 100644 index 000000000..78ebbc502 --- /dev/null +++ b/api/integration/auth_controller_test.go @@ -0,0 +1,198 @@ +package integration + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/replicatedhq/embedded-cluster/api" + "github.com/replicatedhq/embedded-cluster/api/client" + "github.com/replicatedhq/embedded-cluster/api/controllers/auth" + "github.com/replicatedhq/embedded-cluster/api/controllers/install" + "github.com/replicatedhq/embedded-cluster/api/pkg/installation" + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuthLoginAndTokenValidation(t *testing.T) { + password := "test-password" + + // Create an auth controller + authController, err := auth.NewAuthController(password) + require.NoError(t, err) + + // Create an install controller + installController, err := install.NewInstallController( + install.WithInstallationManager(installation.NewInstallationManager( + installation.WithNetUtils(&mockNetUtils{ifaces: []string{"eth0", "eth1"}}), + )), + ) + require.NoError(t, err) + + // Create the API with the auth controller + apiInstance, err := api.New( + password, + api.WithAuthController(authController), + api.WithInstallController(installController), + api.WithLogger(api.NewDiscardLogger()), + ) + require.NoError(t, err) + + // Create a router and register the API routes + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + // Test successful login + t.Run("successful login", func(t *testing.T) { + // Create login request with correct password + loginReq := api.AuthRequest{ + Password: password, + } + loginReqJSON, err := json.Marshal(loginReq) + require.NoError(t, err) + + // Make the login request + req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(loginReqJSON)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the login response + assert.Equal(t, http.StatusOK, rec.Code) + + var loginResponse api.AuthResponse + err = json.NewDecoder(rec.Body).Decode(&loginResponse) + require.NoError(t, err) + + // Validate that we got a session token + assert.NotEmpty(t, loginResponse.Token) + + // Now use this token to access a protected route + getInstallReq := httptest.NewRequest(http.MethodGet, "/install", nil) + getInstallReq.Header.Set("Authorization", "Bearer "+loginResponse.Token) + getInstallRec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(getInstallRec, getInstallReq) + + // Check that we got a 200 OK (or at least not a 401 Unauthorized) + assert.NotEqual(t, http.StatusUnauthorized, getInstallRec.Code) + }) + + // Test failed login with incorrect password + t.Run("failed login with incorrect password", func(t *testing.T) { + // Create login request with incorrect password + loginReq := api.AuthRequest{ + Password: "wrong-password", + } + loginReqJSON, err := json.Marshal(loginReq) + require.NoError(t, err) + + // Make the login request + req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(loginReqJSON)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check that we got a 401 Unauthorized + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) + + // Test access to protected route without token + t.Run("access protected route without token", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/install", nil) + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check that we got a 401 Unauthorized + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) + + // Test access to protected route with invalid token + t.Run("access protected route with invalid token", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/install", nil) + req.Header.Set("Authorization", "Bearer "+"invalid-token") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check that we got a 401 Unauthorized + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) +} + +func TestAPIClientLogin(t *testing.T) { + password := "test-password" + + // Create an auth controller + authController, err := auth.NewAuthController(password) + require.NoError(t, err) + + // Create an install controller + installController, err := install.NewInstallController( + install.WithInstallationManager(installation.NewInstallationManager()), + ) + require.NoError(t, err) + + // Create the API with the auth controller + apiInstance, err := api.New( + password, + api.WithAuthController(authController), + api.WithInstallController(installController), + api.WithLogger(api.NewDiscardLogger()), + ) + require.NoError(t, err) + + // Create a router and register the API routes + router := mux.NewRouter() + apiInstance.RegisterRoutes(router.PathPrefix("/api").Subrouter()) + + // Create a test server using the router + server := httptest.NewServer(router) + defer server.Close() + + // Test successful login with API client + t.Run("successful login with client", func(t *testing.T) { + // Create an API client + c := client.New(server.URL) + + // Login with the client + err := c.Login(password) + require.NoError(t, err, "API client login should succeed with correct password") + + // Verify we can make authenticated requests after login + install, err := c.GetInstall() + require.NoError(t, err, "API client should be able to get install after successful login") + assert.NotNil(t, install, "Install should not be nil") + }) + + // Test failed login with incorrect password + t.Run("failed login with incorrect password", func(t *testing.T) { + // Create a new client for this test + c := client.New(server.URL) + + // Attempt to login with wrong password + err := c.Login("wrong-password") + require.Error(t, err, "API client login should fail with wrong password") + + // Check that the error is of correct type + apiErr, ok := err.(*types.APIError) + require.True(t, ok, "Error should be of type *types.APIError") + assert.Equal(t, http.StatusUnauthorized, apiErr.StatusCode, "Error should have Unauthorized status code") + + // Verify we can't make authenticated requests + _, err = c.GetInstall() + require.Error(t, err, "API client should not be able to get install after failed login") + }) +} diff --git a/api/integration/console_test.go b/api/integration/console_test.go new file mode 100644 index 000000000..eb67a5275 --- /dev/null +++ b/api/integration/console_test.go @@ -0,0 +1,70 @@ +package integration + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/replicatedhq/embedded-cluster/api" + "github.com/replicatedhq/embedded-cluster/api/controllers/console" + "github.com/replicatedhq/embedded-cluster/api/controllers/install" + "github.com/replicatedhq/embedded-cluster/api/pkg/installation" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConsoleListAvailableNetworkInterfaces(t *testing.T) { + netutils := &mockNetUtils{ifaces: []string{"eth0", "eth1"}} + + // Create a console controller + consoleController, err := console.NewConsoleController( + console.WithNetUtils(netutils), + ) + require.NoError(t, err) + + // Create an install controller + installController, err := install.NewInstallController( + install.WithInstallationManager(installation.NewInstallationManager( + installation.WithNetUtils(netutils), + )), + ) + require.NoError(t, err) + + // Create the API with the install controller + apiInstance, err := api.New( + "password", + api.WithInstallController(installController), + api.WithConsoleController(consoleController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(api.NewDiscardLogger()), + ) + require.NoError(t, err) + + // Create a router and register the API routes + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + // Create a request to the network interfaces endpoint + req := httptest.NewRequest(http.MethodGet, "/console/available-network-interfaces", nil) + req.Header.Set("Authorization", "Bearer TOKEN") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + + // Parse the response body + var response struct { + NetworkInterfaces []string `json:"networkInterfaces"` + } + err = json.NewDecoder(rec.Body).Decode(&response) + require.NoError(t, err) + + // Verify the response contains the expected network interfaces + assert.Equal(t, []string{"eth0", "eth1"}, response.NetworkInterfaces) +} diff --git a/api/integration/install_test.go b/api/integration/install_test.go new file mode 100644 index 000000000..c48f5f3e3 --- /dev/null +++ b/api/integration/install_test.go @@ -0,0 +1,570 @@ +package integration + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/replicatedhq/embedded-cluster/api" + "github.com/replicatedhq/embedded-cluster/api/client" + "github.com/replicatedhq/embedded-cluster/api/controllers/install" + "github.com/replicatedhq/embedded-cluster/api/pkg/installation" + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSetInstallConfig(t *testing.T) { + manager := installation.NewInstallationManager() + + // Create an install controller with the config manager + installController, err := install.NewInstallController( + install.WithInstallationManager(manager), + ) + require.NoError(t, err) + + // Create the API with the install controller + apiInstance, err := api.New( + "password", + api.WithInstallController(installController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(api.NewDiscardLogger()), + ) + require.NoError(t, err) + + // Create a router and register the API routes + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + // Test scenarios + testCases := []struct { + name string + config types.InstallationConfig + expectedStatus int + expectedError bool + }{ + { + name: "Valid config", + config: types.InstallationConfig{ + DataDirectory: "/tmp/data", + AdminConsolePort: 8000, + LocalArtifactMirrorPort: 8081, + GlobalCIDR: "10.0.0.0/16", + NetworkInterface: "eth0", + }, + expectedStatus: http.StatusOK, + expectedError: false, + }, + { + name: "Invalid config - port conflict", + config: types.InstallationConfig{ + DataDirectory: "/tmp/data", + AdminConsolePort: 8080, + LocalArtifactMirrorPort: 8080, // Same as AdminConsolePort + GlobalCIDR: "10.0.0.0/16", + NetworkInterface: "eth0", + }, + expectedStatus: http.StatusBadRequest, + expectedError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Serialize the config to JSON + configJSON, err := json.Marshal(tc.config) + require.NoError(t, err) + + // Create a request + req := httptest.NewRequest(http.MethodPost, "/install/config", bytes.NewReader(configJSON)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+"TOKEN") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, tc.expectedStatus, rec.Code) + + t.Logf("Response body: %s", rec.Body.String()) + + // Parse the response body + if tc.expectedError { + var apiError types.APIError + err = json.NewDecoder(rec.Body).Decode(&apiError) + require.NoError(t, err) + assert.Equal(t, tc.expectedStatus, apiError.StatusCode) + assert.NotEmpty(t, apiError.Message) + } else { + var install types.Install + err = json.NewDecoder(rec.Body).Decode(&install) + require.NoError(t, err) + + // Verify that the config was properly set + assert.Equal(t, tc.config.DataDirectory, install.Config.DataDirectory) + assert.Equal(t, tc.config.AdminConsolePort, install.Config.AdminConsolePort) + } + + // Also verify that the config is in the store + if !tc.expectedError { + storedConfig, err := manager.ReadConfig() + require.NoError(t, err) + assert.Equal(t, tc.config.DataDirectory, storedConfig.DataDirectory) + assert.Equal(t, tc.config.AdminConsolePort, storedConfig.AdminConsolePort) + } + }) + } +} + +// Test that config validation errors are properly returned +func TestSetInstallConfigValidation(t *testing.T) { + // Create a memory store + manager := installation.NewInstallationManager() + + // Create an install controller with the config manager + installController, err := install.NewInstallController( + install.WithInstallationManager(manager), + ) + require.NoError(t, err) + + // Create the API with the install controller + apiInstance, err := api.New( + "password", + api.WithInstallController(installController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(api.NewDiscardLogger()), + ) + require.NoError(t, err) + + // Create a router and register the API routes + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + // Test a validation error case with mixed CIDR settings + config := types.InstallationConfig{ + DataDirectory: "/tmp/data", + AdminConsolePort: 8000, + LocalArtifactMirrorPort: 8081, + PodCIDR: "10.244.0.0/16", // Specify PodCIDR but not ServiceCIDR + NetworkInterface: "eth0", + } + + // Serialize the config to JSON + configJSON, err := json.Marshal(config) + require.NoError(t, err) + + // Create a request + req := httptest.NewRequest(http.MethodPost, "/install/config", bytes.NewReader(configJSON)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+"TOKEN") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusBadRequest, rec.Code) + + t.Logf("Response body: %s", rec.Body.String()) + + // We expect a ValidationError with specific error about ServiceCIDR + var apiError types.APIError + err = json.NewDecoder(rec.Body).Decode(&apiError) + require.NoError(t, err) + assert.Contains(t, apiError.Error(), "serviceCidr") +} + +// Test that the endpoint properly handles malformed JSON +func TestSetInstallConfigBadRequest(t *testing.T) { + // Create a memory store and API + manager := installation.NewInstallationManager() + + // Create an install controller with the config manager + installController, err := install.NewInstallController( + install.WithInstallationManager(manager), + ) + require.NoError(t, err) + + apiInstance, err := api.New( + "password", + api.WithInstallController(installController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(api.NewDiscardLogger()), + ) + require.NoError(t, err) + + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + // Create a request with invalid JSON + req := httptest.NewRequest(http.MethodPost, "/install/config", + bytes.NewReader([]byte(`{"dataDirectory": "/tmp/data", "adminConsolePort": "not-a-number"}`))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+"TOKEN") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusBadRequest, rec.Code) + + t.Logf("Response body: %s", rec.Body.String()) +} + +// Test that the server returns proper errors when the API controller fails +func TestSetInstallConfigControllerError(t *testing.T) { + // Create a mock controller that returns an error + mockController := &mockInstallController{ + setConfigError: assert.AnError, + } + + // Create the API with the mock controller + apiInstance, err := api.New( + "password", + api.WithInstallController(mockController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(api.NewDiscardLogger()), + ) + require.NoError(t, err) + + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + // Create a valid config request + config := types.InstallationConfig{ + DataDirectory: "/tmp/data", + AdminConsolePort: 8000, + } + configJSON, err := json.Marshal(config) + require.NoError(t, err) + + // Create a request + req := httptest.NewRequest(http.MethodPost, "/install/config", bytes.NewReader(configJSON)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+"TOKEN") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusInternalServerError, rec.Code) + + t.Logf("Response body: %s", rec.Body.String()) +} + +// Test the getInstall endpoint returns installation data correctly +func TestGetInstall(t *testing.T) { + // Create a config manager + installationManager := installation.NewInstallationManager() + + // Create an install controller with the config manager + installController, err := install.NewInstallController( + install.WithInstallationManager(installationManager), + ) + require.NoError(t, err) + + // Set some initial config + initialConfig := types.InstallationConfig{ + DataDirectory: "/tmp/test-data", + AdminConsolePort: 8080, + LocalArtifactMirrorPort: 8081, + GlobalCIDR: "10.0.0.0/16", + NetworkInterface: "eth0", + } + err = installationManager.WriteConfig(initialConfig) + require.NoError(t, err) + + // Create the API with the install controller + apiInstance, err := api.New( + "password", + api.WithInstallController(installController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(api.NewDiscardLogger()), + ) + require.NoError(t, err) + + // Create a router and register the API routes + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + // Test successful get + t.Run("Success", func(t *testing.T) { + // Create a request + req := httptest.NewRequest(http.MethodGet, "/install", nil) + req.Header.Set("Authorization", "Bearer "+"TOKEN") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + + // Parse the response body + var install types.Install + err = json.NewDecoder(rec.Body).Decode(&install) + require.NoError(t, err) + + // Verify the installation data matches what we expect + assert.Equal(t, initialConfig.DataDirectory, install.Config.DataDirectory) + assert.Equal(t, initialConfig.AdminConsolePort, install.Config.AdminConsolePort) + assert.Equal(t, initialConfig.LocalArtifactMirrorPort, install.Config.LocalArtifactMirrorPort) + assert.Equal(t, initialConfig.GlobalCIDR, install.Config.GlobalCIDR) + assert.Equal(t, initialConfig.NetworkInterface, install.Config.NetworkInterface) + }) + + // Test get with default/empty configuration + t.Run("Default configuration", func(t *testing.T) { + // Create a fresh config manager without writing anything + emptyInstallationManager := installation.NewInstallationManager( + installation.WithNetUtils(&mockNetUtils{ifaces: []string{"eth0", "eth1"}}), + ) + + // Create an install controller with the empty config manager + emptyInstallController, err := install.NewInstallController( + install.WithInstallationManager(emptyInstallationManager), + ) + require.NoError(t, err) + + // Create the API with the install controller + emptyAPI, err := api.New( + "password", + api.WithInstallController(emptyInstallController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(api.NewDiscardLogger()), + ) + require.NoError(t, err) + + // Create a router and register the API routes + emptyRouter := mux.NewRouter() + emptyAPI.RegisterRoutes(emptyRouter) + + // Create a request + req := httptest.NewRequest(http.MethodGet, "/install", nil) + req.Header.Set("Authorization", "Bearer "+"TOKEN") + rec := httptest.NewRecorder() + + // Serve the request + emptyRouter.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + + // Parse the response body + var install types.Install + err = json.NewDecoder(rec.Body).Decode(&install) + require.NoError(t, err) + + // Verify the installation data contains defaults or empty values + assert.Equal(t, "/var/lib/embedded-cluster", install.Config.DataDirectory) + assert.Equal(t, 30000, install.Config.AdminConsolePort) + assert.Equal(t, 50000, install.Config.LocalArtifactMirrorPort) + assert.Equal(t, "10.244.0.0/16", install.Config.GlobalCIDR) + assert.Equal(t, "eth0", install.Config.NetworkInterface) + }) + + // Test error handling + t.Run("Controller error", func(t *testing.T) { + // Create a mock controller that returns an error + mockController := &mockInstallController{ + getError: assert.AnError, + } + + // Create the API with the mock controller + apiInstance, err := api.New( + "password", + api.WithInstallController(mockController), + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithLogger(api.NewDiscardLogger()), + ) + require.NoError(t, err) + + router := mux.NewRouter() + apiInstance.RegisterRoutes(router) + + // Create a request + req := httptest.NewRequest(http.MethodGet, "/install", nil) + req.Header.Set("Authorization", "Bearer "+"TOKEN") + rec := httptest.NewRecorder() + + // Serve the request + router.ServeHTTP(rec, req) + + // Check the response + assert.Equal(t, http.StatusInternalServerError, rec.Code) + + // Parse the response body + var apiError types.APIError + err = json.NewDecoder(rec.Body).Decode(&apiError) + require.NoError(t, err) + assert.Equal(t, http.StatusInternalServerError, apiError.StatusCode) + assert.NotEmpty(t, apiError.Message) + }) +} + +var _ install.Controller = &mockInstallController{} + +// Mock implementation of the install.Controller interface +type mockInstallController struct { + setConfigError error + getError error + setStatusError error + readStatusError error +} + +func (m *mockInstallController) Get(ctx context.Context) (*types.Install, error) { + if m.getError != nil { + return nil, m.getError + } + return &types.Install{ + Config: types.InstallationConfig{}, + }, nil +} + +func (m *mockInstallController) SetConfig(ctx context.Context, config *types.InstallationConfig) error { + return m.setConfigError +} + +func (m *mockInstallController) SetStatus(ctx context.Context, status *types.InstallationStatus) error { + return m.setStatusError +} + +func (m *mockInstallController) ReadStatus(ctx context.Context) (*types.InstallationStatus, error) { + return nil, m.readStatusError +} + +// TestInstallWithAPIClient tests the install endpoints using the API client +func TestInstallWithAPIClient(t *testing.T) { + password := "test-password" + + // Create a config manager + installationManager := installation.NewInstallationManager() + + // Create an install controller with the config manager + installController, err := install.NewInstallController( + install.WithInstallationManager(installationManager), + ) + require.NoError(t, err) + + // Set some initial config + initialConfig := types.InstallationConfig{ + DataDirectory: "/tmp/test-data-for-client", + AdminConsolePort: 9080, + LocalArtifactMirrorPort: 9081, + GlobalCIDR: "192.168.0.0/16", + NetworkInterface: "eth1", + } + err = installationManager.WriteConfig(initialConfig) + require.NoError(t, err) + + // Create the API with controllers + apiInstance, err := api.New( + password, + api.WithAuthController(&staticAuthController{"TOKEN"}), + api.WithInstallController(installController), + api.WithLogger(api.NewDiscardLogger()), + ) + require.NoError(t, err) + + // Create a router and register the API routes + router := mux.NewRouter() + apiInstance.RegisterRoutes(router.PathPrefix("/api").Subrouter()) + + // Create a test server using the router + server := httptest.NewServer(router) + defer server.Close() + + // Create client with the predefined token + c := client.New(server.URL, client.WithToken("TOKEN")) + require.NoError(t, err, "API client login should succeed") + + // Test GetInstall + t.Run("GetInstall", func(t *testing.T) { + install, err := c.GetInstall() + require.NoError(t, err, "GetInstall should succeed") + assert.NotNil(t, install, "Install should not be nil") + + // Verify values + assert.Equal(t, "/tmp/test-data-for-client", install.Config.DataDirectory) + assert.Equal(t, 9080, install.Config.AdminConsolePort) + assert.Equal(t, 9081, install.Config.LocalArtifactMirrorPort) + assert.Equal(t, "192.168.0.0/16", install.Config.GlobalCIDR) + assert.Equal(t, "eth1", install.Config.NetworkInterface) + }) + + // Test SetInstallConfig + t.Run("SetInstallConfig", func(t *testing.T) { + // Create a valid config + config := types.InstallationConfig{ + DataDirectory: "/tmp/new-dir", + AdminConsolePort: 8000, + LocalArtifactMirrorPort: 8081, + GlobalCIDR: "10.0.0.0/16", + NetworkInterface: "eth0", + } + + // Set the config using the client + install, err := c.SetInstallConfig(config) + require.NoError(t, err, "SetInstallConfig should succeed with valid config") + assert.NotNil(t, install, "Install should not be nil") + + // Verify the config was set correctly + assert.Equal(t, config.DataDirectory, install.Config.DataDirectory) + assert.Equal(t, config.AdminConsolePort, install.Config.AdminConsolePort) + assert.Equal(t, config.NetworkInterface, install.Config.NetworkInterface) + + // Get the config to verify it persisted + install, err = c.GetInstall() + require.NoError(t, err, "GetInstall should succeed after setting config") + assert.Equal(t, config.DataDirectory, install.Config.DataDirectory) + assert.Equal(t, config.AdminConsolePort, install.Config.AdminConsolePort) + assert.Equal(t, config.NetworkInterface, install.Config.NetworkInterface) + }) + + // Test SetInstallConfig validation error + t.Run("SetInstallConfig validation error", func(t *testing.T) { + // Create an invalid config (port conflict) + config := types.InstallationConfig{ + DataDirectory: "/tmp/new-dir", + AdminConsolePort: 8080, + LocalArtifactMirrorPort: 8080, // Same as AdminConsolePort + GlobalCIDR: "10.0.0.0/16", + NetworkInterface: "eth0", + } + + // Set the config using the client + _, err := c.SetInstallConfig(config) + require.Error(t, err, "SetInstallConfig should fail with invalid config") + + // Verify the error is of type APIError + apiErr, ok := err.(*types.APIError) + require.True(t, ok, "Error should be of type *types.APIError") + assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode) + // Error message should contain the field and the validation error + assert.Contains(t, apiErr.Error(), "adminConsolePort and localArtifactMirrorPort cannot be equal") + }) + + // Test SetInstallStatus + t.Run("SetInstallStatus", func(t *testing.T) { + // Create a status + status := types.InstallationStatus{ + State: types.InstallationStateFailed, + Description: "Installation failed", + } + + // Set the status using the client + install, err := c.SetInstallStatus(status) + require.NoError(t, err, "SetInstallStatus should succeed") + assert.NotNil(t, install, "Install should not be nil") + assert.NotNil(t, install.Status, status, "Install status should match the one set") + }) +} diff --git a/api/integration/netutils.go b/api/integration/netutils.go new file mode 100644 index 000000000..a44e70314 --- /dev/null +++ b/api/integration/netutils.go @@ -0,0 +1,17 @@ +package integration + +import "github.com/replicatedhq/embedded-cluster/api/pkg/utils" + +var _ utils.NetUtils = &mockNetUtils{} + +type mockNetUtils struct { + ifaces []string +} + +func (m *mockNetUtils) ListValidNetworkInterfaces() ([]string, error) { + return m.ifaces, nil +} + +func (m *mockNetUtils) DetermineBestNetworkInterface() (string, error) { + return m.ifaces[0], nil +} diff --git a/api/logging.go b/api/logging.go new file mode 100644 index 000000000..645f5df5d --- /dev/null +++ b/api/logging.go @@ -0,0 +1,35 @@ +package api + +import ( + "fmt" + "io" + "os" + "time" + + "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" + "github.com/replicatedhq/embedded-cluster/pkg/versions" + "github.com/sirupsen/logrus" +) + +func NewLogger() (*logrus.Logger, error) { + fname := fmt.Sprintf("%s-%s.api.log", runtimeconfig.BinaryName(), time.Now().Format("20060102150405.000")) + logpath := runtimeconfig.PathToLog(fname) + logfile, err := os.OpenFile(logpath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0400) + if err != nil { + return nil, fmt.Errorf("open log file: %w", err) + } + + logger := logrus.New() + logger.SetOutput(logfile) + + logger.Infof("versions: embedded-cluster=%s, k0s=%s", versions.Version, versions.K0sVersion) + logger.Infof("command line arguments: %v", os.Args) + + return logger, nil +} + +func NewDiscardLogger() *logrus.Logger { + logger := logrus.New() + logger.SetOutput(io.Discard) + return logger +} diff --git a/api/pkg/installation/config.go b/api/pkg/installation/config.go new file mode 100644 index 000000000..9e67f2171 --- /dev/null +++ b/api/pkg/installation/config.go @@ -0,0 +1,252 @@ +package installation + +import ( + "errors" + "fmt" + + "github.com/replicatedhq/embedded-cluster/api/pkg/utils" + "github.com/replicatedhq/embedded-cluster/api/types" + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + newconfig "github.com/replicatedhq/embedded-cluster/pkg-new/config" + "github.com/replicatedhq/embedded-cluster/pkg/netutils" +) + +var _ InstallationManager = &installationManager{} + +// InstallationManager provides methods for validating and setting defaults for installation configuration +type InstallationManager interface { + ReadConfig() (*types.InstallationConfig, error) + WriteConfig(config types.InstallationConfig) error + ReadStatus() (*types.InstallationStatus, error) + WriteStatus(status types.InstallationStatus) error + ValidateConfig(config *types.InstallationConfig) error + SetDefaults(config *types.InstallationConfig) error +} + +// installationManager is an implementation of the InstallationManager interface +type installationManager struct { + installationStore InstallationStore + netUtils utils.NetUtils +} + +type InstallationManagerOption func(*installationManager) + +func WithInstallationStore(installationStore InstallationStore) InstallationManagerOption { + return func(c *installationManager) { + c.installationStore = installationStore + } +} + +func WithNetUtils(netUtils utils.NetUtils) InstallationManagerOption { + return func(c *installationManager) { + c.netUtils = netUtils + } +} + +// NewInstallationManager creates a new InstallationManager with the provided network utilities +func NewInstallationManager(opts ...InstallationManagerOption) *installationManager { + manager := &installationManager{} + + for _, opt := range opts { + opt(manager) + } + + if manager.installationStore == nil { + manager.installationStore = NewMemoryStore() + } + + if manager.netUtils == nil { + manager.netUtils = utils.NewNetUtils() + } + + return manager +} + +func (m *installationManager) ReadConfig() (*types.InstallationConfig, error) { + return m.installationStore.ReadConfig() +} + +func (m *installationManager) WriteConfig(config types.InstallationConfig) error { + return m.installationStore.WriteConfig(config) +} + +func (m *installationManager) ReadStatus() (*types.InstallationStatus, error) { + return m.installationStore.ReadStatus() +} + +func (m *installationManager) WriteStatus(status types.InstallationStatus) error { + return m.installationStore.WriteStatus(status) +} + +func (m *installationManager) ValidateConfig(config *types.InstallationConfig) error { + var ve *types.APIError + + if err := m.validateGlobalCIDR(config); err != nil { + ve = types.AppendFieldError(ve, "globalCidr", err) + } + + if err := m.validatePodCIDR(config); err != nil { + ve = types.AppendFieldError(ve, "podCidr", err) + } + + if err := m.validateServiceCIDR(config); err != nil { + ve = types.AppendFieldError(ve, "serviceCidr", err) + } + + if err := m.validateNetworkInterface(config); err != nil { + ve = types.AppendFieldError(ve, "networkInterface", err) + } + + if err := m.validateAdminConsolePort(config); err != nil { + ve = types.AppendFieldError(ve, "adminConsolePort", err) + } + + if err := m.validateLocalArtifactMirrorPort(config); err != nil { + ve = types.AppendFieldError(ve, "localArtifactMirrorPort", err) + } + + if err := m.validateDataDirectory(config); err != nil { + ve = types.AppendFieldError(ve, "dataDirectory", err) + } + + return ve.ErrorOrNil() +} + +func (m *installationManager) validateGlobalCIDR(config *types.InstallationConfig) error { + if config.GlobalCIDR != "" { + if err := netutils.ValidateCIDR(config.GlobalCIDR, 16, true); err != nil { + return err + } + } else { + if config.PodCIDR == "" && config.ServiceCIDR == "" { + return errors.New("globalCidr is required") + } + } + return nil +} + +func (m *installationManager) validatePodCIDR(config *types.InstallationConfig) error { + if config.GlobalCIDR != "" { + return nil + } + if config.PodCIDR == "" { + return errors.New("podCidr is required when globalCidr is not set") + } + return nil +} + +func (m *installationManager) validateServiceCIDR(config *types.InstallationConfig) error { + if config.GlobalCIDR != "" { + return nil + } + if config.ServiceCIDR == "" { + return errors.New("serviceCidr is required when globalCidr is not set") + } + return nil +} + +func (m *installationManager) validateNetworkInterface(config *types.InstallationConfig) error { + if config.NetworkInterface == "" { + return errors.New("networkInterface is required") + } + + // TODO: validate the network interface exists and is up and not loopback + return nil +} + +func (m *installationManager) validateAdminConsolePort(config *types.InstallationConfig) error { + if config.AdminConsolePort == 0 { + return errors.New("adminConsolePort is required") + } + + lamPort := config.LocalArtifactMirrorPort + if lamPort == 0 { + lamPort = ecv1beta1.DefaultLocalArtifactMirrorPort + } + + if config.AdminConsolePort == lamPort { + return errors.New("adminConsolePort and localArtifactMirrorPort cannot be equal") + } + + return nil +} + +func (m *installationManager) validateLocalArtifactMirrorPort(config *types.InstallationConfig) error { + if config.LocalArtifactMirrorPort == 0 { + return errors.New("localArtifactMirrorPort is required") + } + + acPort := config.AdminConsolePort + if acPort == 0 { + acPort = ecv1beta1.DefaultAdminConsolePort + } + + if config.LocalArtifactMirrorPort == acPort { + return errors.New("adminConsolePort and localArtifactMirrorPort cannot be equal") + } + + return nil +} + +func (m *installationManager) validateDataDirectory(config *types.InstallationConfig) error { + if config.DataDirectory == "" { + return errors.New("dataDirectory is required") + } + + return nil +} + +// SetDefaults sets default values for the installation configuration +func (m *installationManager) SetDefaults(config *types.InstallationConfig) error { + if config.AdminConsolePort == 0 { + config.AdminConsolePort = ecv1beta1.DefaultAdminConsolePort + } + + if config.DataDirectory == "" { + config.DataDirectory = ecv1beta1.DefaultDataDir + } + + if config.LocalArtifactMirrorPort == 0 { + config.LocalArtifactMirrorPort = ecv1beta1.DefaultLocalArtifactMirrorPort + } + + // if a network interface was not provided, attempt to discover it + if config.NetworkInterface == "" { + autoInterface, err := m.netUtils.DetermineBestNetworkInterface() + if err == nil { + config.NetworkInterface = autoInterface + } + } + + if err := m.setCIDRDefaults(config); err != nil { + return fmt.Errorf("unable to set cidr defaults: %w", err) + } + + m.setProxyDefaults(config) + + return nil +} + +func (m *installationManager) setProxyDefaults(config *types.InstallationConfig) { + proxy := &ecv1beta1.ProxySpec{ + HTTPProxy: config.HTTPProxy, + HTTPSProxy: config.HTTPSProxy, + ProvidedNoProxy: config.NoProxy, + } + newconfig.SetProxyDefaults(proxy) + + config.HTTPProxy = proxy.HTTPProxy + config.HTTPSProxy = proxy.HTTPSProxy + config.NoProxy = proxy.ProvidedNoProxy +} + +func (m *installationManager) setCIDRDefaults(config *types.InstallationConfig) error { + // if the client has not explicitly set / used pod/service cidrs, we assume the client is using the global cidr + // and only popluate the default for the global cidr. + // we don't populate pod/service cidrs defaults because the client would have to explicitly + // set them in order to use them in place of the global cidr. + if config.PodCIDR == "" && config.ServiceCIDR == "" && config.GlobalCIDR == "" { + config.GlobalCIDR = ecv1beta1.DefaultNetworkCIDR + } + return nil +} diff --git a/api/pkg/installation/store.go b/api/pkg/installation/store.go new file mode 100644 index 000000000..dea3bae23 --- /dev/null +++ b/api/pkg/installation/store.go @@ -0,0 +1,59 @@ +package installation + +import ( + "sync" + + "github.com/replicatedhq/embedded-cluster/api/types" +) + +type InstallationStore interface { + ReadConfig() (*types.InstallationConfig, error) + WriteConfig(cfg types.InstallationConfig) error + ReadStatus() (*types.InstallationStatus, error) + WriteStatus(status types.InstallationStatus) error +} + +var _ InstallationStore = &MemoryStore{} + +type MemoryStore struct { + mu sync.RWMutex + config *types.InstallationConfig + status *types.InstallationStatus +} + +func NewMemoryStore() *MemoryStore { + return &MemoryStore{ + config: &types.InstallationConfig{}, + status: &types.InstallationStatus{}, + } +} + +func (s *MemoryStore) ReadConfig() (*types.InstallationConfig, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.config, nil +} + +func (s *MemoryStore) WriteConfig(cfg types.InstallationConfig) error { + s.mu.Lock() + defer s.mu.Unlock() + s.config = &cfg + + return nil +} + +func (s *MemoryStore) ReadStatus() (*types.InstallationStatus, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.status, nil +} + +func (s *MemoryStore) WriteStatus(status types.InstallationStatus) error { + s.mu.Lock() + defer s.mu.Unlock() + s.status = &status + + return nil +} diff --git a/api/pkg/utils/netutils.go b/api/pkg/utils/netutils.go new file mode 100644 index 000000000..a714f777f --- /dev/null +++ b/api/pkg/utils/netutils.go @@ -0,0 +1,37 @@ +package utils + +import ( + newconfig "github.com/replicatedhq/embedded-cluster/pkg-new/config" + "github.com/replicatedhq/embedded-cluster/pkg/netutils" +) + +type NetUtils interface { + ListValidNetworkInterfaces() ([]string, error) + DetermineBestNetworkInterface() (string, error) +} + +type netUtils struct { +} + +var _ NetUtils = &netUtils{} + +func NewNetUtils() NetUtils { + return &netUtils{} +} + +func (n *netUtils) ListValidNetworkInterfaces() ([]string, error) { + ifs, err := netutils.ListValidNetworkInterfaces() + if err != nil { + return nil, err + } + + names := []string{} + for _, i := range ifs { + names = append(names, i.Name) + } + return names, nil +} + +func (n *netUtils) DetermineBestNetworkInterface() (string, error) { + return newconfig.DetermineBestNetworkInterface() +} diff --git a/api/types/console.go b/api/types/console.go new file mode 100644 index 000000000..05ad6d44d --- /dev/null +++ b/api/types/console.go @@ -0,0 +1,6 @@ +package types + +type Branding struct { + AppTitle string `json:"appTitle"` + AppIcon string `json:"appIcon"` +} diff --git a/api/types/errors.go b/api/types/errors.go new file mode 100644 index 000000000..cf170ff68 --- /dev/null +++ b/api/types/errors.go @@ -0,0 +1,110 @@ +package types + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" +) + +type APIError struct { + StatusCode int `json:"status_code,omitempty"` + Message string `json:"message"` + Field string `json:"field,omitempty"` + Errors []*APIError `json:"errors,omitempty"` + + err error `json:"-"` +} + +func (e *APIError) Error() string { + if e == nil { + return "" + } else if len(e.Errors) == 0 { + return e.Message + } + var buf bytes.Buffer + first := true + for _, ee := range e.Errors { + if first { + first = false + } else { + buf.WriteString("; ") + } + buf.WriteString(ee.Message) + } + return fmt.Sprintf("%s: %s", e.Message, buf.String()) +} + +func (e *APIError) ErrorOrNil() error { + if e == nil || len(e.Errors) == 0 { + return nil + } + return e +} + +func (e *APIError) Unwrap() error { + return e.err +} + +func (e *APIError) JSON(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(e.StatusCode) + json.NewEncoder(w).Encode(e) +} + +func NewBadRequestError(err error) *APIError { + return &APIError{ + StatusCode: http.StatusBadRequest, + Message: err.Error(), + err: err, + } +} + +func NewUnauthorizedError(err error) *APIError { + return &APIError{ + StatusCode: http.StatusUnauthorized, + Message: err.Error(), + err: err, + } +} + +func NewInternalServerError(err error) *APIError { + return &APIError{ + StatusCode: http.StatusInternalServerError, + Message: err.Error(), + err: err, + } +} + +func AppendError(apiErr *APIError, errs ...*APIError) *APIError { + var nonNilErrs []*APIError + for _, err := range errs { + if err != nil { + nonNilErrs = append(nonNilErrs, err) + } + } + if len(nonNilErrs) == 0 { + return apiErr + } + if apiErr == nil { + apiErr = NewInternalServerError(errors.New("errors")) + } + apiErr.Errors = append(apiErr.Errors, nonNilErrs...) + return apiErr +} + +func AppendFieldError(apiErr *APIError, field string, err error) *APIError { + if apiErr == nil { + apiErr = NewBadRequestError(errors.New("field errors")) + } + return AppendError(apiErr, newFieldError(field, err)) +} + +func newFieldError(field string, err error) *APIError { + return &APIError{ + Message: fmt.Sprintf("%s: %s", field, err.Error()), + Field: field, + err: err, + } +} diff --git a/api/types/errors_test.go b/api/types/errors_test.go new file mode 100644 index 000000000..2325b1127 --- /dev/null +++ b/api/types/errors_test.go @@ -0,0 +1,53 @@ +package types + +import ( + "encoding/json" + "errors" + "fmt" + "testing" +) + +func TestAppendFieldError(t *testing.T) { + tests := []struct { + name string + fe func() *APIError + want string + }{ + { + name: "empty", + fe: func() *APIError { + return nil + }, + want: "", + }, + { + name: "single error", + fe: func() *APIError { + var fe *APIError + fe = AppendFieldError(fe, "field1", errors.New("error1")) + b, _ := json.Marshal(fe) + fmt.Println(string(b)) + return fe + }, + want: "field errors: field1: error1", + }, + { + name: "multiple errors", + fe: func() *APIError { + var fe *APIError + fe = AppendFieldError(fe, "field1", errors.New("error1")) + fe = AppendFieldError(fe, "field2", errors.New("error2")) + return fe + }, + want: "field errors: field1: error1; field2: error2", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fe := tt.fe() + if got := fe.Error(); got != tt.want { + t.Errorf("APIError.Error() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/api/types/install.go b/api/types/install.go new file mode 100644 index 000000000..10837b4d6 --- /dev/null +++ b/api/types/install.go @@ -0,0 +1,6 @@ +package types + +type Install struct { + Config InstallationConfig `json:"config"` + Status InstallationStatus `json:"status"` +} diff --git a/api/types/installation.go b/api/types/installation.go new file mode 100644 index 000000000..ac8db9d5f --- /dev/null +++ b/api/types/installation.go @@ -0,0 +1,31 @@ +package types + +import "time" + +type InstallationConfig struct { + AdminConsolePort int `json:"adminConsolePort"` + DataDirectory string `json:"dataDirectory"` + LocalArtifactMirrorPort int `json:"localArtifactMirrorPort"` + HTTPProxy string `json:"httpProxy"` + HTTPSProxy string `json:"httpsProxy"` + NoProxy string `json:"noProxy"` + NetworkInterface string `json:"networkInterface"` + PodCIDR string `json:"podCidr"` + ServiceCIDR string `json:"serviceCidr"` + GlobalCIDR string `json:"globalCidr"` +} + +type InstallationStatus struct { + State InstallationState `json:"state"` + Description string `json:"description"` + LastUpdated time.Time `json:"lastUpdated"` +} + +type InstallationState string + +const ( + InstallationStatePending InstallationState = "Pending" + InstallationStateRunning InstallationState = "Running" + InstallationStateSucceeded InstallationState = "Succeeded" + InstallationStateFailed InstallationState = "Failed" +) diff --git a/cmd/installer/cli/cidr.go b/cmd/installer/cli/cidr.go index 845d33dab..081b7ad9b 100644 --- a/cmd/installer/cli/cidr.go +++ b/cmd/installer/cli/cidr.go @@ -6,7 +6,7 @@ import ( k0sv1beta1 "github.com/k0sproject/k0s/pkg/apis/k0s/v1beta1" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" - "github.com/replicatedhq/embedded-cluster/pkg/netutils" + newconfig "github.com/replicatedhq/embedded-cluster/pkg-new/config" "github.com/spf13/cobra" ) @@ -34,24 +34,18 @@ func validateCIDRFlags(cmd *cobra.Command) error { return fmt.Errorf("unable to get cidr flag: %w", err) } - if err := netutils.ValidateCIDR(cidr, 16, true); err != nil { + if err := newconfig.ValidateCIDR(cidr); err != nil { return err } return nil } -type CIDRConfig struct { - PodCIDR string - ServiceCIDR string - GlobalCIDR *string -} - // getCIDRConfig determines, based on the command line flags, // what are the pod and service CIDRs to be used for the cluster. If either // of --pod-cidr or --service-cidr have been set, they are used. Otherwise, // the cidr flag is split into pod and service CIDRs. -func getCIDRConfig(cmd *cobra.Command) (*CIDRConfig, error) { +func getCIDRConfig(cmd *cobra.Command) (*newconfig.CIDRConfig, error) { if cmd.Flags().Changed("pod-cidr") || cmd.Flags().Changed("service-cidr") { podCIDR, err := cmd.Flags().GetString("pod-cidr") if err != nil { @@ -61,7 +55,7 @@ func getCIDRConfig(cmd *cobra.Command) (*CIDRConfig, error) { if err != nil { return nil, fmt.Errorf("unable to get service-cidr flag: %w", err) } - return &CIDRConfig{ + return &newconfig.CIDRConfig{ PodCIDR: podCIDR, ServiceCIDR: serviceCIDR, }, nil @@ -71,11 +65,11 @@ func getCIDRConfig(cmd *cobra.Command) (*CIDRConfig, error) { if err != nil { return nil, fmt.Errorf("unable to get cidr flag: %w", err) } - podCIDR, serviceCIDR, err := netutils.SplitNetworkCIDR(globalCIDR) + podCIDR, serviceCIDR, err := newconfig.SplitCIDR(globalCIDR) if err != nil { return nil, fmt.Errorf("unable to split cidr flag: %w", err) } - return &CIDRConfig{ + return &newconfig.CIDRConfig{ PodCIDR: podCIDR, ServiceCIDR: serviceCIDR, GlobalCIDR: &globalCIDR, diff --git a/cmd/installer/cli/cidr_test.go b/cmd/installer/cli/cidr_test.go index f0d5d9a89..aba77aa76 100644 --- a/cmd/installer/cli/cidr_test.go +++ b/cmd/installer/cli/cidr_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/k0sproject/k0s/pkg/apis/k0s/v1beta1" + newconfig "github.com/replicatedhq/embedded-cluster/pkg-new/config" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/stretchr/testify/require" @@ -14,11 +15,11 @@ func Test_getCIDRConfig(t *testing.T) { tests := []struct { name string setFlags func(flagSet *pflag.FlagSet) - expected *CIDRConfig + expected *newconfig.CIDRConfig }{ { name: "with pod and service flags", - expected: &CIDRConfig{ + expected: &newconfig.CIDRConfig{ PodCIDR: "10.0.0.0/24", ServiceCIDR: "10.1.0.0/24", GlobalCIDR: nil, @@ -30,7 +31,7 @@ func Test_getCIDRConfig(t *testing.T) { }, { name: "with pod flag", - expected: &CIDRConfig{ + expected: &newconfig.CIDRConfig{ PodCIDR: "10.0.0.0/24", ServiceCIDR: v1beta1.DefaultNetwork().ServiceCIDR, GlobalCIDR: nil, @@ -41,7 +42,7 @@ func Test_getCIDRConfig(t *testing.T) { }, { name: "with pod, service and cidr flags", - expected: &CIDRConfig{ + expected: &newconfig.CIDRConfig{ PodCIDR: "10.0.0.0/24", ServiceCIDR: "10.1.0.0/24", GlobalCIDR: nil, @@ -54,7 +55,7 @@ func Test_getCIDRConfig(t *testing.T) { }, { name: "with pod and cidr flags", - expected: &CIDRConfig{ + expected: &newconfig.CIDRConfig{ PodCIDR: "10.0.0.0/24", ServiceCIDR: v1beta1.DefaultNetwork().ServiceCIDR, GlobalCIDR: nil, @@ -66,7 +67,7 @@ func Test_getCIDRConfig(t *testing.T) { }, { name: "with cidr flag", - expected: &CIDRConfig{ + expected: &newconfig.CIDRConfig{ PodCIDR: "10.2.0.0/25", ServiceCIDR: "10.2.0.128/25", GlobalCIDR: ptr.To("10.2.0.0/24"), diff --git a/cmd/installer/cli/install.go b/cmd/installer/cli/install.go index dc3e667a2..238c510a7 100644 --- a/cmd/installer/cli/install.go +++ b/cmd/installer/cli/install.go @@ -2,9 +2,13 @@ package cli import ( "context" + "crypto/tls" "encoding/json" "errors" "fmt" + "log" + "net" + "net/http" "os" "path/filepath" "runtime" @@ -14,12 +18,18 @@ import ( "time" "github.com/AlecAivazis/survey/v2/terminal" + "github.com/gorilla/mux" "github.com/gosimple/slug" k0sv1beta1 "github.com/k0sproject/k0s/pkg/apis/k0s/v1beta1" + "github.com/replicatedhq/embedded-cluster/api" + apiclient "github.com/replicatedhq/embedded-cluster/api/client" + apitypes "github.com/replicatedhq/embedded-cluster/api/types" "github.com/replicatedhq/embedded-cluster/cmd/installer/goods" "github.com/replicatedhq/embedded-cluster/cmd/installer/kotscli" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/kinds/types" + newconfig "github.com/replicatedhq/embedded-cluster/pkg-new/config" + "github.com/replicatedhq/embedded-cluster/pkg-new/tlsutils" "github.com/replicatedhq/embedded-cluster/pkg/addons" "github.com/replicatedhq/embedded-cluster/pkg/addons/adminconsole" "github.com/replicatedhq/embedded-cluster/pkg/addons/embeddedclusteroperator" @@ -44,6 +54,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/spinner" "github.com/replicatedhq/embedded-cluster/pkg/support" "github.com/replicatedhq/embedded-cluster/pkg/versions" + "github.com/replicatedhq/embedded-cluster/web" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/sirupsen/logrus" "github.com/spf13/cobra" @@ -68,12 +79,18 @@ type InstallCmdFlags struct { skipHostPreflights bool ignoreHostPreflights bool configValues string - - networkInterface string + networkInterface string license *kotsv1beta1.License proxy *ecv1beta1.ProxySpec - cidrCfg *CIDRConfig + cidrCfg *newconfig.CIDRConfig + + // guided UI flags + managerPort int + guidedUI bool + certFile string + keyFile string + hostname string } // InstallCmd returns a cobra command for installing the embedded cluster. @@ -119,6 +136,15 @@ func InstallCmd(ctx context.Context, name string) *cobra.Command { return err } metricsReporter.ReportInstallationSucceeded(ctx) + + // If in guided UI mode, keep the process running until interrupted + if flags.guidedUI { + logrus.Info("") + logrus.Info("Installation complete. Press Ctrl+C to exit.") + logrus.Info("") + <-ctx.Done() + } + return nil }, } @@ -129,6 +155,9 @@ func InstallCmd(ctx context.Context, name string) *cobra.Command { if err := addInstallAdminConsoleFlags(cmd, &flags); err != nil { panic(err) } + if err := addGuidedUIFlags(cmd, &flags); err != nil { + panic(err) + } cmd.AddCommand(InstallRunPreflightsCmd(ctx, name)) @@ -181,40 +210,41 @@ func addInstallAdminConsoleFlags(cmd *cobra.Command, flags *InstallCmdFlags) err return nil } -func preRunInstall(cmd *cobra.Command, flags *InstallCmdFlags) error { - if os.Getuid() != 0 { - return fmt.Errorf("install command must be run as root") - } - - // set the umask to 022 so that we can create files/directories with 755 permissions - // this does not return an error - it returns the previous umask - _ = syscall.Umask(0o022) +func addGuidedUIFlags(cmd *cobra.Command, flags *InstallCmdFlags) error { + cmd.Flags().BoolVarP(&flags.guidedUI, "guided-ui", "g", false, "Run the installation in guided UI mode.") + cmd.Flags().IntVar(&flags.managerPort, "manager-port", ecv1beta1.DefaultManagerPort, "Port on which the Manager will be served") + cmd.Flags().StringVar(&flags.certFile, "cert-file", "", "Path to the TLS certificate file") + cmd.Flags().StringVar(&flags.keyFile, "key-file", "", "Path to the TLS key file") + cmd.Flags().StringVar(&flags.hostname, "hostname", "", "Hostname to use for TLS configuration") - p, err := parseProxyFlags(cmd) - if err != nil { + if err := cmd.Flags().MarkHidden("guided-ui"); err != nil { return err } - flags.proxy = p - - if err := validateCIDRFlags(cmd); err != nil { + if err := cmd.Flags().MarkHidden("manager-port"); err != nil { return err } - - // parse the various cidr flags to make sure we have exactly what we want - cidrCfg, err := getCIDRConfig(cmd) - if err != nil { - return fmt.Errorf("unable to determine pod and service CIDRs: %w", err) + if err := cmd.Flags().MarkHidden("cert-file"); err != nil { + return err + } + if err := cmd.Flags().MarkHidden("key-file"); err != nil { + return err + } + if err := cmd.Flags().MarkHidden("hostname"); err != nil { + return err } - flags.cidrCfg = cidrCfg - // if a network interface flag was not provided, attempt to discover it - if flags.networkInterface == "" { - autoInterface, err := determineBestNetworkInterface() - if err == nil { - flags.networkInterface = autoInterface - } + return nil +} + +func preRunInstall(cmd *cobra.Command, flags *InstallCmdFlags) error { + if os.Getuid() != 0 { + return fmt.Errorf("install command must be run as root") } + // set the umask to 022 so that we can create files/directories with 755 permissions + // this does not return an error - it returns the previous umask + _ = syscall.Umask(0o022) + // license file can be empty for restore if flags.licenseFile != "" { // validate the the license is indeed a license file @@ -238,7 +268,112 @@ func preRunInstall(cmd *cobra.Command, flags *InstallCmdFlags) error { flags.isAirgap = flags.airgapBundle != "" - runtimeconfig.ApplyFlags(cmd.Flags()) + // restore command doesn't have a password flag + if cmd.Flags().Lookup("admin-console-password") != nil { + if err := ensureAdminConsolePassword(flags); err != nil { + return err + } + } + + if flags.guidedUI { + configChan := make(chan *apitypes.InstallationConfig) + defer close(configChan) + + // this is necessary because the api listens on all interfaces, + // and we only know the interface to use when the user selects it in the ui + ipAddresses, err := netutils.ListAllValidIPAddresses() + if err != nil { + return fmt.Errorf("unable to list all valid IP addresses: %w", err) + } + + cert, err := tlsutils.GetCertificate(tlsutils.Config{ + CertFile: flags.certFile, + KeyFile: flags.keyFile, + Hostname: flags.hostname, + IPAddresses: ipAddresses, + }) + if err != nil { + return fmt.Errorf("get tls certificate: %w", err) + } + + if err := preRunInstallAPI(cmd.Context(), cert, flags.adminConsolePassword, flags.managerPort, configChan); err != nil { + return fmt.Errorf("unable to start install API: %w", err) + } + + // TODO: fix this message + logrus.Info("") + logrus.Infof("Visit %s to configure your cluster", getManagerURL(flags.hostname, flags.managerPort)) + + installConfig, ok := <-configChan + if !ok { + return fmt.Errorf("install API closed channel") + } + + proxy, err := newconfig.GetProxySpec( + installConfig.HTTPProxy, + installConfig.HTTPSProxy, + installConfig.NoProxy, + installConfig.PodCIDR, + installConfig.ServiceCIDR, + installConfig.NetworkInterface, + nil, + ) + if err != nil { + return fmt.Errorf("unable to get proxy spec: %w", err) + } + flags.proxy = proxy + + flags.cidrCfg = &newconfig.CIDRConfig{ + PodCIDR: installConfig.PodCIDR, + ServiceCIDR: installConfig.ServiceCIDR, + } + if installConfig.GlobalCIDR != "" { + flags.cidrCfg.GlobalCIDR = &installConfig.GlobalCIDR + } + + flags.networkInterface = installConfig.NetworkInterface + flags.adminConsolePort = installConfig.AdminConsolePort + flags.dataDir = installConfig.DataDirectory + flags.localArtifactMirrorPort = installConfig.LocalArtifactMirrorPort + + } else { + proxy, err := parseProxyFlags(cmd) + if err != nil { + return err + } + flags.proxy = proxy + + if err := validateCIDRFlags(cmd); err != nil { + return err + } + + // parse the various cidr flags to make sure we have exactly what we want + cidrCfg, err := getCIDRConfig(cmd) + if err != nil { + return fmt.Errorf("unable to determine pod and service CIDRs: %w", err) + } + flags.cidrCfg = cidrCfg + + // if a network interface flag was not provided, attempt to discover it + if flags.networkInterface == "" { + autoInterface, err := newconfig.DetermineBestNetworkInterface() + if err == nil { + flags.networkInterface = autoInterface + } + } + + if flags.localArtifactMirrorPort != 0 && flags.adminConsolePort != 0 { + if flags.localArtifactMirrorPort == flags.adminConsolePort { + return fmt.Errorf("local artifact mirror port cannot be the same as admin console port") + } + } + } + + // TODO: validate that a single port isn't used for multiple services + runtimeconfig.SetDataDir(flags.dataDir) + runtimeconfig.SetManagerPort(flags.managerPort) + runtimeconfig.SetLocalArtifactMirrorPort(flags.localArtifactMirrorPort) + runtimeconfig.SetAdminConsolePort(flags.adminConsolePort) os.Setenv("KUBECONFIG", runtimeconfig.PathToKubeConfig()) // this is needed for restore as well since it shares this function os.Setenv("TMPDIR", runtimeconfig.EmbeddedClusterTmpSubDir()) @@ -263,12 +398,102 @@ func preRunInstall(cmd *cobra.Command, flags *InstallCmdFlags) error { return nil } -func runInstall(ctx context.Context, name string, flags InstallCmdFlags, metricsReporter preflights.MetricsReporter) error { - if err := runInstallVerifyAndPrompt(ctx, name, &flags); err != nil { - return err +func preRunInstallAPI(ctx context.Context, cert tls.Certificate, password string, managerPort int, configChan chan<- *apitypes.InstallationConfig) error { + logger, err := api.NewLogger() + if err != nil { + logrus.Warnf("Unable to setup API logging: %v", err) + } + + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", managerPort)) + if err != nil { + return fmt.Errorf("unable to create listener: %w", err) + } + + go func() { + if err := runInstallAPI(ctx, listener, cert, logger, password, configChan); err != nil { + if !errors.Is(err, http.ErrServerClosed) { + logrus.Errorf("install API error: %v", err) + } + } + }() + + if err := waitForInstallAPI(ctx, listener.Addr().String()); err != nil { + return fmt.Errorf("unable to wait for install API: %w", err) + } + + return nil +} + +func runInstallAPI(ctx context.Context, listener net.Listener, cert tls.Certificate, logger logrus.FieldLogger, password string, configChan chan<- *apitypes.InstallationConfig) error { + router := mux.NewRouter() + + api, err := api.New( + password, + api.WithLogger(logger), + api.WithConfigChan(configChan), + ) + if err != nil { + return fmt.Errorf("new api: %w", err) + } + + api.RegisterRoutes(router.PathPrefix("/api").Subrouter()) + + var webFs http.Handler + if os.Getenv("EC_DEV_ENV") == "true" { + webFs = http.FileServer(http.FS(os.DirFS("./web/dist"))) + } else { + webFs = http.FileServer(http.FS(web.Fs())) + } + router.PathPrefix("/").Methods("GET").Handler(webFs) + + server := &http.Server{ + // ErrorLog outputs TLS errors and warnings to the console, we want to make sure we use the same logrus logger for them + ErrorLog: log.New(logger.WithField("http-server", "std-log").Writer(), "", 0), + Handler: router, + TLSConfig: tlsutils.GetTLSConfig(cert), + } + + go func() { + <-ctx.Done() + logrus.Debugf("Shutting down install API") + server.Shutdown(context.Background()) + }() + + return server.ServeTLS(listener, "", "") +} + +func waitForInstallAPI(ctx context.Context, addr string) error { + httpClient := http.Client{ + Timeout: 2 * time.Second, + Transport: &http.Transport{ + Proxy: nil, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + } + timeout := time.After(10 * time.Second) + var lastErr error + for { + select { + case <-timeout: + if lastErr != nil { + return fmt.Errorf("install API did not start in time: %w", lastErr) + } + return fmt.Errorf("install API did not start in time") + case <-time.Tick(1 * time.Second): + resp, err := httpClient.Get(fmt.Sprintf("https://%s/api/health", addr)) + if err != nil { + lastErr = fmt.Errorf("unable to connect to install API: %w", err) + } else if resp.StatusCode == http.StatusOK { + return nil + } + } } +} - if err := ensureAdminConsolePassword(&flags); err != nil { +func runInstall(ctx context.Context, name string, flags InstallCmdFlags, metricsReporter preflights.MetricsReporter) error { + if err := runInstallVerifyAndPrompt(ctx, name, &flags); err != nil { return err } @@ -388,13 +613,35 @@ func runInstall(ctx context.Context, name string, flags InstallCmdFlags, metrics logrus.Warnf("Unable to create host support bundle: %v", err) } - if err := printSuccessMessage(flags.license, flags.networkInterface); err != nil { - return err + if flags.guidedUI { + if err := markUIInstallComplete(flags.adminConsolePassword, flags.managerPort); err != nil { + return fmt.Errorf("unable to mark ui install complete: %w", err) + } + } else { + if err := printSuccessMessage(flags.license, flags.hostname, flags.networkInterface); err != nil { + return err + } } return nil } +func markUIInstallComplete(password string, managerPort int) error { + apiClient := apiclient.New(fmt.Sprintf("http://localhost:%d", managerPort)) + if err := apiClient.Login(password); err != nil { + return fmt.Errorf("unable to login: %w", err) + } + _, err := apiClient.SetInstallStatus(apitypes.InstallationStatus{ + State: apitypes.InstallationStateSucceeded, + Description: "Install Complete", + LastUpdated: time.Now(), + }) + if err != nil { + return fmt.Errorf("unable to set install status: %w", err) + } + return nil +} + func runInstallVerifyAndPrompt(ctx context.Context, name string, flags *InstallCmdFlags) error { logrus.Debugf("checking if k0s is already installed") err := verifyNoInstallation(name, "reinstall") @@ -651,7 +898,7 @@ func materializeFiles(airgapBundle string) error { return nil } -func installAndStartCluster(ctx context.Context, networkInterface string, airgapBundle string, proxy *ecv1beta1.ProxySpec, cidrCfg *CIDRConfig, overrides string, mutate func(*k0sv1beta1.ClusterConfig) error) (*k0sv1beta1.ClusterConfig, error) { +func installAndStartCluster(ctx context.Context, networkInterface string, airgapBundle string, proxy *ecv1beta1.ProxySpec, cidrCfg *newconfig.CIDRConfig, overrides string, mutate func(*k0sv1beta1.ClusterConfig) error) (*k0sv1beta1.ClusterConfig, error) { loading := spinner.Start() loading.Infof("Installing node") logrus.Debugf("creating k0s configuration file") @@ -1277,8 +1524,8 @@ func copyLicenseFileToDataDir(licenseFile, dataDir string) error { return nil } -func printSuccessMessage(license *kotsv1beta1.License, networkInterface string) error { - adminConsoleURL := getAdminConsoleURL(networkInterface, runtimeconfig.AdminConsolePort()) +func printSuccessMessage(license *kotsv1beta1.License, hostname string, networkInterface string) error { + adminConsoleURL := getAdminConsoleURL(hostname, networkInterface, runtimeconfig.AdminConsolePort()) // Create the message content message := fmt.Sprintf("Visit the Admin Console to configure and install %s:", license.Spec.AppSlug) @@ -1308,14 +1555,37 @@ func printSuccessMessage(license *kotsv1beta1.License, networkInterface string) return nil } -func getAdminConsoleURL(networkInterface string, port int) string { +func getManagerURL(hostname string, port int) string { + if hostname != "" { + return fmt.Sprintf("https://%s:%v", hostname, port) + } + ipaddr := runtimeconfig.TryDiscoverPublicIP() + if ipaddr == "" { + if addr := os.Getenv("EC_PUBLIC_ADDRESS"); addr != "" { + ipaddr = addr + } else { + logrus.Errorf("Unable to determine node IP address") + ipaddr = "NODE-IP-ADDRESS" + } + } + return fmt.Sprintf("https://%s:%v", ipaddr, port) +} + +func getAdminConsoleURL(hostname string, networkInterface string, port int) string { + if hostname != "" { + return fmt.Sprintf("http://%s:%v", hostname, port) + } ipaddr := runtimeconfig.TryDiscoverPublicIP() if ipaddr == "" { var err error ipaddr, err = netutils.FirstValidAddress(networkInterface) if err != nil { - logrus.Errorf("Unable to determine node IP address: %v", err) - ipaddr = "NODE-IP-ADDRESS" + if addr := os.Getenv("EC_PUBLIC_ADDRESS"); addr != "" { + ipaddr = addr + } else { + logrus.Errorf("Unable to determine node IP address: %v", err) + ipaddr = "NODE-IP-ADDRESS" + } } } return fmt.Sprintf("http://%s:%v", ipaddr, port) diff --git a/cmd/installer/cli/install_test.go b/cmd/installer/cli/install_test.go index b82df3253..9d291d46f 100644 --- a/cmd/installer/cli/install_test.go +++ b/cmd/installer/cli/install_test.go @@ -3,17 +3,23 @@ package cli import ( "bytes" "context" + "crypto/tls" "fmt" + "net" "net/http" "net/http/httptest" "os" "path/filepath" "testing" + "time" + "github.com/replicatedhq/embedded-cluster/api" + "github.com/replicatedhq/embedded-cluster/pkg-new/tlsutils" "github.com/replicatedhq/embedded-cluster/pkg/prompts" "github.com/replicatedhq/embedded-cluster/pkg/prompts/plain" "github.com/replicatedhq/embedded-cluster/pkg/release" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -527,3 +533,55 @@ versionLabel: testversion }) } } + +func Test_runInstallAPI(t *testing.T) { + logrus.SetLevel(logrus.DebugLevel) + + listener, err := net.Listen("tcp", ":0") + require.NoError(t, err) + t.Cleanup(func() { + _ = listener.Close() + }) + + ctx, cancel := context.WithCancel(t.Context()) + t.Cleanup(cancel) + + errCh := make(chan error) + + logger := api.NewDiscardLogger() + + cert, err := tlsutils.GetCertificate(tlsutils.Config{}) + require.NoError(t, err) + + go func() { + err := runInstallAPI(ctx, listener, cert, logger, "password", nil) + t.Logf("Install API exited with error: %v", err) + errCh <- err + }() + + t.Logf("Waiting for install API to start on %s", listener.Addr().String()) + err = waitForInstallAPI(ctx, listener.Addr().String()) + assert.NoError(t, err) + + url := "https://" + listener.Addr().String() + "/api/health" + t.Logf("Making request to %s", url) + httpClient := http.Client{ + Timeout: 2 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + } + resp, err := httpClient.Get(url) + assert.NoError(t, err) + if resp != nil { + defer resp.Body.Close() + } + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + cancel() + assert.ErrorIs(t, <-errCh, http.ErrServerClosed) + t.Logf("Install API exited") +} diff --git a/cmd/installer/cli/join.go b/cmd/installer/cli/join.go index 815533f51..616728b4b 100644 --- a/cmd/installer/cli/join.go +++ b/cmd/installer/cli/join.go @@ -13,6 +13,7 @@ import ( "github.com/replicatedhq/embedded-cluster/cmd/installer/goods" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/kinds/types/join" + newconfig "github.com/replicatedhq/embedded-cluster/pkg-new/config" "github.com/replicatedhq/embedded-cluster/pkg/addons" "github.com/replicatedhq/embedded-cluster/pkg/airgap" "github.com/replicatedhq/embedded-cluster/pkg/config" @@ -114,7 +115,7 @@ func preRunJoin(flags *JoinCmdFlags) error { // if a network interface flag was not provided, attempt to discover it if flags.networkInterface == "" { - autoInterface, err := determineBestNetworkInterface() + autoInterface, err := newconfig.DetermineBestNetworkInterface() if err == nil { flags.networkInterface = autoInterface } @@ -253,9 +254,9 @@ func runJoinVerifyAndPrompt(name string, flags JoinCmdFlags, jcmd *join.JoinComm return fmt.Errorf("embedded cluster version mismatch - this binary is version %q, but the cluster is running version %q", versions.Version, jcmd.EmbeddedClusterVersion) } - setProxyEnv(jcmd.InstallationSpec.Proxy) + newconfig.SetProxyEnv(jcmd.InstallationSpec.Proxy) - proxyOK, localIP, err := checkProxyConfigForLocalIP(jcmd.InstallationSpec.Proxy, flags.networkInterface) + proxyOK, localIP, err := newconfig.CheckProxyConfigForLocalIP(jcmd.InstallationSpec.Proxy, flags.networkInterface, nil) if err != nil { return fmt.Errorf("failed to check proxy config for local IP: %w", err) } @@ -270,7 +271,7 @@ func runJoinVerifyAndPrompt(name string, flags JoinCmdFlags, jcmd *join.JoinComm return nil } -func initializeJoin(ctx context.Context, name string, jcmd *join.JoinCommandResponse, kotsAPIAddress string) (cidrCfg *CIDRConfig, err error) { +func initializeJoin(ctx context.Context, name string, jcmd *join.JoinCommandResponse, kotsAPIAddress string) (cidrCfg *newconfig.CIDRConfig, err error) { logrus.Info("") spinner := spinner.Start() spinner.Infof("Initializing") @@ -343,7 +344,7 @@ func materializeFilesForJoin(ctx context.Context, jcmd *join.JoinCommandResponse return nil } -func getJoinCIDRConfig(jcmd *join.JoinCommandResponse) (*CIDRConfig, error) { +func getJoinCIDRConfig(jcmd *join.JoinCommandResponse) (*newconfig.CIDRConfig, error) { podCIDR, serviceCIDR, err := netutils.SplitNetworkCIDR(ecv1beta1.DefaultNetworkCIDR) if err != nil { return nil, fmt.Errorf("unable to split default network CIDR: %w", err) @@ -358,7 +359,7 @@ func getJoinCIDRConfig(jcmd *join.JoinCommandResponse) (*CIDRConfig, error) { } } - return &CIDRConfig{ + return &newconfig.CIDRConfig{ PodCIDR: podCIDR, ServiceCIDR: serviceCIDR, }, nil diff --git a/cmd/installer/cli/join_runpreflights.go b/cmd/installer/cli/join_runpreflights.go index 4bf271f47..0cbe1bbf4 100644 --- a/cmd/installer/cli/join_runpreflights.go +++ b/cmd/installer/cli/join_runpreflights.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/replicatedhq/embedded-cluster/kinds/types/join" + newconfig "github.com/replicatedhq/embedded-cluster/pkg-new/config" "github.com/replicatedhq/embedded-cluster/pkg/configutils" "github.com/replicatedhq/embedded-cluster/pkg/kotsadm" "github.com/replicatedhq/embedded-cluster/pkg/netutil" @@ -92,7 +93,7 @@ func runJoinRunPreflights(ctx context.Context, name string, flags JoinCmdFlags, return nil } -func runJoinPreflights(ctx context.Context, jcmd *join.JoinCommandResponse, flags JoinCmdFlags, cidrCfg *CIDRConfig, metricsReported preflights.MetricsReporter) error { +func runJoinPreflights(ctx context.Context, jcmd *join.JoinCommandResponse, flags JoinCmdFlags, cidrCfg *newconfig.CIDRConfig, metricsReported preflights.MetricsReporter) error { nodeIP, err := netutils.FirstValidAddress(flags.networkInterface) if err != nil { return fmt.Errorf("unable to find first valid address: %w", err) diff --git a/cmd/installer/cli/materialize.go b/cmd/installer/cli/materialize.go index 1ec110616..fb4796eff 100644 --- a/cmd/installer/cli/materialize.go +++ b/cmd/installer/cli/materialize.go @@ -25,7 +25,7 @@ func MaterializeCmd(ctx context.Context, name string) *cobra.Command { return fmt.Errorf("materialize command must be run as root") } - runtimeconfig.ApplyFlags(cmd.Flags()) + runtimeconfig.SetDataDir(dataDir) os.Setenv("TMPDIR", runtimeconfig.EmbeddedClusterTmpSubDir()) return nil diff --git a/cmd/installer/cli/proxy.go b/cmd/installer/cli/proxy.go index 94714d061..f069741d0 100644 --- a/cmd/installer/cli/proxy.go +++ b/cmd/installer/cli/proxy.go @@ -3,13 +3,10 @@ package cli import ( "fmt" "net" - "os" - "strings" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + newconfig "github.com/replicatedhq/embedded-cluster/pkg-new/config" "github.com/replicatedhq/embedded-cluster/pkg/netutils" - "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" - "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) @@ -26,13 +23,6 @@ func (d *defaultNetworkLookup) FirstValidIPNet(networkInterface string) (*net.IP var defaultNetworkLookupImpl NetworkLookup = &defaultNetworkLookup{} -func getNetworkIPNet(networkInterface string, lookup NetworkLookup) (*net.IPNet, error) { - if lookup == nil { - lookup = defaultNetworkLookupImpl - } - return lookup.FirstValidIPNet(networkInterface) -} - func addProxyFlags(cmd *cobra.Command) error { cmd.Flags().String("http-proxy", "", "HTTP proxy to use for the installation (overrides http_proxy/HTTP_PROXY environment variables)") cmd.Flags().String("https-proxy", "", "HTTPS proxy to use for the installation (overrides https_proxy/HTTPS_PROXY environment variables)") @@ -46,14 +36,12 @@ func parseProxyFlags(cmd *cobra.Command) (*ecv1beta1.ProxySpec, error) { if err != nil { return nil, fmt.Errorf("unable to get proxy spec from flags: %w", err) } - setProxyEnv(p) + newconfig.SetProxyEnv(p) return p, nil } func getProxySpec(cmd *cobra.Command) (*ecv1beta1.ProxySpec, error) { - proxy := &ecv1beta1.ProxySpec{} - // Command-line flags have the highest precedence httpProxy, err := cmd.Flags().GetString("http-proxy") if err != nil { @@ -67,158 +55,17 @@ func getProxySpec(cmd *cobra.Command) (*ecv1beta1.ProxySpec, error) { if err != nil { return nil, fmt.Errorf("unable to get no-proxy flag: %w", err) } - - // If flags aren't set, look for environment variables (lowercase takes precedence) - if httpProxy == "" { - if envValue := os.Getenv("http_proxy"); envValue != "" { - logrus.Debug("got http_proxy from http_proxy env var") - httpProxy = envValue - } else if envValue := os.Getenv("HTTP_PROXY"); envValue != "" { - logrus.Debug("got http_proxy from HTTP_PROXY env var") - httpProxy = envValue - } - } - - if httpsProxy == "" { - if envValue := os.Getenv("https_proxy"); envValue != "" { - logrus.Debug("got https_proxy from https_proxy env var") - httpsProxy = envValue - } else if envValue := os.Getenv("HTTPS_PROXY"); envValue != "" { - logrus.Debug("got https_proxy from HTTPS_PROXY env var") - httpsProxy = envValue - } - } - - if noProxy == "" { - if envValue := os.Getenv("no_proxy"); envValue != "" { - logrus.Debug("got no_proxy from no_proxy env var") - noProxy = envValue - } else if envValue := os.Getenv("NO_PROXY"); envValue != "" { - logrus.Debug("got no_proxy from NO_PROXY env var") - noProxy = envValue - } - } - - // Set the values on the proxy object - proxy.HTTPProxy = httpProxy - proxy.HTTPSProxy = httpsProxy - proxy.ProvidedNoProxy = noProxy - - // Now that we have all no-proxy entries (from flags/env), merge in defaults - if err := combineNoProxySuppliedValuesAndDefaults(cmd, proxy, nil); err != nil { - return nil, fmt.Errorf("unable to combine no-proxy supplied values and defaults: %w", err) - } - - if proxy.HTTPProxy == "" && proxy.HTTPSProxy == "" && proxy.NoProxy == "" { - return nil, nil - } - return proxy, nil -} - -func combineNoProxySuppliedValuesAndDefaults(cmd *cobra.Command, proxy *ecv1beta1.ProxySpec, lookup NetworkLookup) error { - if proxy.ProvidedNoProxy == "" && proxy.HTTPProxy == "" && proxy.HTTPSProxy == "" { - return nil + networkInterface, err := cmd.Flags().GetString("network-interface") + if err != nil { + return nil, fmt.Errorf("unable to get network-interface flag: %w", err) } - - // Start with runtime defaults - noProxy := runtimeconfig.DefaultNoProxy - - // Add pod and service CIDRs cidrCfg, err := getCIDRConfig(cmd) if err != nil { - return fmt.Errorf("unable to determine pod and service CIDRs: %w", err) - } - noProxy = append(noProxy, cidrCfg.PodCIDR, cidrCfg.ServiceCIDR) - - // Add user-provided no-proxy values - if proxy.ProvidedNoProxy != "" { - noProxy = append(noProxy, strings.Split(proxy.ProvidedNoProxy, ",")...) - } - - // If we have a proxy set, ensure the local IP is in the no-proxy list - if proxy.HTTPProxy != "" || proxy.HTTPSProxy != "" { - networkInterfaceFlag, err := cmd.Flags().GetString("network-interface") - if err != nil { - return fmt.Errorf("unable to get network-interface flag: %w", err) - } - - ipnet, err := getNetworkIPNet(networkInterfaceFlag, lookup) - if err != nil { - return fmt.Errorf("failed to get first valid ip net: %w", err) - } - cleanIPNet, err := cleanCIDR(ipnet) - if err != nil { - return fmt.Errorf("failed to clean subnet: %w", err) - } - - // Check if the local IP is already covered by any of the no-proxy entries - isValid, err := validateNoProxy(strings.Join(noProxy, ","), ipnet.IP.String()) - if err != nil { - return fmt.Errorf("failed to validate no-proxy: %w", err) - } else if !isValid { - logrus.Infof("The node IP (%q) is not included in the no-proxy list. Adding the network interface's subnet (%q).", ipnet.IP.String(), cleanIPNet) - noProxy = append(noProxy, cleanIPNet) - } - } - - proxy.NoProxy = strings.Join(noProxy, ",") - return nil -} - -// setProxyEnv sets the HTTP_PROXY, HTTPS_PROXY, and NO_PROXY environment variables based on the provided ProxySpec. -// If the provided ProxySpec is nil, this environment variables are not set. -func setProxyEnv(proxy *ecv1beta1.ProxySpec) { - if proxy == nil { - return - } - if proxy.HTTPProxy != "" { - os.Setenv("HTTP_PROXY", proxy.HTTPProxy) - } - if proxy.HTTPSProxy != "" { - os.Setenv("HTTPS_PROXY", proxy.HTTPSProxy) - } - if proxy.NoProxy != "" { - os.Setenv("NO_PROXY", proxy.NoProxy) - } -} - -func validateNoProxy(newNoProxy string, localIP string) (bool, error) { - foundLocal := false - localIPParsed := net.ParseIP(localIP) - if localIPParsed == nil { - return false, fmt.Errorf("failed to parse local IP %q", localIP) - } - - for _, oneEntry := range strings.Split(newNoProxy, ",") { - if oneEntry == localIP { - foundLocal = true - } else if strings.Contains(oneEntry, "/") { - _, ipnet, err := net.ParseCIDR(oneEntry) - if err != nil { - return false, fmt.Errorf("failed to parse CIDR within no-proxy: %w", err) - } - if ipnet.Contains(localIPParsed) { - foundLocal = true - } - } - } - - return foundLocal, nil -} - -func checkProxyConfigForLocalIP(proxy *ecv1beta1.ProxySpec, networkInterface string) (bool, string, error) { - if proxy == nil { - return true, "", nil // no proxy is fine - } - if proxy.HTTPProxy == "" && proxy.HTTPSProxy == "" { - return true, "", nil // no proxy is fine + return nil, fmt.Errorf("unable to determine pod and service CIDRs: %w", err) } - - ipnet, err := netutils.FirstValidIPNet(networkInterface) + proxy, err := newconfig.GetProxySpec(httpProxy, httpsProxy, noProxy, cidrCfg.PodCIDR, cidrCfg.ServiceCIDR, networkInterface, defaultNetworkLookupImpl) if err != nil { - return false, "", fmt.Errorf("failed to get default IPNet: %w", err) + return nil, fmt.Errorf("unable to get proxy spec: %w", err) } - - ok, err := validateNoProxy(proxy.NoProxy, ipnet.IP.String()) - return ok, ipnet.IP.String(), err + return proxy, nil } diff --git a/cmd/installer/cli/restore.go b/cmd/installer/cli/restore.go index f17d65fd6..6491db6cd 100644 --- a/cmd/installer/cli/restore.go +++ b/cmd/installer/cli/restore.go @@ -1579,7 +1579,7 @@ func waitForAdditionalNodes(ctx context.Context, highAvailability bool, networkI return fmt.Errorf("unable to create kube client: %w", err) } - adminConsoleURL := getAdminConsoleURL(networkInterface, runtimeconfig.AdminConsolePort()) + adminConsoleURL := getAdminConsoleURL("", networkInterface, runtimeconfig.AdminConsolePort()) successColor := "\033[32m" colorReset := "\033[0m" diff --git a/cmd/installer/cli/version_embeddeddata.go b/cmd/installer/cli/version_embeddeddata.go index a00abc023..8f29417e4 100644 --- a/cmd/installer/cli/version_embeddeddata.go +++ b/cmd/installer/cli/version_embeddeddata.go @@ -16,7 +16,11 @@ func VersionEmbeddedDataCmd(ctx context.Context, name string) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { // Application app := release.GetApplication() - fmt.Printf("Application:\n%s\n\n", string(app)) + appJson, err := json.MarshalIndent(app, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal application: %w", err) + } + fmt.Printf("Application:\n%s\n\n", string(appJson)) // Embedded Cluster Config cfg := release.GetEmbeddedClusterConfig() diff --git a/go.mod b/go.mod index fef6b0ddf..db1936282 100644 --- a/go.mod +++ b/go.mod @@ -20,8 +20,10 @@ require ( github.com/evanphx/json-patch v5.9.11+incompatible github.com/fatih/color v1.18.0 github.com/go-logr/logr v1.4.2 + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/go-github/v62 v62.0.0 github.com/google/uuid v1.6.0 + github.com/gorilla/mux v1.8.1 github.com/gosimple/slug v1.15.0 github.com/jedib0t/go-pretty/v6 v6.6.7 github.com/k0sproject/k0s v1.31.9-0.20250428141639-26a9908cf691 @@ -116,6 +118,7 @@ require ( github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 // indirect github.com/containerd/cgroups/v3 v3.0.5 // indirect github.com/containerd/containerd v1.7.27 // indirect + github.com/containerd/continuity v0.4.5 // indirect github.com/containerd/errdefs v0.3.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect @@ -177,7 +180,6 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/googleapis/gax-go/v2 v2.14.1 // indirect github.com/gorilla/handlers v1.5.2 // indirect - github.com/gorilla/mux v1.8.1 // indirect github.com/gosuri/uitable v0.0.4 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index 94236587f..e380de525 100644 --- a/go.sum +++ b/go.sum @@ -806,8 +806,8 @@ github.com/containerd/cgroups/v3 v3.0.5 h1:44na7Ud+VwyE7LIoJ8JTNQOa549a8543BmzaJ github.com/containerd/cgroups/v3 v3.0.5/go.mod h1:SA5DLYnXO8pTGYiAHXz94qvLQTKfVM5GEVisn4jpins= github.com/containerd/containerd v1.7.27 h1:yFyEyojddO3MIGVER2xJLWoCIn+Up4GaHFquP7hsFII= github.com/containerd/containerd v1.7.27/go.mod h1:xZmPnl75Vc+BLGt4MIfu6bp+fy03gdHAn9bz+FreFR0= -github.com/containerd/continuity v0.4.4 h1:/fNVfTJ7wIl/YPMHjf+5H32uFhl63JucB34PlCpMKII= -github.com/containerd/continuity v0.4.4/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= +github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= +github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v0.3.0 h1:FSZgGOeK4yuT/+DnF07/Olde/q4KBoMsaamhXxIMDp4= github.com/containerd/errdefs v0.3.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= diff --git a/kinds/apis/v1beta1/installation_types.go b/kinds/apis/v1beta1/installation_types.go index 077a87aee..dc28608d5 100644 --- a/kinds/apis/v1beta1/installation_types.go +++ b/kinds/apis/v1beta1/installation_types.go @@ -104,6 +104,12 @@ type LocalArtifactMirrorSpec struct { Port int `json:"port,omitempty"` } +// ManagerSpec holds the manager configuration. +type ManagerSpec struct { + // Port holds the port on which the manager will be served. + Port int `json:"port,omitempty"` +} + // LicenseInfo holds information about the license used to install the cluster. type LicenseInfo struct { IsDisasterRecoverySupported bool `json:"isDisasterRecoverySupported,omitempty"` diff --git a/kinds/apis/v1beta1/runtimeconfig_types.go b/kinds/apis/v1beta1/runtimeconfig_types.go index 397f01a66..faf82dce5 100644 --- a/kinds/apis/v1beta1/runtimeconfig_types.go +++ b/kinds/apis/v1beta1/runtimeconfig_types.go @@ -9,6 +9,7 @@ const ( DefaultAdminConsolePort = 30000 DefaultLocalArtifactMirrorPort = 50000 DefaultNetworkCIDR = "10.244.0.0/16" + DefaultManagerPort = 30080 ) // RuntimeConfigSpec defines the configuration for the Embedded Cluster at runtime. @@ -29,6 +30,8 @@ type RuntimeConfigSpec struct { AdminConsole AdminConsoleSpec `json:"adminConsole,omitempty"` // LocalArtifactMirrorPort holds the Local Artifact Mirror configuration. LocalArtifactMirror LocalArtifactMirrorSpec `json:"localArtifactMirror,omitempty"` + // Manager holds the Manager configuration. + Manager ManagerSpec `json:"manager,omitempty"` } func (c *RuntimeConfigSpec) UnmarshalJSON(data []byte) error { @@ -53,6 +56,7 @@ func runtimeConfigSetDefaults(c *RuntimeConfigSpec) { } adminConsoleSpecSetDefaults(&c.AdminConsole) localArtifactMirrorSpecSetDefaults(&c.LocalArtifactMirror) + managerSpecSetDefaults(&c.Manager) } func adminConsoleSpecSetDefaults(s *AdminConsoleSpec) { @@ -66,3 +70,9 @@ func localArtifactMirrorSpecSetDefaults(s *LocalArtifactMirrorSpec) { s.Port = DefaultLocalArtifactMirrorPort } } + +func managerSpecSetDefaults(s *ManagerSpec) { + if s.Port == 0 { + s.Port = DefaultManagerPort + } +} diff --git a/kinds/apis/v1beta1/zz_generated.deepcopy.go b/kinds/apis/v1beta1/zz_generated.deepcopy.go index 28c1467c8..25b497a37 100644 --- a/kinds/apis/v1beta1/zz_generated.deepcopy.go +++ b/kinds/apis/v1beta1/zz_generated.deepcopy.go @@ -467,6 +467,21 @@ func (in *LocalArtifactMirrorSpec) DeepCopy() *LocalArtifactMirrorSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ManagerSpec) DeepCopyInto(out *ManagerSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ManagerSpec. +func (in *ManagerSpec) DeepCopy() *ManagerSpec { + if in == nil { + return nil + } + out := new(ManagerSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NetworkSpec) DeepCopyInto(out *NetworkSpec) { *out = *in @@ -617,6 +632,7 @@ func (in *RuntimeConfigSpec) DeepCopyInto(out *RuntimeConfigSpec) { *out = *in out.AdminConsole = in.AdminConsole out.LocalArtifactMirror = in.LocalArtifactMirror + out.Manager = in.Manager } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimeConfigSpec. diff --git a/operator/charts/embedded-cluster-operator/charts/crds/templates/resources.yaml b/operator/charts/embedded-cluster-operator/charts/crds/templates/resources.yaml index eeeebbbae..b8aec7b64 100644 --- a/operator/charts/embedded-cluster-operator/charts/crds/templates/resources.yaml +++ b/operator/charts/embedded-cluster-operator/charts/crds/templates/resources.yaml @@ -631,6 +631,13 @@ spec: description: Port holds the port on which the local artifact mirror will be served. type: integer type: object + manager: + description: Manager holds the Manager configuration. + properties: + port: + description: Port holds the port on which the manager will be served. + type: integer + type: object openEBSDataDirOverride: description: |- OpenEBSDataDirOverride holds the override for the data directory for the OpenEBS storage diff --git a/operator/config/crd/bases/embeddedcluster.replicated.com_installations.yaml b/operator/config/crd/bases/embeddedcluster.replicated.com_installations.yaml index 762afe901..711eb679e 100644 --- a/operator/config/crd/bases/embeddedcluster.replicated.com_installations.yaml +++ b/operator/config/crd/bases/embeddedcluster.replicated.com_installations.yaml @@ -407,6 +407,14 @@ spec: mirror will be served. type: integer type: object + manager: + description: Manager holds the Manager configuration. + properties: + port: + description: Port holds the port on which the manager will + be served. + type: integer + type: object openEBSDataDirOverride: description: |- OpenEBSDataDirOverride holds the override for the data directory for the OpenEBS storage diff --git a/pkg-new/config/cidr.go b/pkg-new/config/cidr.go new file mode 100644 index 000000000..11a080524 --- /dev/null +++ b/pkg-new/config/cidr.go @@ -0,0 +1,41 @@ +package cli + +import ( + "fmt" + "net" + + "github.com/replicatedhq/embedded-cluster/pkg/netutils" +) + +func ValidateCIDR(cidr string) error { + if err := netutils.ValidateCIDR(cidr, 16, true); err != nil { + return fmt.Errorf("unable to validate cidr flag: %w", err) + } + return nil +} + +type CIDRConfig struct { + PodCIDR string + ServiceCIDR string + GlobalCIDR *string +} + +// SplitCIDR takes a CIDR string and splits it into pod and service CIDRs +// to be used for the cluster. It returns a CIDRConfig containing the split CIDRs +// and the original global CIDR. +func SplitCIDR(cidr string) (string, string, error) { + podCIDR, serviceCIDR, err := netutils.SplitNetworkCIDR(cidr) + if err != nil { + return "", "", fmt.Errorf("unable to split cidr flag: %w", err) + } + return podCIDR, serviceCIDR, nil +} + +// cleanCIDR returns a `.0/x` subnet instead of a `.2/x` etc subnet +func cleanCIDR(ipnet *net.IPNet) (string, error) { + _, newNet, err := net.ParseCIDR(ipnet.String()) + if err != nil { + return "", fmt.Errorf("failed to parse local inet CIDR %q: %w", ipnet.String(), err) + } + return newNet.String(), nil +} diff --git a/cmd/installer/cli/network_interface.go b/pkg-new/config/network_interface.go similarity index 91% rename from cmd/installer/cli/network_interface.go rename to pkg-new/config/network_interface.go index 6cc86244b..8a7907e56 100644 --- a/cmd/installer/cli/network_interface.go +++ b/pkg-new/config/network_interface.go @@ -13,8 +13,8 @@ var ( ErrCannotDetermineInterfaceName = fmt.Errorf("cannot determine interface name") ) -// determineBestNetworkInterface attempts to determine the best network interface to use for the cluster. -func determineBestNetworkInterface() (string, error) { +// DetermineBestNetworkInterface attempts to determine the best network interface to use for the cluster. +func DetermineBestNetworkInterface() (string, error) { iface, err := apimachinerynet.ChooseHostInterface() if err != nil || iface == nil { diff --git a/pkg-new/config/proxy.go b/pkg-new/config/proxy.go new file mode 100644 index 000000000..566074950 --- /dev/null +++ b/pkg-new/config/proxy.go @@ -0,0 +1,184 @@ +package cli + +import ( + "fmt" + "net" + "os" + "strings" + + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg/netutils" + "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" + "github.com/sirupsen/logrus" +) + +// NetworkLookup defines the interface for network lookups +type NetworkLookup interface { + FirstValidIPNet(networkInterface string) (*net.IPNet, error) +} + +type defaultNetworkLookup struct{} + +func (d *defaultNetworkLookup) FirstValidIPNet(networkInterface string) (*net.IPNet, error) { + return netutils.FirstValidIPNet(networkInterface) +} + +var defaultNetworkLookupImpl NetworkLookup = &defaultNetworkLookup{} + +func GetNetworkIPNet(networkInterface string, lookup NetworkLookup) (*net.IPNet, error) { + if lookup == nil { + lookup = defaultNetworkLookupImpl + } + return lookup.FirstValidIPNet(networkInterface) +} + +func GetProxySpec(httpProxy, httpsProxy, noProxy string, podCIDR string, serviceCIDR string, networkInterface string, lookup NetworkLookup) (*ecv1beta1.ProxySpec, error) { + proxy := &ecv1beta1.ProxySpec{ + HTTPProxy: httpProxy, + HTTPSProxy: httpsProxy, + ProvidedNoProxy: noProxy, + } + + SetProxyDefaults(proxy) + + // Now that we have all no-proxy entries (from flags/env), merge in defaults + if err := populateNoProxy(proxy, podCIDR, serviceCIDR, networkInterface, lookup); err != nil { + return nil, fmt.Errorf("unable to combine no-proxy supplied values and defaults: %w", err) + } + + if proxy.HTTPProxy == "" && proxy.HTTPSProxy == "" && proxy.NoProxy == "" { + return nil, nil + } + return proxy, nil +} + +func SetProxyDefaults(proxy *ecv1beta1.ProxySpec) { + if proxy.HTTPProxy == "" { + if envValue := os.Getenv("http_proxy"); envValue != "" { + // logrus.Debug("got http_proxy from http_proxy env var") + proxy.HTTPProxy = envValue + } else if envValue := os.Getenv("HTTP_PROXY"); envValue != "" { + // logrus.Debug("got http_proxy from HTTP_PROXY env var") + proxy.HTTPProxy = envValue + } + } + + if proxy.HTTPSProxy == "" { + if envValue := os.Getenv("https_proxy"); envValue != "" { + // logrus.Debug("got https_proxy from https_proxy env var") + proxy.HTTPSProxy = envValue + } else if envValue := os.Getenv("HTTPS_PROXY"); envValue != "" { + // logrus.Debug("got https_proxy from HTTPS_PROXY env var") + proxy.HTTPSProxy = envValue + } + } + + if proxy.ProvidedNoProxy == "" { + if envValue := os.Getenv("no_proxy"); envValue != "" { + // logrus.Debug("got no_proxy from no_proxy env var") + proxy.ProvidedNoProxy = envValue + } else if envValue := os.Getenv("NO_PROXY"); envValue != "" { + // logrus.Debug("got no_proxy from NO_PROXY env var") + proxy.ProvidedNoProxy = envValue + } + } +} + +func populateNoProxy(proxy *ecv1beta1.ProxySpec, podCIDR string, serviceCIDR string, networkInterface string, lookup NetworkLookup) error { + if proxy.ProvidedNoProxy == "" && proxy.HTTPProxy == "" && proxy.HTTPSProxy == "" { + return nil + } + + // Start with runtime defaults + noProxy := runtimeconfig.DefaultNoProxy + + // Add pod and service CIDRs + noProxy = append(noProxy, podCIDR, serviceCIDR) + + // Add user-provided no-proxy values + if proxy.ProvidedNoProxy != "" { + noProxy = append(noProxy, strings.Split(proxy.ProvidedNoProxy, ",")...) + } + + // If we have a proxy set, ensure the local IP is in the no-proxy list + if proxy.HTTPProxy != "" || proxy.HTTPSProxy != "" { + ipnet, err := GetNetworkIPNet(networkInterface, lookup) + if err != nil { + return fmt.Errorf("failed to get first valid ip net: %w", err) + } + cleanIPNet, err := cleanCIDR(ipnet) + if err != nil { + return fmt.Errorf("failed to clean subnet: %w", err) + } + + // Check if the local IP is already covered by any of the no-proxy entries + isValid, err := NoProxyHasLocalIP(strings.Join(noProxy, ","), ipnet.IP.String()) + if err != nil { + return fmt.Errorf("failed to validate no-proxy: %w", err) + } else if !isValid { + logrus.Debugf("The node IP (%q) is not included in the no-proxy list. Adding the network interface's subnet (%q).", ipnet.IP.String(), cleanIPNet) + noProxy = append(noProxy, cleanIPNet) + } + } + + proxy.NoProxy = strings.Join(noProxy, ",") + return nil +} + +// SetProxyEnv sets the HTTP_PROXY, HTTPS_PROXY, and NO_PROXY environment variables based on the provided ProxySpec. +// If the provided ProxySpec is nil, this environment variables are not set. +func SetProxyEnv(proxy *ecv1beta1.ProxySpec) { + if proxy == nil { + return + } + if proxy.HTTPProxy != "" { + os.Setenv("HTTP_PROXY", proxy.HTTPProxy) + } + if proxy.HTTPSProxy != "" { + os.Setenv("HTTPS_PROXY", proxy.HTTPSProxy) + } + if proxy.NoProxy != "" { + os.Setenv("NO_PROXY", proxy.NoProxy) + } +} + +func NoProxyHasLocalIP(noProxy string, localIP string) (bool, error) { + foundLocal := false + localIPParsed := net.ParseIP(localIP) + if localIPParsed == nil { + return false, fmt.Errorf("failed to parse local IP %q", localIP) + } + + for _, oneEntry := range strings.Split(noProxy, ",") { + if oneEntry == localIP { + foundLocal = true + } else if strings.Contains(oneEntry, "/") { + _, ipnet, err := net.ParseCIDR(oneEntry) + if err != nil { + return false, fmt.Errorf("failed to parse CIDR within no-proxy: %w", err) + } + if ipnet.Contains(localIPParsed) { + foundLocal = true + } + } + } + + return foundLocal, nil +} + +func CheckProxyConfigForLocalIP(proxy *ecv1beta1.ProxySpec, networkInterface string, lookup NetworkLookup) (bool, string, error) { + if proxy == nil { + return true, "", nil // no proxy is fine + } + if proxy.HTTPProxy == "" && proxy.HTTPSProxy == "" { + return true, "", nil // no proxy is fine + } + + ipnet, err := GetNetworkIPNet(networkInterface, lookup) + if err != nil { + return false, "", fmt.Errorf("failed to get default IPNet: %w", err) + } + + ok, err := NoProxyHasLocalIP(proxy.NoProxy, ipnet.IP.String()) + return ok, ipnet.IP.String(), err +} diff --git a/pkg-new/tlsutils/tls.go b/pkg-new/tlsutils/tls.go new file mode 100644 index 000000000..e853f0b0c --- /dev/null +++ b/pkg-new/tlsutils/tls.go @@ -0,0 +1,83 @@ +package tlsutils + +import ( + "crypto/tls" + "fmt" + "net" + + "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" + "github.com/sirupsen/logrus" + certutil "k8s.io/client-go/util/cert" +) + +var ( + // TLSCipherSuites defines the allowed cipher suites for TLS connections + TLSCipherSuites = []uint16{ + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, + } +) + +// Config represents TLS configuration options +type Config struct { + CertFile string + KeyFile string + Hostname string + IPAddresses []net.IP +} + +// GetCertificate returns a TLS certificate based on the provided configuration. +// If cert and key files are provided, it uses those. Otherwise, it generates a self-signed certificate. +func GetCertificate(cfg Config) (tls.Certificate, error) { + if cfg.CertFile != "" && cfg.KeyFile != "" { + logrus.Debugf("Using TLS configuration with cert file: %s and key file: %s", cfg.CertFile, cfg.KeyFile) + return tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile) + } + + hostname, altNames := generateCertHostnames(cfg.Hostname) + + // Generate a new self-signed cert + certData, keyData, err := certutil.GenerateSelfSignedCertKey(hostname, cfg.IPAddresses, altNames) + if err != nil { + return tls.Certificate{}, fmt.Errorf("generate self-signed cert: %w", err) + } + + cert, err := tls.X509KeyPair(certData, keyData) + if err != nil { + return tls.Certificate{}, fmt.Errorf("create TLS certificate: %w", err) + } + + logrus.Debugf("Using self-signed TLS certificate for hostname: %s", hostname) + return cert, nil +} + +// GetTLSConfig returns a TLS configuration with the provided certificate +func GetTLSConfig(cert tls.Certificate) *tls.Config { + return &tls.Config{ + MinVersion: tls.VersionTLS12, + CipherSuites: TLSCipherSuites, + Certificates: []tls.Certificate{cert}, + } +} + +func generateCertHostnames(hostname string) (string, []string) { + namespace := runtimeconfig.KotsadmNamespace + + if hostname == "" { + hostname = fmt.Sprintf("kotsadm.%s.svc.cluster.local", namespace) + } + + altNames := []string{ + "kotsadm", + fmt.Sprintf("kotsadm.%s", namespace), + fmt.Sprintf("kotsadm.%s.svc", namespace), + fmt.Sprintf("kotsadm.%s.svc.cluster", namespace), + fmt.Sprintf("kotsadm.%s.svc.cluster.local", namespace), + } + + return hostname, altNames +} diff --git a/pkg/netutils/ips.go b/pkg/netutils/ips.go index 6f20b0de9..bc51ba700 100644 --- a/pkg/netutils/ips.go +++ b/pkg/netutils/ips.go @@ -4,6 +4,8 @@ import ( "fmt" "net" "strings" + + "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" ) // adapted from https://github.com/k0sproject/k0s/blob/v1.30.4%2Bk0s.0/internal/pkg/iface/iface.go#L61 @@ -41,6 +43,27 @@ func FirstValidIPNet(networkInterface string) (*net.IPNet, error) { return nil, fmt.Errorf("interface %s not found or is not valid. The following interfaces were detected: %s", networkInterface, strings.Join(ifNames, ", ")) } +// ListValidNetworkInterfaces returns a list of valid network interfaces that are up and not +// loopback. +func ListValidNetworkInterfaces() ([]net.Interface, error) { + ifs, err := listValidInterfaces() + if err != nil { + return nil, err + } + + validIfs := []net.Interface{} + for _, i := range ifs { + if i.Flags&net.FlagUp == 0 { + continue + } + if i.Flags&net.FlagLoopback != 0 { + continue + } + validIfs = append(validIfs, i) + } + return validIfs, nil +} + // listValidInterfaces returns a list of valid network interfaces for the node. func listValidInterfaces() ([]net.Interface, error) { ifs, err := net.Interfaces() @@ -93,3 +116,32 @@ func firstValidIPNet(i net.Interface) (*net.IPNet, error) { } return nil, fmt.Errorf("could not find any non-local, non podnetwork ipv4 addresses") } + +func ListAllValidIPAddresses() ([]net.IP, error) { + ipAddresses := []net.IP{} + + ifs, err := ListValidNetworkInterfaces() + if err != nil { + return nil, fmt.Errorf("list valid network interfaces: %w", err) + } + for _, i := range ifs { + addrs, err := i.Addrs() + if err != nil { + return nil, fmt.Errorf("get addresses: %w", err) + } + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { + if ipnet.IP.To4() != nil { + ipAddresses = append(ipAddresses, ipnet.IP) + } + } + } + } + + publicIP := runtimeconfig.TryDiscoverPublicIP() + if publicIP != "" { + ipAddresses = append(ipAddresses, net.ParseIP(publicIP)) + } + + return ipAddresses, nil +} diff --git a/pkg/release/release.go b/pkg/release/release.go index 762dd6b51..e8d9fbdc2 100644 --- a/pkg/release/release.go +++ b/pkg/release/release.go @@ -10,6 +10,7 @@ import ( embeddedclusterv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/utils/pkg/embed" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" "gopkg.in/yaml.v2" @@ -25,7 +26,7 @@ var ( // ReleaseData holds the parsed data from a Kots Release. type ReleaseData struct { data []byte - Application []byte + Application *kotsv1beta1.Application HostPreflights *v1beta2.HostPreflightSpec EmbeddedClusterConfig *embeddedclusterv1beta1.Config ChannelRelease *ChannelRelease @@ -42,7 +43,7 @@ func GetHostPreflights() *v1beta2.HostPreflightSpec { // GetApplication reads and returns the kots application embedded as part of the // release. If no application is found, returns nil and no error. This function does // not unmarshal the application yaml. -func GetApplication() []byte { +func GetApplication() *kotsv1beta1.Application { return _releaseData.Application } @@ -109,6 +110,17 @@ func parseReleaseDataFromBinary() (*ReleaseData, error) { return release, nil } +func parseApplication(data []byte) (*kotsv1beta1.Application, error) { + if len(data) == 0 { + return nil, nil + } + var app kotsv1beta1.Application + if err := kyaml.Unmarshal(data, &app); err != nil { + return nil, fmt.Errorf("unable to unmarshal application: %w", err) + } + return &app, nil +} + func parseHostPreflights(data []byte) (*v1beta2.HostPreflightSpec, error) { if len(data) == 0 { return nil, nil @@ -224,7 +236,11 @@ func (r *ReleaseData) parse() error { switch { case bytes.Contains(content.Bytes(), []byte("apiVersion: kots.io/v1beta1")): if bytes.Contains(content.Bytes(), []byte("kind: Application")) { - r.Application = content.Bytes() + parsed, err := parseApplication(content.Bytes()) + if err != nil { + return fmt.Errorf("failed to parse application: %w", err) + } + r.Application = parsed } case bytes.Contains(content.Bytes(), []byte("apiVersion: troubleshoot.sh/v1beta2")): diff --git a/pkg/runtimeconfig/runtimeconfig.go b/pkg/runtimeconfig/runtimeconfig.go index b27a19604..c74e7322d 100644 --- a/pkg/runtimeconfig/runtimeconfig.go +++ b/pkg/runtimeconfig/runtimeconfig.go @@ -7,7 +7,6 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/sirupsen/logrus" - "github.com/spf13/pflag" "sigs.k8s.io/yaml" ) @@ -216,50 +215,10 @@ func SetAdminConsolePort(port int) { runtimeConfig.AdminConsole.Port = port } -func SetHostCABundlePath(hostCABundlePath string) { - runtimeConfig.HostCABundlePath = hostCABundlePath -} - -func ApplyFlags(flags *pflag.FlagSet) error { - if flags.Lookup("data-dir") != nil { - dd, err := flags.GetString("data-dir") - if err != nil { - return fmt.Errorf("get data-dir flag: %w", err) - } - SetDataDir(dd) - } - - if flags.Lookup("local-artifact-mirror-port") != nil { - lap, err := flags.GetInt("local-artifact-mirror-port") - if err != nil { - return fmt.Errorf("get local-artifact-mirror-port flag: %w", err) - } - SetLocalArtifactMirrorPort(lap) - } - - if flags.Lookup("admin-console-port") != nil { - ap, err := flags.GetInt("admin-console-port") - if err != nil { - return fmt.Errorf("get admin-console-port flag: %w", err) - } - SetAdminConsolePort(ap) - } - - if err := validate(); err != nil { - return err - } - - return nil +func SetManagerPort(port int) { + runtimeConfig.Manager.Port = port } -func validate() error { - lamPort := LocalArtifactMirrorPort() - acPort := AdminConsolePort() - - if lamPort != 0 && acPort != 0 { - if lamPort == acPort { - return fmt.Errorf("local artifact mirror port cannot be the same as admin console port") - } - } - return nil +func SetHostCABundlePath(hostCABundlePath string) { + runtimeConfig.HostCABundlePath = hostCABundlePath } diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 000000000..2163071be --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,38 @@ + +# See https://help.github.com/ignore-files/ for more about ignoring files. +cypress/screenshots +cypress/videos +.Trash-* + +# dependencies +/node_modules + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +package-lock.json + +dist/* +!dist/README.md +/history + +#editors +.vscode/ + +logs/ + +# typescript +tsconfig.tsbuildinfo diff --git a/web/dist/README.md b/web/dist/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 000000000..82c2e20cc --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + } +); diff --git a/web/index.html b/web/index.html new file mode 100644 index 000000000..f61af318d --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Gitea Enterprise Installer + + +
+ + + diff --git a/web/netlify.toml b/web/netlify.toml new file mode 100644 index 000000000..35628dbee --- /dev/null +++ b/web/netlify.toml @@ -0,0 +1,9 @@ +[build] + publish = "dist" + command = "npm run build" + +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 + force = true \ No newline at end of file diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 000000000..7fbd20639 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,4093 @@ +{ + "name": "vite-react-typescript-starter", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vite-react-typescript-starter", + "version": "0.0.0", + "dependencies": { + "lucide-react": "^0.344.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.22.3" + }, + "devDependencies": { + "@eslint/js": "^9.9.1", + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.18", + "eslint": "^9.9.1", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.11", + "globals": "^15.9.0", + "postcss": "^8.4.35", + "tailwindcss": "^3.4.1", + "typescript": "^5.5.3", + "typescript-eslint": "^8.3.0", + "vite": "^5.4.2" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.25.7.tgz", + "integrity": "sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.25.7", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.7.tgz", + "integrity": "sha512-9ickoLz+hcXCeh7jrcin+/SLWm+GkxE2kTvoYyp38p4WkdFXfQJxDFGWp/YHjiKLPx06z2A7W8XKuqbReXDzsw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.7.tgz", + "integrity": "sha512-yJ474Zv3cwiSOO9nXJuqzvwEeM+chDuQ8GJirw+pZ91sCGCyOZ3dJkVE09fTV0VEVzXyLWhh3G/AolYTPX7Mow==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.25.7", + "@babel/generator": "^7.25.7", + "@babel/helper-compilation-targets": "^7.25.7", + "@babel/helper-module-transforms": "^7.25.7", + "@babel/helpers": "^7.25.7", + "@babel/parser": "^7.25.7", + "@babel/template": "^7.25.7", + "@babel/traverse": "^7.25.7", + "@babel/types": "^7.25.7", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.7.tgz", + "integrity": "sha512-5Dqpl5fyV9pIAD62yK9P7fcA768uVPUyrQmqpqstHWgMma4feF1x/oFysBCVZLY5wJ2GkMUCdsNDnGZrPoR6rA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.25.7", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.7.tgz", + "integrity": "sha512-DniTEax0sv6isaw6qSQSfV4gVRNtw2rte8HHM45t9ZR0xILaufBRNkpMifCRiAPyvL4ACD6v0gfCwCmtOQaV4A==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.25.7", + "@babel/helper-validator-option": "^7.25.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.7.tgz", + "integrity": "sha512-o0xCgpNmRohmnoWKQ0Ij8IdddjyBFE4T2kagL/x6M3+4zUgc+4qTOUBoNe4XxDskt1HPKO007ZPiMgLDq2s7Kw==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.25.7", + "@babel/types": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.7.tgz", + "integrity": "sha512-k/6f8dKG3yDz/qCwSM+RKovjMix563SLxQFo0UhRNo239SP6n9u5/eLtKD6EAjwta2JHJ49CsD8pms2HdNiMMQ==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.25.7", + "@babel/helper-simple-access": "^7.25.7", + "@babel/helper-validator-identifier": "^7.25.7", + "@babel/traverse": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.7.tgz", + "integrity": "sha512-eaPZai0PiqCi09pPs3pAFfl/zYgGaE6IdXtYvmf0qlcDTd3WCtO7JWCcRd64e0EQrcYgiHibEZnOGsSY4QSgaw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.25.7.tgz", + "integrity": "sha512-FPGAkJmyoChQeM+ruBGIDyrT2tKfZJO8NcxdC+CWNJi7N8/rZpSxK7yvBJ5O/nF1gfu5KzN7VKG3YVSLFfRSxQ==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.25.7", + "@babel/types": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.7.tgz", + "integrity": "sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.7.tgz", + "integrity": "sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.7.tgz", + "integrity": "sha512-ytbPLsm+GjArDYXJ8Ydr1c/KJuutjF2besPNbIZnZ6MKUxi/uTA22t2ymmA4WFjZFpjiAMO0xuuJPqK2nvDVfQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.7.tgz", + "integrity": "sha512-Sv6pASx7Esm38KQpF/U/OXLwPPrdGHNKoeblRxgZRLXnAtnkEe4ptJPDtAZM7fBLadbc1Q07kQpSiGQ0Jg6tRA==", + "dev": true, + "dependencies": { + "@babel/template": "^7.25.7", + "@babel/types": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.7.tgz", + "integrity": "sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.7", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.7.tgz", + "integrity": "sha512-aZn7ETtQsjjGG5HruveUK06cU3Hljuhd9Iojm4M8WWv3wLE6OkE5PWbDUkItmMgegmccaITudyuW5RPYrYlgWw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.25.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.7.tgz", + "integrity": "sha512-JD9MUnLbPL0WdVK8AWC7F7tTG2OS6u/AKKnsK+NdRhUiVdnzyR1S3kKQCaRLOiaULvUiqK6Z4JQE635VgtCFeg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.7.tgz", + "integrity": "sha512-S/JXG/KrbIY06iyJPKfxr0qRxnhNOdkNXYBl/rmwgDd72cQLH9tEGkDm/yJPGvcSIUoikzfjMios9i+xT/uv9w==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.7.tgz", + "integrity": "sha512-wRwtAgI3bAS+JGU2upWNL9lSlDcRCqD05BZ1n3X2ONLH1WilFP6O1otQjeMK/1g0pvYcXC7b/qVUB1keofjtZA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.25.7", + "@babel/parser": "^7.25.7", + "@babel/types": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.7.tgz", + "integrity": "sha512-jatJPT1Zjqvh/1FyJs6qAHL+Dzb7sTb+xr7Q+gM1b+1oBsMsQQ4FkVKb6dFlJvLlVssqkRzV05Jzervt9yhnzg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.25.7", + "@babel/generator": "^7.25.7", + "@babel/parser": "^7.25.7", + "@babel/template": "^7.25.7", + "@babel/types": "^7.25.7", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/types": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.7.tgz", + "integrity": "sha512-vwIVdXG+j+FOpkwqHRcBgHLYNL7XMkufrlaFvL9o6Ai9sJn9+PdyIL5qa0XzTZw084c+u9LOls53eoZWP/W5WQ==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.25.7", + "@babel/helper-validator-identifier": "^7.25.7", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.1.tgz", + "integrity": "sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz", + "integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.4", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.6.0.tgz", + "integrity": "sha512-8I2Q8ykA4J0x0o7cg67FPVnehcqWTBehu/lmY+bolPFHGjh49YzGBMXTvpqVgEbBdvNCSxj6iFgiIyHzf03lzg==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", + "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.12.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.12.0.tgz", + "integrity": "sha512-eohesHH8WFRUprDNyEREgqP6beG6htMeUYeCpkEgBCieCMme5r9zFWjzAJp//9S+Kub4rqE+jXe9Cp1a7IYIIA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", + "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.0.tgz", + "integrity": "sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==", + "dev": true, + "dependencies": { + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.0.tgz", + "integrity": "sha512-2cbWIHbZVEweE853g8jymffCA+NCMiuqeECeBBLm8dg2oFdjuGJhgN4UAbI+6v0CKbbhvtXA4qV8YR5Ji86nmw==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.5", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.5.tgz", + "integrity": "sha512-KSPA4umqSG4LHYRodq31VDwKAvaTF4xmVlzM8Aeh4PlU1JQ3IG0wiA8C25d3RQ9nJyM3mBHyI53K06VVL/oFFg==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.0", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", + "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz", + "integrity": "sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.0.tgz", + "integrity": "sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.0.tgz", + "integrity": "sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.0.tgz", + "integrity": "sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.0.tgz", + "integrity": "sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.0.tgz", + "integrity": "sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.0.tgz", + "integrity": "sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.0.tgz", + "integrity": "sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.0.tgz", + "integrity": "sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.0.tgz", + "integrity": "sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.0.tgz", + "integrity": "sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.0.tgz", + "integrity": "sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.0.tgz", + "integrity": "sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.0.tgz", + "integrity": "sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.0.tgz", + "integrity": "sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.0.tgz", + "integrity": "sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", + "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/prop-types": { + "version": "15.7.13", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", + "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", + "dev": true + }, + "node_modules/@types/react": { + "version": "18.3.11", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.11.tgz", + "integrity": "sha512-r6QZ069rFTjrEYgFdOck1gK7FLVsgJE7tTz0pQBczlBNUhBNk0MQH4UbnFSwjpQLMkLzgqvBBa+qGpLje16eTQ==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", + "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.8.1.tgz", + "integrity": "sha512-xfvdgA8AP/vxHgtgU310+WBnLB4uJQ9XdyP17RebG26rLtDrQJV3ZYrcopX91GrHmMoH8bdSwMRh2a//TiJ1jQ==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.8.1", + "@typescript-eslint/type-utils": "8.8.1", + "@typescript-eslint/utils": "8.8.1", + "@typescript-eslint/visitor-keys": "8.8.1", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.8.1.tgz", + "integrity": "sha512-hQUVn2Lij2NAxVFEdvIGxT9gP1tq2yM83m+by3whWFsWC+1y8pxxxHUFE1UqDu2VsGi2i6RLcv4QvouM84U+ow==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.8.1", + "@typescript-eslint/types": "8.8.1", + "@typescript-eslint/typescript-estree": "8.8.1", + "@typescript-eslint/visitor-keys": "8.8.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.8.1.tgz", + "integrity": "sha512-X4JdU+66Mazev/J0gfXlcC/dV6JI37h+93W9BRYXrSn0hrE64IoWgVkO9MSJgEzoWkxONgaQpICWg8vAN74wlA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.8.1", + "@typescript-eslint/visitor-keys": "8.8.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.8.1.tgz", + "integrity": "sha512-qSVnpcbLP8CALORf0za+vjLYj1Wp8HSoiI8zYU5tHxRVj30702Z1Yw4cLwfNKhTPWp5+P+k1pjmD5Zd1nhxiZA==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "8.8.1", + "@typescript-eslint/utils": "8.8.1", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.8.1.tgz", + "integrity": "sha512-WCcTP4SDXzMd23N27u66zTKMuEevH4uzU8C9jf0RO4E04yVHgQgW+r+TeVTNnO1KIfrL8ebgVVYYMMO3+jC55Q==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.8.1.tgz", + "integrity": "sha512-A5d1R9p+X+1js4JogdNilDuuq+EHZdsH9MjTVxXOdVFfTJXunKJR/v+fNNyO4TnoOn5HqobzfRlc70NC6HTcdg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.8.1", + "@typescript-eslint/visitor-keys": "8.8.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.8.1.tgz", + "integrity": "sha512-/QkNJDbV0bdL7H7d0/y0qBbV2HTtf0TIyjSDTvvmQEzeVx8jEImEbLuOA4EsvE8gIgqMitns0ifb5uQhMj8d9w==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.8.1", + "@typescript-eslint/types": "8.8.1", + "@typescript-eslint/typescript-estree": "8.8.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.8.1.tgz", + "integrity": "sha512-0/TdC3aeRAsW7MDvYRwEc1Uwm0TIBfzjPFgg60UU2Haj5qsCs9cc3zNgY71edqE3LbWfF/WoZQd3lJoDXFQpag==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.8.1", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.2.tgz", + "integrity": "sha512-hieu+o05v4glEBucTcKMK3dlES0OeJlD9YVOAPraVMOInBCwzumaIFiUjr4bHK7NPgnAHgiskUoceKercrN8vg==", + "dev": true, + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/plugin-transform-react-jsx-self": "^7.24.7", + "@babel/plugin-transform-react-jsx-source": "^7.24.7", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0" + } + }, + "node_modules/acorn": { + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/argparse": { + "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 + }, + "node_modules/autoprefixer": { + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "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==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.0.tgz", + "integrity": "sha512-Rmb62sR1Zpjql25eSanFGEhAxcFwfA1K0GuQcLoaJBAcENegrQut3hYdhXFF1obQfiDyqIW/cLM5HSJ/9k884A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001663", + "electron-to-chromium": "^1.5.28", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "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, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001667", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001667.tgz", + "integrity": "sha512-7LTwJjcRkzKFmtqGsibMeuXmvFDfZq/nzIjnmgCGzKKRVzjD72selLDK1oPF/Oxzmt4fNcPvTDvGqSDG4tCALw==", + "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" + } + ] + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "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 + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "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==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true + }, + "node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.33", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.33.tgz", + "integrity": "sha512-+cYTcFB1QqD4j4LegwLfpCNxifb6dDFUAwk6RsLusCwIaZI6or2f+q8rs5tTB2YC53HhOlIbEaqHMAAC8IOIwA==", + "dev": true + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "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, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/eslint": { + "version": "9.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.12.0.tgz", + "integrity": "sha512-UVIOlTEWxwIopRL1wgSQYdnVDcEvs2wyaO6DGo5mXqe3r16IoCNWkR29iHhyaP4cICWjbgbmFUGAhh0GJRuGZw==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.11.0", + "@eslint/config-array": "^0.18.0", + "@eslint/core": "^0.6.0", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "9.12.0", + "@eslint/plugin-kit": "^0.2.0", + "@humanfs/node": "^0.16.5", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.3.1", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.1.0", + "eslint-visitor-keys": "^4.1.0", + "espree": "^10.2.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.1.0-rc-fb9a90fa48-20240614", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.1.0-rc-fb9a90fa48-20240614.tgz", + "integrity": "sha512-xsiRwaDNF5wWNC4ZHLut+x/YcAxksUd9Rizt7LaEn3bV8VyYRpXnRJQlLOfYaVy9esk4DFP4zPPnoNVjq5Gc0w==", + "dev": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.12.tgz", + "integrity": "sha512-9neVjoGv20FwYtCP6CB1dzR1vr57ZDNOXst21wd2xJ/cTlM2xLq0GWVlSNTdMn/4BtP6cHYBMCSp1wFBJ9jBsg==", + "dev": true, + "peerDependencies": { + "eslint": ">=7" + } + }, + "node_modules/eslint-scope": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.1.0.tgz", + "integrity": "sha512-14dSvlhaVhKKsa9Fx1l8A17s7ah7Ef7wCakJ10LYk6+GYmP9yDti2oq2SEwcyndt6knfcZyhyxwY3i9yL78EQw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz", + "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/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, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/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, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/eslint/node_modules/color-name": { + "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 + }, + "node_modules/eslint/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, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/espree": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.2.0.tgz", + "integrity": "sha512-upbkBJbckcCNBDBDXEbuhjbP68n+scUd3k/U2EkyM9nw+I/jPiL4cLF/Al06CF96wRltFda16sxDFrxsI1v0/g==", + "dev": true, + "dependencies": { + "acorn": "^8.12.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "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 + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "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.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "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", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "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==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true + }, + "node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "15.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.11.0.tgz", + "integrity": "sha512-yeyNSjdbyVaWurlwCpcA6XNBrHTMIeDdj0/hnvX/OLJ9ekOXYbLsLinH/MucQyGvNnXhidTdNhTtJaffL2sMfw==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "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==", + "dev": true, + "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, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.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 + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.6", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", + "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true, + "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 + }, + "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 + }, + "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/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=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, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "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, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.344.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.344.0.tgz", + "integrity": "sha512-6YyBnn91GB45VuVT96bYCOKElbJzUHqp65vX8cDcu55MQL9T969v4dhGClpljamuI/+KMO9P6w9Acq1CVQGvIQ==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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 + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "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 + }, + "node_modules/node-releases": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true + }, + "node_modules/parent-module": { + "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, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/lilconfig": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", + "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "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, + "engines": { + "node": ">= 0.8.0" + } + }, + "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, + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.0.tgz", + "integrity": "sha512-D3X8FyH9nBcTSHGdEKurK7r8OYE1kKFn3d/CF+CoxbSHkxU7o37+Uh7eAHRXr6k2tSExXYO++07PeXJtA/dEhQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.0.tgz", + "integrity": "sha512-x30B78HV5tFk8ex0ITwzC9TTZMua4jGyA9IUlH1JLQYQTFyxr/ZxwOJq7evg1JX1qGVUcvhsmQSKdPncQrjTgA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0", + "react-router": "6.30.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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, + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz", + "integrity": "sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.6" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.24.0", + "@rollup/rollup-android-arm64": "4.24.0", + "@rollup/rollup-darwin-arm64": "4.24.0", + "@rollup/rollup-darwin-x64": "4.24.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.24.0", + "@rollup/rollup-linux-arm-musleabihf": "4.24.0", + "@rollup/rollup-linux-arm64-gnu": "4.24.0", + "@rollup/rollup-linux-arm64-musl": "4.24.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.24.0", + "@rollup/rollup-linux-riscv64-gnu": "4.24.0", + "@rollup/rollup-linux-s390x-gnu": "4.24.0", + "@rollup/rollup-linux-x64-gnu": "4.24.0", + "@rollup/rollup-linux-x64-musl": "4.24.0", + "@rollup/rollup-win32-arm64-msvc": "4.24.0", + "@rollup/rollup-win32-ia32-msvc": "4.24.0", + "@rollup/rollup-win32-x64-msvc": "4.24.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "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, + "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==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "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==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/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==", + "dev": true + }, + "node_modules/string-width-cjs/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==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.13.tgz", + "integrity": "sha512-KqjHOJKogOUt5Bs752ykCeiwvi0fKVkr5oqsFNt/8px/tA8scFPIlkygsf6jXrfCqGHz7VflA6+yytWuM+XhFw==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.0", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.8.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.8.1.tgz", + "integrity": "sha512-R0dsXFt6t4SAFjUSKFjMh4pXDtq04SsFKCVGDP3ZOzNP7itF0jBcZYU4fMsZr4y7O7V7Nc751dDeESbe4PbQMQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.8.1", + "@typescript-eslint/parser": "8.8.1", + "@typescript-eslint/utils": "8.8.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", + "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/vite": { + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz", + "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "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, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "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==", + "dev": true, + "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/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/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, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/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, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-name": { + "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 + }, + "node_modules/wrap-ansi-cjs/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==", + "dev": true + }, + "node_modules/wrap-ansi-cjs/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==", + "dev": true, + "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-cjs/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==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yaml": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", + "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", + "dev": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 000000000..be28da611 --- /dev/null +++ b/web/package.json @@ -0,0 +1,34 @@ +{ + "name": "vite-react-typescript-starter", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^0.344.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.22.3" + }, + "devDependencies": { + "@eslint/js": "^9.9.1", + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.18", + "eslint": "^9.9.1", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.11", + "globals": "^15.9.0", + "postcss": "^8.4.35", + "tailwindcss": "^3.4.1", + "typescript": "^5.5.3", + "typescript-eslint": "^8.3.0", + "vite": "^5.4.2" + } +} \ No newline at end of file diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 000000000..2aa7205d4 --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 000000000..3a8563ebc --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,41 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { ConfigProvider } from './contexts/ConfigContext'; +import { WizardModeProvider } from './contexts/WizardModeContext'; +import { BrandingProvider } from './contexts/BrandingContext'; +import InstallWizard from './components/wizard/InstallWizard'; +import PrototypeSettings from './components/prototype/PrototypeSettings'; + +function App() { + return ( + + +
+ + + } /> + + + + } + /> + + + + } + /> + } /> + + +
+
+
+ ); +} + +export default App; diff --git a/web/src/components/common/Button.tsx b/web/src/components/common/Button.tsx new file mode 100644 index 000000000..3f2d9d2f1 --- /dev/null +++ b/web/src/components/common/Button.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { useConfig } from '../../contexts/ConfigContext'; + +interface ButtonProps { + children: React.ReactNode; + onClick?: () => void; + type?: 'button' | 'submit' | 'reset'; + variant?: 'primary' | 'secondary' | 'outline' | 'danger'; + size?: 'sm' | 'md' | 'lg'; + disabled?: boolean; + className?: string; + icon?: React.ReactNode; +} + +const Button: React.FC = ({ + children, + onClick, + type = 'button', + variant = 'primary', + size = 'md', + disabled = false, + className = '', + icon, +}) => { + const { prototypeSettings } = useConfig(); + const themeColor = prototypeSettings.themeColor; + + const baseStyles = 'inline-flex items-center justify-center font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-md'; + + const variantStyles = { + primary: `bg-[${themeColor}] hover:bg-[${themeColor}] text-white focus:ring-[${themeColor}]`, + secondary: 'bg-[#3498DB] hover:bg-[#2980B9] text-white focus:ring-[#3498DB]', + outline: `border border-gray-300 bg-white hover:bg-gray-50 text-gray-700 focus:ring-[${themeColor}]`, + danger: 'bg-red-500 hover:bg-red-600 text-white focus:ring-red-500', + }; + + const sizeStyles = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2 text-sm', + lg: 'px-5 py-2.5 text-base', + }; + + const disabledStyles = disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'; + + return ( + + ); +}; + +export default Button; \ No newline at end of file diff --git a/web/src/components/common/Card.tsx b/web/src/components/common/Card.tsx new file mode 100644 index 000000000..d073f3e56 --- /dev/null +++ b/web/src/components/common/Card.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +interface CardProps { + children: React.ReactNode; + title?: string; + className?: string; + titleClassName?: string; + contentClassName?: string; + footer?: React.ReactNode; + footerClassName?: string; +} + +const Card: React.FC = ({ + children, + title, + className = '', + titleClassName = '', + contentClassName = '', + footer, + footerClassName = '', +}) => { + return ( +
+ {title && ( +
+

{title}

+
+ )} +
{children}
+ {footer && ( +
+ {footer} +
+ )} +
+ ); +}; + +export default Card; \ No newline at end of file diff --git a/web/src/components/common/Input.tsx b/web/src/components/common/Input.tsx new file mode 100644 index 000000000..56b40482d --- /dev/null +++ b/web/src/components/common/Input.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { useConfig } from '../../contexts/ConfigContext'; + +interface InputProps { + id: string; + label: string; + type?: string; + value: string; + onChange: (e: React.ChangeEvent) => void; + onKeyDown?: (e: React.KeyboardEvent) => void; + placeholder?: string; + required?: boolean; + disabled?: boolean; + error?: string; + helpText?: string; + className?: string; + labelClassName?: string; + icon?: React.ReactNode; +} + +const Input: React.FC = ({ + id, + label, + type = 'text', + value, + onChange, + onKeyDown, + placeholder = '', + required = false, + disabled = false, + error, + helpText, + className = '', + labelClassName = '', + icon, +}) => { + const { prototypeSettings } = useConfig(); + const themeColor = prototypeSettings.themeColor; + + return ( +
+ +
+ {icon && ( +
+ {icon} +
+ )} + +
+ {error &&

{error}

} + {helpText && !error &&

{helpText}

} +
+ ); +}; + +export default Input; \ No newline at end of file diff --git a/web/src/components/common/Logo.tsx b/web/src/components/common/Logo.tsx new file mode 100644 index 000000000..a92d850a7 --- /dev/null +++ b/web/src/components/common/Logo.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { useBranding } from '../../contexts/BrandingContext'; + +export const AppIcon: React.FC<{ className?: string }> = ({ className = 'w-6 h-6' }) => { + const { branding } = useBranding(); + if (!branding?.appIcon) { + return
; + } + return ( + App Icon + ); +}; diff --git a/web/src/components/common/Select.tsx b/web/src/components/common/Select.tsx new file mode 100644 index 000000000..e2a7df559 --- /dev/null +++ b/web/src/components/common/Select.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { useConfig } from '../../contexts/ConfigContext'; + +interface SelectOption { + value: string; + label: string; +} + +interface SelectProps { + id: string; + label: string; + value: string; + onChange: (e: React.ChangeEvent) => void; + options: SelectOption[]; + required?: boolean; + disabled?: boolean; + error?: string; + helpText?: string; + className?: string; + labelClassName?: string; +} + +const Select: React.FC = ({ + id, + label, + value, + onChange, + options, + required = false, + disabled = false, + error, + helpText, + className = '', + labelClassName = '', +}) => { + const { prototypeSettings } = useConfig(); + const themeColor = prototypeSettings.themeColor; + + return ( +
+ + + {error &&

{error}

} + {helpText && !error &&

{helpText}

} +
+ ); +}; + +export default Select; \ No newline at end of file diff --git a/web/src/components/prototype/PrototypeSettings.tsx b/web/src/components/prototype/PrototypeSettings.tsx new file mode 100644 index 000000000..16f7df422 --- /dev/null +++ b/web/src/components/prototype/PrototypeSettings.tsx @@ -0,0 +1,196 @@ +import React from 'react'; +import Card from '../common/Card'; +import { AppIcon } from '../common/Logo'; +import { useConfig } from '../../contexts/ConfigContext'; + +const PrototypeSettings: React.FC = () => { + const { prototypeSettings, updatePrototypeSettings } = useConfig(); + + const handleSkipValidationChange = (e: React.ChangeEvent) => { + updatePrototypeSettings({ skipValidation: e.target.checked }); + }; + + const handleFailPreflightsChange = (e: React.ChangeEvent) => { + updatePrototypeSettings({ failPreflights: e.target.checked }); + }; + + const handleFailHostPreflightsChange = (e: React.ChangeEvent) => { + updatePrototypeSettings({ failHostPreflights: e.target.checked }); + }; + + const handleFailInstallationChange = (e: React.ChangeEvent) => { + updatePrototypeSettings({ failInstallation: e.target.checked }); + }; + + const handleThemeColorChange = (e: React.ChangeEvent) => { + updatePrototypeSettings({ themeColor: e.target.value }); + }; + + const handleSelfSignedCertChange = (e: React.ChangeEvent) => { + updatePrototypeSettings({ useSelfSignedCert: e.target.checked }); + }; + + const handleSkipNodeValidationChange = (e: React.ChangeEvent) => { + updatePrototypeSettings({ skipNodeValidation: e.target.checked }); + }; + + const handleMultiNodeChange = (e: React.ChangeEvent) => { + updatePrototypeSettings({ enableMultiNode: e.target.checked }); + }; + + return ( +
+
+
+
+
+ +
+

Prototype Settings

+

Configure prototype behavior

+
+
+
+
+
+ +
+ +
+
+

Theme Settings

+
+ + +

Enter a hex color code (e.g., #609926)

+
+
+ +
+

Security Settings

+
+
+ + +
+
+
+ +
+

Linux Installation Settings

+
+
+ + +
+
+
+ +
+

Validation Settings

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +

+ These settings affect how the installer behaves during development and testing. +

+
+
+
+
+
+ ); +}; + +export default PrototypeSettings; \ No newline at end of file diff --git a/web/src/components/wizard/CompletionStep.tsx b/web/src/components/wizard/CompletionStep.tsx new file mode 100644 index 000000000..8e7e33064 --- /dev/null +++ b/web/src/components/wizard/CompletionStep.tsx @@ -0,0 +1,132 @@ +import React, { useState } from 'react'; +import Card from '../common/Card'; +import Button from '../common/Button'; +import { useConfig } from '../../contexts/ConfigContext'; +import { useBranding } from '../../contexts/BrandingContext'; +import { CheckCircle, ExternalLink, Copy, ClipboardCheck } from 'lucide-react'; + +const CompletionStep: React.FC = () => { + const { config, prototypeSettings } = useConfig(); + const { branding } = useBranding(); + const [copied, setCopied] = useState(false); + const themeColor = prototypeSettings.themeColor; + + const baseUrl = `${config.useHttps ? 'https' : 'http'}://${config.domain}`; + const urls = [ + { name: 'Web Interface', url: baseUrl, description: `Access the main ${branding?.appTitle} interface` }, + { name: 'API Documentation', url: `${baseUrl}/api/swagger`, description: `Browse and test the ${branding?.appTitle} API` } + ]; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + + return ( +
+ +
+
+ +
+

Installation Complete!

+

+ {branding?.appTitle} is installed successfully. +

+ + + +
+ {urls.map((item, index) => ( +
+
+

{item.name}

+ +
+ + {item.url} + + +

{item.description}

+
+ ))} +
+
+
+ + +
+

Next Steps

+ +
+
+
+
+ 1 +
+
+
+

Log in to your {branding?.appTitle} instance

+

+ Use the administrator credentials you provided during setup to log in to your {branding?.appTitle} instance. +

+
+
+ +
+
+
+ 2 +
+
+
+

Configure additional settings

+

+ Visit the Admin Dashboard to configure additional settings such as authentication providers, + webhooks, and other integrations. +

+
+
+ +
+
+
+ 3 +
+
+
+

Create your first organization

+

+ Set up an organization for your team and invite members to collaborate on repositories. +

+
+
+
+
+
+
+ ); +}; + +export default CompletionStep; \ No newline at end of file diff --git a/web/src/components/wizard/InstallWizard.tsx b/web/src/components/wizard/InstallWizard.tsx new file mode 100644 index 000000000..6d7725b2f --- /dev/null +++ b/web/src/components/wizard/InstallWizard.tsx @@ -0,0 +1,73 @@ +import React, { useState } from 'react'; +import StepNavigation from './StepNavigation'; +import WelcomeStep from './WelcomeStep'; +import SetupStep from './SetupStep'; +import ValidationInstallStep from './ValidationInstallStep'; +import { WizardStep } from '../../types'; +import { AppIcon } from '../common/Logo'; +import { useWizardMode } from '../../contexts/WizardModeContext'; +import { useBranding } from '../../contexts/BrandingContext'; + +const InstallWizard: React.FC = () => { + const [currentStep, setCurrentStep] = useState('welcome'); + const { text } = useWizardMode(); + const { branding } = useBranding(); + + const goToNextStep = () => { + const steps: WizardStep[] = ['welcome', 'setup', 'installation']; + const currentIndex = steps.indexOf(currentStep); + if (currentIndex < steps.length - 1) { + setCurrentStep(steps[currentIndex + 1]); + } + }; + + const goToPreviousStep = () => { + const steps: WizardStep[] = ['welcome', 'setup', 'installation']; + const currentIndex = steps.indexOf(currentStep); + if (currentIndex > 0) { + setCurrentStep(steps[currentIndex - 1]); + } + }; + + const renderStep = () => { + switch (currentStep) { + case 'welcome': + return ; + case 'setup': + return ; + case 'installation': + return ; + default: + return null; + } + }; + + return ( +
+
+
+
+
+ +
+

{text.title}

+

{text.subtitle}

+
+
+
+
+
+ +
+
+ +
+ {renderStep()} +
+
+
+
+ ); +}; + +export default InstallWizard; \ No newline at end of file diff --git a/web/src/components/wizard/SetupStep.tsx b/web/src/components/wizard/SetupStep.tsx new file mode 100644 index 000000000..ef64e111d --- /dev/null +++ b/web/src/components/wizard/SetupStep.tsx @@ -0,0 +1,170 @@ +import React, { useState, useEffect } from 'react'; +import Card from '../common/Card'; +import Button from '../common/Button'; +import { useConfig } from '../../contexts/ConfigContext'; +import { useWizardMode } from '../../contexts/WizardModeContext'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import LinuxSetup from './setup/LinuxSetup'; +import KubernetesSetup from './setup/KubernetesSetup'; + +interface SetupStepProps { + onNext: () => void; + onBack: () => void; +} + +const SetupStep: React.FC = ({ onNext, onBack }) => { + const { config, updateConfig, prototypeSettings } = useConfig(); + const { text } = useWizardMode(); + const [showAdvanced, setShowAdvanced] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [availableNetworkInterfaces, setAvailableNetworkInterfaces] = useState([]); + + useEffect(() => { + const fetchData = async () => { + try { + // Fetch cluster config + const installResponse = await fetch('/api/install', { + headers: { + ...(localStorage.getItem('auth') && { + 'Authorization': `Bearer ${localStorage.getItem('auth')}`, + }), + }, + }); + + if (installResponse.ok) { + const install = await installResponse.json(); + updateConfig(install.config); + } + + const interfacesResponse = await fetch('/api/console/available-network-interfaces', { + headers: { + ...(localStorage.getItem('auth') && { + 'Authorization': `Bearer ${localStorage.getItem('auth')}`, + }), + }, + }); + + if (interfacesResponse.ok) { + const interfacesData = await interfacesResponse.json(); + setAvailableNetworkInterfaces(interfacesData.networkInterfaces || []); + } + } catch (err) { + console.error('Failed to fetch data:', err); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleInputChange = (e: React.ChangeEvent) => { + const { id, value } = e.target; + if (id === 'adminConsolePort') { + updateConfig({ adminConsolePort: parseInt(value) }); + } else if (id === 'localArtifactMirrorPort') { + updateConfig({ localArtifactMirrorPort: parseInt(value) }); + } else { + updateConfig({ [id]: value }); + } + }; + + const handleSelectChange = (e: React.ChangeEvent) => { + const { id, value } = e.target; + updateConfig({ [id]: value }); + }; + + const handleNext = async () => { + setIsSubmitting(true); + setError(null); + + try { + // Make the POST request to the cluster-setup endpoint + const response = await fetch('/api/install/config', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // Include auth credentials if available from localStorage or another source + ...(localStorage.getItem('auth') && { + 'Authorization': `Bearer ${localStorage.getItem('auth')}`, + }), + }, + body: JSON.stringify(config), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `Server responded with ${response.status}`); + } + + // Call the original onNext function to proceed to the next step + onNext(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to setup cluster'); + console.error('Cluster setup failed:', err); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ +
+

{text.setupTitle}

+

+ {prototypeSettings.clusterMode === 'embedded' + ? 'Configure the installation settings.' + : text.setupDescription} +

+
+ + {isLoading ? ( +
+
+

Loading configuration...

+
+ ) : prototypeSettings?.clusterMode === 'embedded' ? ( + + ) : ( + + )} + + {error && ( +
+ {error} +
+ )} +
+ +
+ + +
+
+ ); +}; + +export default SetupStep; \ No newline at end of file diff --git a/web/src/components/wizard/StepNavigation.tsx b/web/src/components/wizard/StepNavigation.tsx new file mode 100644 index 000000000..52ef2e6ff --- /dev/null +++ b/web/src/components/wizard/StepNavigation.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { WizardStep } from '../../types'; +import { ClipboardList, Settings, Download } from 'lucide-react'; +import { useWizardMode } from '../../contexts/WizardModeContext'; +import { useConfig } from '../../contexts/ConfigContext'; + +interface StepNavigationProps { + currentStep: WizardStep; +} + +const StepNavigation: React.FC = ({ currentStep }) => { + const { mode } = useWizardMode(); + const { prototypeSettings } = useConfig(); + const themeColor = prototypeSettings.themeColor; + + const steps = [ + { id: 'welcome', name: 'Welcome', icon: ClipboardList }, + { id: 'setup', name: 'Setup', icon: Settings }, + { id: 'installation', name: mode === 'upgrade' ? 'Upgrade' : 'Installation', icon: Download }, + ]; + + const getStepStatus = (step: { id: string }) => { + const stepIndex = steps.findIndex((s) => s.id === step.id); + const currentIndex = steps.findIndex((s) => s.id === currentStep); + + if (stepIndex < currentIndex) return 'complete'; + if (stepIndex === currentIndex) return 'current'; + return 'upcoming'; + }; + + return ( + + ); +}; + +export default StepNavigation; \ No newline at end of file diff --git a/web/src/components/wizard/ValidationInstallStep.tsx b/web/src/components/wizard/ValidationInstallStep.tsx new file mode 100644 index 000000000..11f6735d5 --- /dev/null +++ b/web/src/components/wizard/ValidationInstallStep.tsx @@ -0,0 +1,103 @@ +import React, { useState, useEffect } from 'react'; +import Card from '../common/Card'; +import { useConfig } from '../../contexts/ConfigContext'; +import { ExternalLink } from 'lucide-react'; + +const ValidationInstallStep: React.FC = () => { + const { config } = useConfig(); + const [showAdminLink, setShowAdminLink] = useState(false); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + let pollInterval: NodeJS.Timeout; + + const checkInstallStatus = async () => { + try { + const response = await fetch('/api/install/status', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + // Include auth credentials if available from localStorage or another source + ...(localStorage.getItem('auth') && { + 'Authorization': `Bearer ${localStorage.getItem('auth')}`, + }), + }, + }); + + if (!response.ok) { + throw new Error(`Installation failed: ${response.statusText}`); + } + + const data = await response.json(); + + if (data.state === 'Succeeded') { + setShowAdminLink(true); + setIsLoading(false); + if (pollInterval) { + clearInterval(pollInterval); + } + } else if (data.state === 'Failed') { + throw new Error('Installation failed'); + } + // If state is neither Succeeded nor Failed, continue polling + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to install cluster'); + setIsLoading(false); + if (pollInterval) { + clearInterval(pollInterval); + } + } + }; + + // Initial check + checkInstallStatus(); + + // Set up polling every 5 seconds + pollInterval = setInterval(checkInstallStatus, 2000); + + // Cleanup on unmount + return () => { + if (pollInterval) { + clearInterval(pollInterval); + } + }; + }, [config.adminPassword]); + + return ( +
+ +
+

Installing Embedded Cluster

+ + {isLoading && ( +

+ Please wait while we complete the installation... +

+ )} + + {error && ( +
+

Installation Error

+

{error}

+
+ )} + + {showAdminLink && ( + + Visit Admin Console + + + )} +
+
+
+ ); +}; + +export default ValidationInstallStep; \ No newline at end of file diff --git a/web/src/components/wizard/WelcomeStep.tsx b/web/src/components/wizard/WelcomeStep.tsx new file mode 100644 index 000000000..0ba7aeb4b --- /dev/null +++ b/web/src/components/wizard/WelcomeStep.tsx @@ -0,0 +1,151 @@ +import React, { useState } from 'react'; +import Card from '../common/Card'; +import Button from '../common/Button'; +import Input from '../common/Input'; +import { AppIcon } from '../common/Logo'; +import { ChevronRight, Lock, AlertTriangle } from 'lucide-react'; +import { useWizardMode } from '../../contexts/WizardModeContext'; +import { useConfig } from '../../contexts/ConfigContext'; + +interface WelcomeStepProps { + onNext: () => void; +} + +const WelcomeStep: React.FC = ({ onNext }) => { + const { text } = useWizardMode(); + const { prototypeSettings } = useConfig(); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [showPasswordInput, setShowPasswordInput] = useState(!prototypeSettings.useSelfSignedCert); + + const handlePasswordChange = (e: React.ChangeEvent) => { + setPassword(e.target.value); + setError(''); + }; + + const handleSubmit = async () => { + if (!showPasswordInput) { + setShowPasswordInput(true); + return; + } + + if (!password.trim()) { + setError('Password is required'); + return; + } + + setIsLoading(true); + setError(''); + + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ password }), + headers: { + 'Content-Type': 'application/json' + } + }); + + if (response.ok) { + // Store the password in localStorage + const data = await response.json(); + localStorage.setItem('auth', data.token); + onNext(); + } else { + setError('Invalid password'); + } + } catch (err) { + setError('Authentication failed. Please try again.'); + console.error('Login error:', err); + } finally { + setIsLoading(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && showPasswordInput) { + handleSubmit(); + } + }; + + return ( +
+ +
+ +

{text.welcomeTitle}

+

+ {text.welcomeDescription} +

+ + {prototypeSettings.useSelfSignedCert && !showPasswordInput ? ( + <> +
+
+
+ +
+
+

+ Self-Signed Certificate Warning +

+
+

+ When you click "Continue", you'll be redirected to a secure HTTPS connection. + Your browser will show a security warning because this wizard uses a self-signed certificate. +

+

+ To proceed: +

+
    +
  1. Click "Advanced" or "Show Details" in your browser's warning
  2. +
  3. Choose "Proceed" or "Continue" to the site
  4. +
  5. You'll return to this page to enter your password
  6. +
+
+
+
+
+ + + + ) : ( +
+ } + /> + + +
+ )} +
+
+
+ ); +}; + +export default WelcomeStep; \ No newline at end of file diff --git a/web/src/components/wizard/setup/KubernetesSetup.tsx b/web/src/components/wizard/setup/KubernetesSetup.tsx new file mode 100644 index 000000000..97631d51b --- /dev/null +++ b/web/src/components/wizard/setup/KubernetesSetup.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +interface KubernetesSetupProps { + config: { + networkInterface?: string; + globalCidr?: string; + }; + onInputChange: (e: React.ChangeEvent) => void; +} + +const KubernetesSetup: React.FC = ({ + config, + onInputChange, +}) => ( +
+
+

Network Settings

+

+ Configure network settings for your Kubernetes cluster. +

+
+ + +
+
+
+); + +export default KubernetesSetup; \ No newline at end of file diff --git a/web/src/components/wizard/setup/LinuxSetup.tsx b/web/src/components/wizard/setup/LinuxSetup.tsx new file mode 100644 index 000000000..49a97fec2 --- /dev/null +++ b/web/src/components/wizard/setup/LinuxSetup.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import Input from '../../common/Input'; +import Select from '../../common/Select'; +import { useBranding } from '../../../contexts/BrandingContext'; +import { ChevronDown, ChevronUp } from 'lucide-react'; + +interface LinuxSetupProps { + config: { + dataDirectory?: string; + adminConsolePort?: number; + localArtifactMirrorPort?: number; + httpProxy?: string; + httpsProxy?: string; + noProxy?: string; + networkInterface?: string; + globalCidr?: string; + }; + prototypeSettings: { + clusterMode: string; + availableNetworkInterfaces?: Array<{ + name: string; + }>; + }; + showAdvanced: boolean; + onShowAdvancedChange: (show: boolean) => void; + onInputChange: (e: React.ChangeEvent) => void; + onSelectChange: (e: React.ChangeEvent) => void; + availableNetworkInterfaces?: string[]; +} + +const LinuxSetup: React.FC = ({ + config, + prototypeSettings, + showAdvanced, + onShowAdvancedChange, + onInputChange, + onSelectChange, + availableNetworkInterfaces = [], +}) => { + const { branding } = useBranding(); + return ( +
+
+

System Configuration

+ + + + + +
+ +
+

Proxy Configuration

+
+ + + + + +
+
+ +
+ + + {showAdvanced && ( +
+ +
+ )} +
+
+ ); +}; + +export default LinuxSetup; \ No newline at end of file diff --git a/web/src/components/wizard/validation/ErrorMessage.tsx b/web/src/components/wizard/validation/ErrorMessage.tsx new file mode 100644 index 000000000..4e5c28e9a --- /dev/null +++ b/web/src/components/wizard/validation/ErrorMessage.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { XCircle } from 'lucide-react'; + +interface ErrorMessageProps { + error: string; +} + +const ErrorMessage: React.FC = ({ error }) => ( +
+
+
+ +
+
+

Installation Error

+
+

{error}

+
+
+
+
+); + +export default ErrorMessage; \ No newline at end of file diff --git a/web/src/components/wizard/validation/InstallationProgress.tsx b/web/src/components/wizard/validation/InstallationProgress.tsx new file mode 100644 index 000000000..ac458b442 --- /dev/null +++ b/web/src/components/wizard/validation/InstallationProgress.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { InstallationStatus } from '../../../types'; + +interface InstallationProgressProps { + progress: number; + currentMessage: string; + themeColor: string; + status?: 'failed' | 'success'; +} + +const InstallationProgress: React.FC = ({ + progress, + currentMessage, + themeColor, + status +}) => ( +
+
+
+
+

+ {currentMessage || 'Preparing installation...'} +

+
+); + +export default InstallationProgress; \ No newline at end of file diff --git a/web/src/components/wizard/validation/LogViewer.tsx b/web/src/components/wizard/validation/LogViewer.tsx new file mode 100644 index 000000000..bb33c6990 --- /dev/null +++ b/web/src/components/wizard/validation/LogViewer.tsx @@ -0,0 +1,52 @@ +import React, { useRef, useEffect } from 'react'; +import { ChevronDown, ChevronUp } from 'lucide-react'; + +interface LogViewerProps { + title: string; + logs: string[]; + isExpanded: boolean; + onToggle: () => void; +} + +const LogViewer: React.FC = ({ + title, + logs, + isExpanded, + onToggle +}) => { + const logsEndRef = useRef(null); + + useEffect(() => { + if (logsEndRef.current && isExpanded) { + logsEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [logs, isExpanded]); + + return ( +
+ + {isExpanded && ( +
+ {logs.map((log, index) => ( +
+ {log} +
+ ))} +
+
+ )} +
+ ); +}; + +export default LogViewer; \ No newline at end of file diff --git a/web/src/components/wizard/validation/StatusIndicator.tsx b/web/src/components/wizard/validation/StatusIndicator.tsx new file mode 100644 index 000000000..8eaf6043d --- /dev/null +++ b/web/src/components/wizard/validation/StatusIndicator.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Server, CheckCircle, XCircle, AlertTriangle, Loader2 } from 'lucide-react'; + +interface StatusIndicatorProps { + title: string; + status: 'pending' | 'in-progress' | 'completed' | 'failed'; +} + +const StatusIndicator: React.FC = ({ title, status }) => { + let Icon; + let statusColor; + let statusText; + + switch (status) { + case 'completed': + Icon = CheckCircle; + statusColor = 'text-green-500'; + statusText = 'Complete'; + break; + case 'failed': + Icon = XCircle; + statusColor = 'text-red-500'; + statusText = 'Failed'; + break; + case 'in-progress': + Icon = Loader2; + statusColor = 'text-blue-500'; + statusText = 'Installing...'; + break; + default: + Icon = AlertTriangle; + statusColor = 'text-gray-400'; + statusText = 'Pending'; + } + + return ( +
+
+ +
+
+

{title}

+
+
+
+ + {statusText} +
+
+
+ ); +}; + +export default StatusIndicator; \ No newline at end of file diff --git a/web/src/contexts/BrandingContext.tsx b/web/src/contexts/BrandingContext.tsx new file mode 100644 index 000000000..eb954f83a --- /dev/null +++ b/web/src/contexts/BrandingContext.tsx @@ -0,0 +1,66 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; + +interface Branding { + appTitle: string; + appIcon?: string; +} + +interface BrandingContextType { + branding: Branding | null; + isLoading: boolean; + error: Error | null; +} + +const BrandingContext = createContext(undefined); + +export const useBranding = () => { + const context = useContext(BrandingContext); + if (!context) { + throw new Error('useBranding must be used within a BrandingProvider'); + } + return context; +}; + +export const BrandingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [branding, setBranding] = useState({ appTitle: "App" }); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (branding?.appTitle) { + document.title = branding.appTitle; + } + }, [branding?.appTitle]); + + useEffect(() => { + const fetchBranding = async () => { + try { + const response = await fetch('/api/branding', { + headers: { + // Include auth credentials if available from localStorage or another source + ...(localStorage.getItem('auth') && { + 'Authorization': `Bearer ${localStorage.getItem('auth')}`, + }), + }, + }); + if (!response.ok) { + throw new Error('Failed to fetch branding'); + } + const data = await response.json(); + setBranding(data.branding); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to fetch branding')); + } finally { + setIsLoading(false); + } + }; + + fetchBranding(); + }, []); + + return ( + + {children} + + ); +}; diff --git a/web/src/contexts/ConfigContext.tsx b/web/src/contexts/ConfigContext.tsx new file mode 100644 index 000000000..ef152541f --- /dev/null +++ b/web/src/contexts/ConfigContext.tsx @@ -0,0 +1,131 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; + +export interface ClusterConfig { + clusterName: string; + namespace: string; + storageClass: string; + domain: string; + useHttps: boolean; + adminUsername: string; + adminPassword: string; + adminEmail: string; + adminConsolePort?: number; + localArtifactMirrorPort?: number; + databaseType: 'internal' | 'external'; + dataDirectory: string; + useProxy: boolean; + httpProxy?: string; + httpsProxy?: string; + noProxy?: string; + networkInterface?: string; + globalCidr?: string; + databaseConfig?: { + host: string; + port: number; + username: string; + password: string; + database: string; + }; +} + +interface PrototypeSettings { + skipValidation: boolean; + failPreflights: boolean; + failInstallation: boolean; + failHostPreflights: boolean; + clusterMode: 'existing' | 'embedded'; + themeColor: string; + skipNodeValidation: boolean; + useSelfSignedCert: boolean; + enableMultiNode: boolean; + availableNetworkInterfaces: Array<{ + name: string; + }>; +} + +interface ConfigContextType { + config: ClusterConfig; + prototypeSettings: PrototypeSettings; + updateConfig: (newConfig: Partial) => void; + updatePrototypeSettings: (newSettings: Partial) => void; + resetConfig: () => void; +} + +const defaultConfig: ClusterConfig = { + clusterName: '', + namespace: '', + storageClass: 'standard', + domain: '', + useHttps: true, + adminUsername: 'admin', + adminPassword: '', + adminEmail: '', + databaseType: 'internal', + dataDirectory: '/var/lib/embedded-cluster', + useProxy: false, +}; + +const defaultPrototypeSettings: PrototypeSettings = { + skipValidation: false, + failPreflights: false, + failInstallation: false, + failHostPreflights: false, + clusterMode: 'embedded', + themeColor: '#316DE6', + skipNodeValidation: false, + useSelfSignedCert: false, + enableMultiNode: true, + availableNetworkInterfaces: [] +}; + +const PROTOTYPE_SETTINGS_KEY = 'app-prototype-settings'; + +const ConfigContext = createContext(undefined); + +export const ConfigProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [config, setConfig] = useState(defaultConfig); + const [prototypeSettings, setPrototypeSettings] = useState(() => { + const saved = localStorage.getItem(PROTOTYPE_SETTINGS_KEY); + const settings = saved ? JSON.parse(saved) : defaultPrototypeSettings; + if (!settings.themeColor) { + settings.themeColor = defaultPrototypeSettings.themeColor; + } + return settings; + }); + + useEffect(() => { + localStorage.setItem(PROTOTYPE_SETTINGS_KEY, JSON.stringify(prototypeSettings)); + }, [prototypeSettings]); + + const updateConfig = (newConfig: Partial) => { + setConfig((prev) => ({ ...prev, ...newConfig })); + }; + + const updatePrototypeSettings = (newSettings: Partial) => { + setPrototypeSettings((prev) => { + const updated = { ...prev, ...newSettings }; + if (!updated.themeColor) { + updated.themeColor = defaultPrototypeSettings.themeColor; + } + return updated; + }); + }; + + const resetConfig = () => { + setConfig(defaultConfig); + }; + + return ( + + {children} + + ); +}; + +export const useConfig = (): ConfigContextType => { + const context = useContext(ConfigContext); + if (context === undefined) { + throw new Error('useConfig must be used within a ConfigProvider'); + } + return context; +}; \ No newline at end of file diff --git a/web/src/contexts/WizardModeContext.tsx b/web/src/contexts/WizardModeContext.tsx new file mode 100644 index 000000000..f0257f90b --- /dev/null +++ b/web/src/contexts/WizardModeContext.tsx @@ -0,0 +1,88 @@ +import React, { createContext, useContext } from 'react'; +import { useConfig } from './ConfigContext'; +import { useBranding } from './BrandingContext'; + +export type WizardMode = 'install' | 'upgrade'; + +interface WizardText { + title: string; + subtitle: string; + welcomeTitle: string; + welcomeDescription: string; + setupTitle: string; + setupDescription: string; + configurationTitle: string; + configurationDescription: string; + installationTitle: string; + installationDescription: string; + completionTitle: string; + completionDescription: string; + welcomeButtonText: string; + nextButtonText: string; +} + +const getTextVariations = (isEmbedded: boolean, appTitle: string): Record => ({ + install: { + title: appTitle || '', + subtitle: 'Installation Wizard', + welcomeTitle: `Welcome to ${appTitle}`, + welcomeDescription: `This wizard will guide you through installing ${appTitle} on your ${isEmbedded ? 'Linux machine' : 'Kubernetes cluster'}.`, + setupTitle: 'Setup', + setupDescription: 'Set up the hosts to use for this installation.', + configurationTitle: 'Configuration', + configurationDescription: `Configure your ${appTitle} installation by providing the information below.`, + installationTitle: `Installing ${appTitle}`, + installationDescription: '', + completionTitle: 'Installation Complete!', + completionDescription: `${appTitle} has been installed successfully.`, + welcomeButtonText: 'Start', + nextButtonText: 'Next: Start Installation', + }, + upgrade: { + title: appTitle || '', + subtitle: 'Upgrade Wizard', + welcomeTitle: `Welcome to ${appTitle}`, + welcomeDescription: `This wizard will guide you through upgrading ${appTitle} on your ${isEmbedded ? 'Linux machine' : 'Kubernetes cluster'}.`, + setupTitle: 'Setup', + setupDescription: 'Set up the hosts to use for this installation.', + configurationTitle: 'Upgrade Configuration', + configurationDescription: `Configure your ${appTitle} installation by providing the information below.`, + installationTitle: `Upgrading ${appTitle}`, + installationDescription: '', + completionTitle: 'Upgrade Complete!', + completionDescription: `${appTitle} has been successfully upgraded.`, + welcomeButtonText: 'Start Upgrade', + nextButtonText: 'Next: Start Upgrade', + }, +}); + +interface WizardModeContextType { + mode: WizardMode; + text: WizardText; +} + +const WizardModeContext = createContext(undefined); + +export const WizardModeProvider: React.FC<{ + children: React.ReactNode; + mode: WizardMode; +}> = ({ children, mode }) => { + const { prototypeSettings } = useConfig(); + const { branding } = useBranding(); + const isEmbedded = prototypeSettings.clusterMode === 'embedded'; + const text = getTextVariations(isEmbedded, branding?.appTitle || '')[mode]; + + return ( + + {children} + + ); +}; + +export const useWizardMode = (): WizardModeContextType => { + const context = useContext(WizardModeContext); + if (context === undefined) { + throw new Error('useWizardMode must be used within a WizardModeProvider'); + } + return context; +}; \ No newline at end of file diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 000000000..b5c61c956 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 000000000..ea9e3630a --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App.tsx'; +import './index.css'; + +createRoot(document.getElementById('root')!).render( + + + +); diff --git a/web/src/types/index.ts b/web/src/types/index.ts new file mode 100644 index 000000000..804f3c28c --- /dev/null +++ b/web/src/types/index.ts @@ -0,0 +1,16 @@ +export interface InstallationStatus { + openebs: 'pending' | 'in-progress' | 'completed' | 'failed'; + registry: 'pending' | 'in-progress' | 'completed' | 'failed'; + velero: 'pending' | 'in-progress' | 'completed' | 'failed'; + components: 'pending' | 'in-progress' | 'completed' | 'failed'; + database: 'pending' | 'in-progress' | 'completed' | 'failed'; + core: 'pending' | 'in-progress' | 'completed' | 'failed'; + plugins: 'pending' | 'in-progress' | 'completed' | 'failed'; + overall: 'pending' | 'in-progress' | 'completed' | 'failed'; + currentMessage: string; + error?: string; + logs: string[]; + progress: number; +} + +export type WizardStep = 'welcome' | 'setup' | 'validation' | 'installation' | 'completion'; diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/static.go b/web/static.go new file mode 100644 index 000000000..0d3aef520 --- /dev/null +++ b/web/static.go @@ -0,0 +1,23 @@ +package web + +import ( + "embed" + "io/fs" +) + +//go:embed dist +var static embed.FS + +var staticFS fs.FS + +func init() { + var err error + staticFS, err = fs.Sub(static, "dist") + if err != nil { + panic(err) + } +} + +func Fs() fs.FS { + return staticFS +} diff --git a/web/tailwind.config.js b/web/tailwind.config.js new file mode 100644 index 000000000..d21f1cdae --- /dev/null +++ b/web/tailwind.config.js @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 000000000..f0a235055 --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 000000000..1ffef600d --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 000000000..0d3d71446 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 000000000..147380aff --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + optimizeDeps: { + exclude: ['lucide-react'], + }, +});