diff --git a/.env.example b/.env.example index 2d736a7..3c7840a 100644 --- a/.env.example +++ b/.env.example @@ -97,6 +97,22 @@ AWS_BEDROCK_CONFIG= # Include this environment variable if you want more logging for debugging locally VITE_LOG_LEVEL=debug +# Get your GitHub Personal Access Token here - +# https://github.com/settings/tokens +# This token is used for: +# 1. Importing/cloning GitHub repositories without rate limiting +# 2. Accessing private repositories +# 3. Automatic GitHub authentication (no need to manually connect in the UI) +# +# For classic tokens, ensure it has these scopes: repo, read:org, read:user +# For fine-grained tokens, ensure it has Repository and Organization access +VITE_GITHUB_ACCESS_TOKEN= + +# Specify the type of GitHub token you're using +# Can be 'classic' or 'fine-grained' +# Classic tokens are recommended for broader access +VITE_GITHUB_TOKEN_TYPE=classic + # Example Context Values for qwen2.5-coder:32b # # DEFAULT_NUM_CTX=32768 # Consumes 36GB of VRAM diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..8fe4367 --- /dev/null +++ b/.env.production @@ -0,0 +1,115 @@ +# Rename this file to .env once you have filled in the below environment variables! + +# Get your GROQ API Key here - +# https://console.groq.com/keys +# You only need this environment variable set if you want to use Groq models +GROQ_API_KEY= + +# Get your HuggingFace API Key here - +# https://huggingface.co/settings/tokens +# You only need this environment variable set if you want to use HuggingFace models +HuggingFace_API_KEY= + +# Get your Open AI API Key by following these instructions - +# https://help.openai.com/en/articles/4936850-where-do-i-find-my-openai-api-key +# You only need this environment variable set if you want to use GPT models +OPENAI_API_KEY= + +# Get your Anthropic API Key in your account settings - +# https://console.anthropic.com/settings/keys +# You only need this environment variable set if you want to use Claude models +ANTHROPIC_API_KEY= + +# Get your OpenRouter API Key in your account settings - +# https://openrouter.ai/settings/keys +# You only need this environment variable set if you want to use OpenRouter models +OPEN_ROUTER_API_KEY= + +# Get your Google Generative AI API Key by following these instructions - +# https://console.cloud.google.com/apis/credentials +# You only need this environment variable set if you want to use Google Generative AI models +GOOGLE_GENERATIVE_AI_API_KEY= + +# You only need this environment variable set if you want to use oLLAMA models +# DONT USE http://localhost:11434 due to IPV6 issues +# USE EXAMPLE http://127.0.0.1:11434 +OLLAMA_API_BASE_URL= + +# You only need this environment variable set if you want to use OpenAI Like models +OPENAI_LIKE_API_BASE_URL= + +# You only need this environment variable set if you want to use Together AI models +TOGETHER_API_BASE_URL= + +# You only need this environment variable set if you want to use DeepSeek models through their API +DEEPSEEK_API_KEY= + +# Get your OpenAI Like API Key +OPENAI_LIKE_API_KEY= + +# Get your Together API Key +TOGETHER_API_KEY= + +# You only need this environment variable set if you want to use Hyperbolic models +HYPERBOLIC_API_KEY= +HYPERBOLIC_API_BASE_URL= + +# Get your Mistral API Key by following these instructions - +# https://console.mistral.ai/api-keys/ +# You only need this environment variable set if you want to use Mistral models +MISTRAL_API_KEY= + +# Get the Cohere Api key by following these instructions - +# https://dashboard.cohere.com/api-keys +# You only need this environment variable set if you want to use Cohere models +COHERE_API_KEY= + +# Get LMStudio Base URL from LM Studio Developer Console +# Make sure to enable CORS +# DONT USE http://localhost:1234 due to IPV6 issues +# Example: http://127.0.0.1:1234 +LMSTUDIO_API_BASE_URL= + +# Get your xAI API key +# https://x.ai/api +# You only need this environment variable set if you want to use xAI models +XAI_API_KEY= + +# Get your Perplexity API Key here - +# https://www.perplexity.ai/settings/api +# You only need this environment variable set if you want to use Perplexity models +PERPLEXITY_API_KEY= + +# Get your AWS configuration +# https://console.aws.amazon.com/iam/home +AWS_BEDROCK_CONFIG= + +# Include this environment variable if you want more logging for debugging locally +VITE_LOG_LEVEL= + +# Get your GitHub Personal Access Token here - +# https://github.com/settings/tokens +# This token is used for: +# 1. Importing/cloning GitHub repositories without rate limiting +# 2. Accessing private repositories +# 3. Automatic GitHub authentication (no need to manually connect in the UI) +# +# For classic tokens, ensure it has these scopes: repo, read:org, read:user +# For fine-grained tokens, ensure it has Repository and Organization access +VITE_GITHUB_ACCESS_TOKEN= + +# Specify the type of GitHub token you're using +# Can be 'classic' or 'fine-grained' +# Classic tokens are recommended for broader access +VITE_GITHUB_TOKEN_TYPE= + +# Netlify Authentication +VITE_NETLIFY_ACCESS_TOKEN= + +# Example Context Values for qwen2.5-coder:32b +# +# DEFAULT_NUM_CTX=32768 # Consumes 36GB of VRAM +# DEFAULT_NUM_CTX=24576 # Consumes 32GB of VRAM +# DEFAULT_NUM_CTX=12288 # Consumes 26GB of VRAM +# DEFAULT_NUM_CTX=6144 # Consumes 24GB of VRAM +DEFAULT_NUM_CTX= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 168f84c..4bc03e1 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ dist-ssr /.history /.cache /build +functions/build/ .env.local .env .dev.vars diff --git a/app/components/@settings/tabs/connections/ConnectionDiagnostics.tsx b/app/components/@settings/tabs/connections/ConnectionDiagnostics.tsx new file mode 100644 index 0000000..497d57b --- /dev/null +++ b/app/components/@settings/tabs/connections/ConnectionDiagnostics.tsx @@ -0,0 +1,377 @@ +import React, { useState } from 'react'; +import { toast } from 'react-toastify'; +import { Button } from '~/components/ui/Button'; +import { Badge } from '~/components/ui/Badge'; +import { classNames } from '~/utils/classNames'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible'; +import { CodeBracketIcon, ChevronDownIcon } from '@heroicons/react/24/outline'; + +/** + * A diagnostics component to help troubleshoot connection issues + */ +export default function ConnectionDiagnostics() { + const [diagnosticResults, setDiagnosticResults] = useState(null); + const [isRunning, setIsRunning] = useState(false); + const [showDetails, setShowDetails] = useState(false); + + // Run diagnostics when requested + const runDiagnostics = async () => { + try { + setIsRunning(true); + setDiagnosticResults(null); + + // Check browser-side storage + const localStorageChecks = { + githubConnection: localStorage.getItem('github_connection'), + netlifyConnection: localStorage.getItem('netlify_connection'), + }; + + // Get diagnostic data from server + const response = await fetch('/api/system/diagnostics'); + + if (!response.ok) { + throw new Error(`Diagnostics API error: ${response.status}`); + } + + const serverDiagnostics = await response.json(); + + // Get GitHub token if available + const githubToken = localStorageChecks.githubConnection + ? JSON.parse(localStorageChecks.githubConnection)?.token + : null; + + const authHeaders = { + ...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}), + 'Content-Type': 'application/json', + }; + + console.log('Testing GitHub endpoints with token:', githubToken ? 'present' : 'missing'); + + // Test GitHub API endpoints + const githubEndpoints = [ + { name: 'User', url: '/api/system/git-info?action=getUser' }, + { name: 'Repos', url: '/api/system/git-info?action=getRepos' }, + { name: 'Default', url: '/api/system/git-info' }, + ]; + + const githubResults = await Promise.all( + githubEndpoints.map(async (endpoint) => { + try { + const resp = await fetch(endpoint.url, { + headers: authHeaders, + }); + return { + endpoint: endpoint.name, + status: resp.status, + ok: resp.ok, + }; + } catch (error) { + return { + endpoint: endpoint.name, + error: error instanceof Error ? error.message : String(error), + ok: false, + }; + } + }), + ); + + // Check if Netlify token works + let netlifyUserCheck = null; + const netlifyToken = localStorageChecks.netlifyConnection + ? JSON.parse(localStorageChecks.netlifyConnection || '{"token":""}').token + : ''; + + if (netlifyToken) { + try { + const netlifyResp = await fetch('https://api.netlify.com/api/v1/user', { + headers: { + Authorization: `Bearer ${netlifyToken}`, + }, + }); + netlifyUserCheck = { + status: netlifyResp.status, + ok: netlifyResp.ok, + }; + } catch (error) { + netlifyUserCheck = { + error: error instanceof Error ? error.message : String(error), + ok: false, + }; + } + } + + // Compile results + const results = { + timestamp: new Date().toISOString(), + localStorage: { + hasGithubConnection: Boolean(localStorageChecks.githubConnection), + hasNetlifyConnection: Boolean(localStorageChecks.netlifyConnection), + githubConnectionParsed: localStorageChecks.githubConnection + ? JSON.parse(localStorageChecks.githubConnection) + : null, + netlifyConnectionParsed: localStorageChecks.netlifyConnection + ? JSON.parse(localStorageChecks.netlifyConnection) + : null, + }, + apiEndpoints: { + github: githubResults, + netlify: netlifyUserCheck, + }, + serverDiagnostics, + }; + + setDiagnosticResults(results); + + // Display simple results + if (results.localStorage.hasGithubConnection && results.apiEndpoints.github.some((r: { ok: boolean }) => !r.ok)) { + toast.error('GitHub API connections are failing. Try reconnecting.'); + } + + if (results.localStorage.hasNetlifyConnection && netlifyUserCheck && !netlifyUserCheck.ok) { + toast.error('Netlify API connection is failing. Try reconnecting.'); + } + + if (!results.localStorage.hasGithubConnection && !results.localStorage.hasNetlifyConnection) { + toast.info('No connection data found in browser storage.'); + } + } catch (error) { + console.error('Diagnostics error:', error); + toast.error('Error running diagnostics'); + setDiagnosticResults({ error: error instanceof Error ? error.message : String(error) }); + } finally { + setIsRunning(false); + } + }; + + // Helper to reset GitHub connection + const resetGitHubConnection = () => { + try { + localStorage.removeItem('github_connection'); + document.cookie = 'githubToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + document.cookie = 'githubUsername=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + document.cookie = 'git:github.com=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + toast.success('GitHub connection data cleared. Please refresh the page and reconnect.'); + } catch (error) { + console.error('Error clearing GitHub data:', error); + toast.error('Failed to clear GitHub connection data'); + } + }; + + // Helper to reset Netlify connection + const resetNetlifyConnection = () => { + try { + localStorage.removeItem('netlify_connection'); + document.cookie = 'netlifyToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + toast.success('Netlify connection data cleared. Please refresh the page and reconnect.'); + } catch (error) { + console.error('Error clearing Netlify data:', error); + toast.error('Failed to clear Netlify connection data'); + } + }; + + return ( +
+ {/* Connection Status Cards */} +
+ {/* GitHub Connection Card */} +
+
+
+
+ GitHub Connection +
+
+ {diagnosticResults ? ( + <> +
+ + {diagnosticResults.localStorage.hasGithubConnection ? 'Connected' : 'Not Connected'} + +
+ {diagnosticResults.localStorage.hasGithubConnection && ( + <> +
+
+ User: {diagnosticResults.localStorage.githubConnectionParsed?.user?.login || 'N/A'} +
+
+
+ API Status:{' '} + r.ok) + ? 'default' + : 'destructive' + } + className="ml-1" + > + {diagnosticResults.apiEndpoints.github.every((r: { ok: boolean }) => r.ok) ? 'OK' : 'Failed'} + +
+ + )} + {!diagnosticResults.localStorage.hasGithubConnection && ( + + )} + + ) : ( +
+
+
+ Run diagnostics to check connection status +
+
+ )} +
+ + {/* Netlify Connection Card */} +
+
+
+
+ Netlify Connection +
+
+ {diagnosticResults ? ( + <> +
+ + {diagnosticResults.localStorage.hasNetlifyConnection ? 'Connected' : 'Not Connected'} + +
+ {diagnosticResults.localStorage.hasNetlifyConnection && ( + <> +
+
+ User:{' '} + {diagnosticResults.localStorage.netlifyConnectionParsed?.user?.full_name || + diagnosticResults.localStorage.netlifyConnectionParsed?.user?.email || + 'N/A'} +
+
+
+ API Status:{' '} + + {diagnosticResults.apiEndpoints.netlify?.ok ? 'OK' : 'Failed'} + +
+ + )} + {!diagnosticResults.localStorage.hasNetlifyConnection && ( + + )} + + ) : ( +
+
+
+ Run diagnostics to check connection status +
+
+ )} +
+
+ + {/* Action Buttons */} +
+ + + + + +
+ + {/* Details Panel */} + {diagnosticResults && ( +
+ + +
+
+ + + Diagnostic Details + +
+ +
+
+ +
+
+                  {JSON.stringify(diagnosticResults, null, 2)}
+                
+
+
+
+
+ )} +
+ ); +} diff --git a/app/components/@settings/tabs/connections/ConnectionsTab.tsx b/app/components/@settings/tabs/connections/ConnectionsTab.tsx index 72ff643..c03bf4f 100644 --- a/app/components/@settings/tabs/connections/ConnectionsTab.tsx +++ b/app/components/@settings/tabs/connections/ConnectionsTab.tsx @@ -1,45 +1,180 @@ import { motion } from 'framer-motion'; -import React, { Suspense } from 'react'; +import React, { Suspense, useState } from 'react'; +import { classNames } from '~/utils/classNames'; +import ConnectionDiagnostics from './ConnectionDiagnostics'; +import { Button } from '~/components/ui/Button'; // Use React.lazy for dynamic imports -const GithubConnection = React.lazy(() => import('./GithubConnection')); +const GitHubConnection = React.lazy(() => import('./GithubConnection')); const NetlifyConnection = React.lazy(() => import('./NetlifyConnection')); // Loading fallback component const LoadingFallback = () => ( -
-
-
+
+
+
Loading connection...
); export default function ConnectionsTab() { + const [isEnvVarsExpanded, setIsEnvVarsExpanded] = useState(false); + const [showDiagnostics, setShowDiagnostics] = useState(false); + return ( -
+
{/* Header */} -
-

Connection Settings

+
+
+

+ Connection Settings +

+
+ -

+

Manage your external service connections and integrations

-
+ {/* Diagnostics Tool - Conditionally rendered */} + {showDiagnostics && } + + {/* Environment Variables Info - Collapsible */} + +
+ + + {isEnvVarsExpanded && ( +
+

+ You can configure connections using environment variables in your{' '} + + .env.local + {' '} + file: +

+
+
+ # GitHub Authentication +
+
+ VITE_GITHUB_ACCESS_TOKEN=your_token_here +
+
+ # Optional: Specify token type (defaults to 'classic' if not specified) +
+
+ VITE_GITHUB_TOKEN_TYPE=classic|fine-grained +
+
+ # Netlify Authentication +
+
+ VITE_NETLIFY_ACCESS_TOKEN=your_token_here +
+
+
+

+ Token types: +

+
    +
  • + classic - Personal Access Token with{' '} + + repo, read:org, read:user + {' '} + scopes +
  • +
  • + fine-grained - Fine-grained token with Repository and + Organization access +
  • +
+

+ When set, these variables will be used automatically without requiring manual connection. +

+
+
+ )} +
+
+ +
}> - + }>
+ + {/* Additional help text */} +
+

+ + Troubleshooting Tip: +

+

+ If you're having trouble with connections, try using the troubleshooting tool at the top of this page. It can + help diagnose and fix common connection issues. +

+

For persistent issues:

+
    +
  1. Check your browser console for errors
  2. +
  3. Verify that your tokens have the correct permissions
  4. +
  5. Try clearing your browser cache and cookies
  6. +
  7. Ensure your browser allows third-party cookies if using integrations
  8. +
+
); } diff --git a/app/components/@settings/tabs/connections/GithubConnection.tsx b/app/components/@settings/tabs/connections/GithubConnection.tsx index 789cf0b..03af3e4 100644 --- a/app/components/@settings/tabs/connections/GithubConnection.tsx +++ b/app/components/@settings/tabs/connections/GithubConnection.tsx @@ -4,6 +4,8 @@ import { toast } from 'react-toastify'; import { logStore } from '~/lib/stores/logs'; import { classNames } from '~/utils/classNames'; import Cookies from 'js-cookie'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible'; +import { Button } from '~/components/ui/Button'; interface GitHubUserResponse { login: string; @@ -51,12 +53,22 @@ interface GitHubLanguageStats { interface GitHubStats { repos: GitHubRepoInfo[]; - totalStars: number; - totalForks: number; - organizations: GitHubOrganization[]; recentActivity: GitHubEvent[]; languages: GitHubLanguageStats; totalGists: number; + publicRepos: number; + privateRepos: number; + stars: number; + forks: number; + followers: number; + publicGists: number; + privateGists: number; + lastUpdated: string; + + // Keep these for backward compatibility + totalStars?: number; + totalForks?: number; + organizations?: GitHubOrganization[]; } interface GitHubConnection { @@ -64,9 +76,24 @@ interface GitHubConnection { token: string; tokenType: 'classic' | 'fine-grained'; stats?: GitHubStats; + rateLimit?: { + limit: number; + remaining: number; + reset: number; + }; } -export default function GithubConnection() { +// Add the GitHub logo SVG component +const GithubLogo = () => ( + + + +); + +export default function GitHubConnection() { const [connection, setConnection] = useState({ user: null, token: '', @@ -76,152 +103,320 @@ export default function GithubConnection() { const [isConnecting, setIsConnecting] = useState(false); const [isFetchingStats, setIsFetchingStats] = useState(false); const [isStatsExpanded, setIsStatsExpanded] = useState(false); + const tokenTypeRef = React.useRef<'classic' | 'fine-grained'>('classic'); const fetchGithubUser = async (token: string) => { try { - setIsConnecting(true); + console.log('Fetching GitHub user with token:', token.substring(0, 5) + '...'); - const response = await fetch('https://api.github.com/user', { + // Use server-side API endpoint instead of direct GitHub API call + const response = await fetch(`/api/system/git-info?action=getUser`, { + method: 'GET', headers: { - Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, // Include token in headers for validation }, }); if (!response.ok) { - throw new Error('Invalid token or unauthorized'); + console.error('Error fetching GitHub user. Status:', response.status); + throw new Error(`Error: ${response.status}`); } - const data = (await response.json()) as GitHubUserResponse; - const newConnection: GitHubConnection = { - user: data, - token, - tokenType: connection.tokenType, + // Get rate limit information from headers + const rateLimit = { + limit: parseInt(response.headers.get('x-ratelimit-limit') || '0'), + remaining: parseInt(response.headers.get('x-ratelimit-remaining') || '0'), + reset: parseInt(response.headers.get('x-ratelimit-reset') || '0'), }; - localStorage.setItem('github_connection', JSON.stringify(newConnection)); + const data = await response.json(); + console.log('GitHub user API response:', data); + + const { user } = data as { user: GitHubUserResponse }; + + // Validate that we received a user object + if (!user || !user.login) { + console.error('Invalid user data received:', user); + throw new Error('Invalid user data received'); + } + + // Use the response data + setConnection((prev) => ({ + ...prev, + user, + token, + tokenType: tokenTypeRef.current, + rateLimit, + })); + + // Set cookies for client-side access + Cookies.set('githubUsername', user.login); Cookies.set('githubToken', token); - Cookies.set('githubUsername', data.login); Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' })); - setConnection(newConnection); + // Store connection details in localStorage + localStorage.setItem( + 'github_connection', + JSON.stringify({ + user, + token, + tokenType: tokenTypeRef.current, + }), + ); - await fetchGitHubStats(token); + logStore.logInfo('Connected to GitHub', { + type: 'system', + message: `Connected to GitHub as ${user.login}`, + }); - toast.success('Successfully connected to GitHub'); + // Fetch additional GitHub stats + fetchGitHubStats(token); } catch (error) { - logStore.logError('Failed to authenticate with GitHub', { error }); - toast.error('Failed to connect to GitHub'); - setConnection({ user: null, token: '', tokenType: 'classic' }); - } finally { - setIsConnecting(false); + console.error('Failed to fetch GitHub user:', error); + logStore.logError(`GitHub authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`, { + type: 'system', + message: 'GitHub authentication failed', + }); + + toast.error(`Authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + throw error; // Rethrow to allow handling in the calling function } }; const fetchGitHubStats = async (token: string) => { + setIsFetchingStats(true); + try { - setIsFetchingStats(true); - - const reposResponse = await fetch( - 'https://api.github.com/user/repos?sort=updated&per_page=10&affiliation=owner,organization_member,collaborator', - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, - ); - - if (!reposResponse.ok) { - throw new Error('Failed to fetch repositories'); - } - - const repos = (await reposResponse.json()) as GitHubRepoInfo[]; - - const orgsResponse = await fetch('https://api.github.com/user/orgs', { + // Get the current user first to ensure we have the latest value + const userResponse = await fetch('https://api.github.com/user', { headers: { - Authorization: `Bearer ${token}`, + Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`, }, }); - if (!orgsResponse.ok) { - throw new Error('Failed to fetch organizations'); + if (!userResponse.ok) { + if (userResponse.status === 401) { + toast.error('Your GitHub token has expired. Please reconnect your account.'); + handleDisconnect(); + + return; + } + + throw new Error(`Failed to fetch user data: ${userResponse.statusText}`); } - const organizations = (await orgsResponse.json()) as GitHubOrganization[]; + const userData = (await userResponse.json()) as any; - const eventsResponse = await fetch('https://api.github.com/users/' + connection.user?.login + '/events/public', { + // Fetch repositories with pagination + let allRepos: any[] = []; + let page = 1; + let hasMore = true; + + while (hasMore) { + const reposResponse = await fetch(`https://api.github.com/user/repos?per_page=100&page=${page}`, { + headers: { + Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`, + }, + }); + + if (!reposResponse.ok) { + throw new Error(`Failed to fetch repositories: ${reposResponse.statusText}`); + } + + const repos = (await reposResponse.json()) as any[]; + allRepos = [...allRepos, ...repos]; + + // Check if there are more pages + const linkHeader = reposResponse.headers.get('Link'); + hasMore = linkHeader?.includes('rel="next"') ?? false; + page++; + } + + // Calculate stats + const repoStats = calculateRepoStats(allRepos); + + // Fetch recent activity + const eventsResponse = await fetch(`https://api.github.com/users/${userData.login}/events?per_page=10`, { headers: { - Authorization: `Bearer ${token}`, + Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`, }, }); if (!eventsResponse.ok) { - throw new Error('Failed to fetch events'); + throw new Error(`Failed to fetch events: ${eventsResponse.statusText}`); } - const recentActivity = ((await eventsResponse.json()) as GitHubEvent[]).slice(0, 5); - - const languagePromises = repos.map((repo) => - fetch(repo.languages_url, { - headers: { - Authorization: `Bearer ${token}`, - }, - }).then((res) => res.json() as Promise>), - ); - - const repoLanguages = await Promise.all(languagePromises); - const languages: GitHubLanguageStats = {}; - - repoLanguages.forEach((repoLang) => { - Object.entries(repoLang).forEach(([lang, bytes]) => { - languages[lang] = (languages[lang] || 0) + bytes; - }); - }); - - const totalStars = repos.reduce((acc, repo) => acc + repo.stargazers_count, 0); - const totalForks = repos.reduce((acc, repo) => acc + repo.forks_count, 0); - const totalGists = connection.user?.public_gists || 0; - - setConnection((prev) => ({ - ...prev, - stats: { - repos, - totalStars, - totalForks, - organizations, - recentActivity, - languages, - totalGists, - }, + const events = (await eventsResponse.json()) as any[]; + const recentActivity = events.slice(0, 5).map((event: any) => ({ + id: event.id, + type: event.type, + repo: event.repo.name, + created_at: event.created_at, })); + + // Calculate total stars and forks + const totalStars = allRepos.reduce((sum: number, repo: any) => sum + repo.stargazers_count, 0); + const totalForks = allRepos.reduce((sum: number, repo: any) => sum + repo.forks_count, 0); + const privateRepos = allRepos.filter((repo: any) => repo.private).length; + + // Update the stats in the store + const stats: GitHubStats = { + repos: repoStats.repos, + recentActivity, + languages: repoStats.languages || {}, + totalGists: repoStats.totalGists || 0, + publicRepos: userData.public_repos || 0, + privateRepos: privateRepos || 0, + stars: totalStars || 0, + forks: totalForks || 0, + followers: userData.followers || 0, + publicGists: userData.public_gists || 0, + privateGists: userData.private_gists || 0, + lastUpdated: new Date().toISOString(), + + // For backward compatibility + totalStars: totalStars || 0, + totalForks: totalForks || 0, + organizations: [], + }; + + // Get the current user first to ensure we have the latest value + const currentConnection = JSON.parse(localStorage.getItem('github_connection') || '{}'); + const currentUser = currentConnection.user || connection.user; + + // Update connection with stats + const updatedConnection: GitHubConnection = { + user: currentUser, + token, + tokenType: connection.tokenType, + stats, + rateLimit: connection.rateLimit, + }; + + // Update localStorage + localStorage.setItem('github_connection', JSON.stringify(updatedConnection)); + + // Update state + setConnection(updatedConnection); + + toast.success('GitHub stats refreshed'); } catch (error) { - logStore.logError('Failed to fetch GitHub stats', { error }); - toast.error('Failed to fetch GitHub statistics'); + console.error('Error fetching GitHub stats:', error); + toast.error(`Failed to fetch GitHub stats: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setIsFetchingStats(false); } }; + const calculateRepoStats = (repos: any[]) => { + const repoStats = { + repos: repos.map((repo: any) => ({ + name: repo.name, + full_name: repo.full_name, + html_url: repo.html_url, + description: repo.description, + stargazers_count: repo.stargazers_count, + forks_count: repo.forks_count, + default_branch: repo.default_branch, + updated_at: repo.updated_at, + languages_url: repo.languages_url, + })), + + languages: {} as Record, + totalGists: 0, + }; + + repos.forEach((repo: any) => { + fetch(repo.languages_url) + .then((response) => response.json()) + .then((languages: any) => { + const typedLanguages = languages as Record; + Object.keys(typedLanguages).forEach((language) => { + if (!repoStats.languages[language]) { + repoStats.languages[language] = 0; + } + + repoStats.languages[language] += 1; + }); + }); + }); + + return repoStats; + }; + useEffect(() => { - const savedConnection = localStorage.getItem('github_connection'); + const loadSavedConnection = async () => { + setIsLoading(true); - if (savedConnection) { - const parsed = JSON.parse(savedConnection); + const savedConnection = localStorage.getItem('github_connection'); - if (!parsed.tokenType) { - parsed.tokenType = 'classic'; + if (savedConnection) { + try { + const parsed = JSON.parse(savedConnection); + + if (!parsed.tokenType) { + parsed.tokenType = 'classic'; + } + + // Update the ref with the parsed token type + tokenTypeRef.current = parsed.tokenType; + + // Set the connection + setConnection(parsed); + + // If we have a token but no stats or incomplete stats, fetch them + if ( + parsed.user && + parsed.token && + (!parsed.stats || !parsed.stats.repos || parsed.stats.repos.length === 0) + ) { + console.log('Fetching missing GitHub stats for saved connection'); + await fetchGitHubStats(parsed.token); + } + } catch (error) { + console.error('Error parsing saved GitHub connection:', error); + localStorage.removeItem('github_connection'); + } + } else { + // Check for environment variable token + const envToken = import.meta.env.VITE_GITHUB_ACCESS_TOKEN; + + if (envToken) { + // Check if token type is specified in environment variables + const envTokenType = import.meta.env.VITE_GITHUB_TOKEN_TYPE; + console.log('Environment token type:', envTokenType); + + const tokenType = + envTokenType === 'classic' || envTokenType === 'fine-grained' + ? (envTokenType as 'classic' | 'fine-grained') + : 'classic'; + + console.log('Using token type:', tokenType); + + // Update both the state and the ref + tokenTypeRef.current = tokenType; + setConnection((prev) => ({ + ...prev, + tokenType, + })); + + try { + // Fetch user data with the environment token + await fetchGithubUser(envToken); + } catch (error) { + console.error('Failed to connect with environment token:', error); + } + } } - setConnection(parsed); + setIsLoading(false); + }; - if (parsed.user && parsed.token) { - fetchGitHubStats(parsed.token); - } - } else if (import.meta.env.VITE_GITHUB_ACCESS_TOKEN) { - fetchGithubUser(import.meta.env.VITE_GITHUB_ACCESS_TOKEN); - } - - setIsLoading(false); + loadSavedConnection(); }, []); + + // Ensure cookies are updated when connection changes useEffect(() => { if (!connection) { return; @@ -229,57 +424,169 @@ export default function GithubConnection() { const token = connection.token; const data = connection.user; - Cookies.set('githubToken', token); - Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' })); + + if (token) { + Cookies.set('githubToken', token); + Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' })); + } if (data) { Cookies.set('githubUsername', data.login); } }, [connection]); + // Add function to update rate limits + const updateRateLimits = async (token: string) => { + try { + const response = await fetch('https://api.github.com/rate_limit', { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github.v3+json', + }, + }); + + if (response.ok) { + const rateLimit = { + limit: parseInt(response.headers.get('x-ratelimit-limit') || '0'), + remaining: parseInt(response.headers.get('x-ratelimit-remaining') || '0'), + reset: parseInt(response.headers.get('x-ratelimit-reset') || '0'), + }; + + setConnection((prev) => ({ + ...prev, + rateLimit, + })); + } + } catch (error) { + console.error('Failed to fetch rate limits:', error); + } + }; + + // Add effect to update rate limits periodically + useEffect(() => { + let interval: NodeJS.Timeout; + + if (connection.token && connection.user) { + updateRateLimits(connection.token); + interval = setInterval(() => updateRateLimits(connection.token), 60000); // Update every minute + } + + return () => { + if (interval) { + clearInterval(interval); + } + }; + }, [connection.token, connection.user]); + if (isLoading || isConnecting || isFetchingStats) { return ; } const handleConnect = async (event: React.FormEvent) => { event.preventDefault(); - await fetchGithubUser(connection.token); + setIsConnecting(true); + + try { + // Update the ref with the current state value before connecting + tokenTypeRef.current = connection.tokenType; + + /* + * Save token type to localStorage even before connecting + * This ensures the token type is persisted even if connection fails + */ + localStorage.setItem( + 'github_connection', + JSON.stringify({ + user: null, + token: connection.token, + tokenType: connection.tokenType, + }), + ); + + // Attempt to fetch the user info which validates the token + await fetchGithubUser(connection.token); + + toast.success('Connected to GitHub successfully'); + } catch (error) { + console.error('Failed to connect to GitHub:', error); + + // Reset connection state on failure + setConnection({ user: null, token: connection.token, tokenType: connection.tokenType }); + + toast.error(`Failed to connect to GitHub: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + setIsConnecting(false); + } }; const handleDisconnect = () => { localStorage.removeItem('github_connection'); + + // Remove all GitHub-related cookies + Cookies.remove('githubToken'); + Cookies.remove('githubUsername'); + Cookies.remove('git:github.com'); + + // Reset the token type ref + tokenTypeRef.current = 'classic'; setConnection({ user: null, token: '', tokenType: 'classic' }); toast.success('Disconnected from GitHub'); }; return (
-
-
-

GitHub Connection

+
+
+ +

+ GitHub Connection +

+
+ {!connection.user && ( +
+

+ + Tip: You can also set the{' '} + + VITE_GITHUB_ACCESS_TOKEN + {' '} + environment variable to connect automatically. +

+

+ For fine-grained tokens, also set{' '} + + VITE_GITHUB_TOKEN_TYPE=fine-grained + +

+
+ )}
- + @@ -314,10 +621,11 @@ export default function GithubConnection() { href={`https://github.com/settings/tokens${connection.tokenType === 'fine-grained' ? '/beta' : '/new'}`} target="_blank" rel="noopener noreferrer" - className="text-purple-500 hover:underline inline-flex items-center gap-1" + className="text-bolt-elements-link-text dark:text-bolt-elements-link-text hover:text-bolt-elements-link-textHover dark:hover:text-bolt-elements-link-textHover flex items-center gap-1" > +
Get your token -
+
@@ -330,235 +638,340 @@ export default function GithubConnection() {
-
+
{!connection.user ? ( - + ) : ( - - )} - - {connection.user && ( - -
- Connected to GitHub - + <> +
+
+ +
+
+
+ + Connected to GitHub using{' '} + + {connection.tokenType === 'classic' ? 'PAT' : 'Fine-grained Token'} + + +
+ {connection.rateLimit && ( +
+
+ + API Limit: {connection.rateLimit.remaining.toLocaleString()}/ + {connection.rateLimit.limit.toLocaleString()} • Resets in{' '} + {Math.max(0, Math.floor((connection.rateLimit.reset * 1000 - Date.now()) / 60000))} min + +
+ )} +
+
+
+ + +
+
+ )}
{connection.user && connection.stats && ( -
- +
- {isStatsExpanded && ( -
- {connection.stats.organizations.length > 0 && ( + + +
+
+
+ GitHub Stats +
+
+
+ + +
+ {/* Languages Section */}
-

Organizations

-
- {connection.stats.organizations.map((org) => ( +

Top Languages

+
+ {Object.entries(connection.stats.languages) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([language]) => ( + + {language} + + ))} +
+
+ + {/* Additional Stats */} +
+ {[ + { + label: 'Member Since', + value: new Date(connection.user.created_at).toLocaleDateString(), + }, + { + label: 'Public Gists', + value: connection.stats.publicGists, + }, + { + label: 'Organizations', + value: connection.stats.organizations ? connection.stats.organizations.length : 0, + }, + { + label: 'Languages', + value: Object.keys(connection.stats.languages).length, + }, + ].map((stat, index) => ( +
+ {stat.label} + {stat.value} +
+ ))} +
+ + {/* Repository Stats */} +
+
+
+
Repository Stats
+
+ {[ + { + label: 'Public Repos', + value: connection.stats.publicRepos, + }, + { + label: 'Private Repos', + value: connection.stats.privateRepos, + }, + ].map((stat, index) => ( +
+ {stat.label} + {stat.value} +
+ ))} +
+
+ +
+
Contribution Stats
+
+ {[ + { + label: 'Stars', + value: connection.stats.stars || 0, + icon: 'i-ph:star', + iconColor: 'text-bolt-elements-icon-warning', + }, + { + label: 'Forks', + value: connection.stats.forks || 0, + icon: 'i-ph:git-fork', + iconColor: 'text-bolt-elements-icon-info', + }, + { + label: 'Followers', + value: connection.stats.followers || 0, + icon: 'i-ph:users', + iconColor: 'text-bolt-elements-icon-success', + }, + ].map((stat, index) => ( +
+ {stat.label} + +
+ {stat.value} + +
+ ))} +
+
+ +
+
Gists
+
+ {[ + { + label: 'Public', + value: connection.stats.publicGists, + }, + { + label: 'Private', + value: connection.stats.privateGists || 0, + }, + ].map((stat, index) => ( +
+ {stat.label} + {stat.value} +
+ ))} +
+
+ +
+ + Last updated: {new Date(connection.stats.lastUpdated).toLocaleString()} + +
+
+
+ + {/* Repositories Section */} +
+

Recent Repositories

+
+ {connection.stats.repos.map((repo) => ( - {org.login} - {org.login} +
+
+
+
+
+ {repo.name} +
+
+
+ +
+ {repo.stargazers_count.toLocaleString()} + + +
+ {repo.forks_count.toLocaleString()} + +
+
+ + {repo.description && ( +

+ {repo.description} +

+ )} + +
- )} - - {/* Languages Section */} -
-

Top Languages

-
- {Object.entries(connection.stats.languages) - .sort(([, a], [, b]) => b - a) - .slice(0, 5) - .map(([language]) => ( - - {language} - - ))} -
- - {/* Recent Activity Section */} -
-

Recent Activity

-
- {connection.stats.recentActivity.map((event) => ( -
-
-
- {event.type.replace('Event', '')} - on - - {event.repo.name} - -
-
- {new Date(event.created_at).toLocaleDateString()} at{' '} - {new Date(event.created_at).toLocaleTimeString()} -
-
- ))} -
-
- - {/* Additional Stats */} -
-
-
Member Since
-
- {new Date(connection.user.created_at).toLocaleDateString()} -
-
-
-
Public Gists
-
- {connection.stats.totalGists} -
-
-
-
Organizations
-
- {connection.stats.organizations.length} -
-
-
-
Languages
-
- {Object.keys(connection.stats.languages).length} -
-
-
- - {/* Repositories Section */} -

Recent Repositories

- diff --git a/app/components/@settings/tabs/connections/NetlifyConnection.tsx b/app/components/@settings/tabs/connections/NetlifyConnection.tsx index d811602..a2e27c1 100644 --- a/app/components/@settings/tabs/connections/NetlifyConnection.tsx +++ b/app/components/@settings/tabs/connections/NetlifyConnection.tsx @@ -1,263 +1,755 @@ -import React, { useEffect, useState } from 'react'; -import { motion } from 'framer-motion'; +import React, { useState, useEffect } from 'react'; import { toast } from 'react-toastify'; -import { useStore } from '@nanostores/react'; -import { logStore } from '~/lib/stores/logs'; import { classNames } from '~/utils/classNames'; +import { useStore } from '@nanostores/react'; +import { netlifyConnection, updateNetlifyConnection, initializeNetlifyConnection } from '~/lib/stores/netlify'; +import type { NetlifySite, NetlifyDeploy, NetlifyBuild, NetlifyUser } from '~/types/netlify'; import { - netlifyConnection, - isConnecting, - isFetchingStats, - updateNetlifyConnection, - fetchNetlifyStats, -} from '~/lib/stores/netlify'; -import type { NetlifyUser } from '~/types/netlify'; + CloudIcon, + BuildingLibraryIcon, + ClockIcon, + CodeBracketIcon, + CheckCircleIcon, + XCircleIcon, + TrashIcon, + ArrowPathIcon, + LockClosedIcon, + LockOpenIcon, + RocketLaunchIcon, +} from '@heroicons/react/24/outline'; +import { Button } from '~/components/ui/Button'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible'; +import { formatDistanceToNow } from 'date-fns'; +import { Badge } from '~/components/ui/Badge'; + +// Add the Netlify logo SVG component at the top of the file +const NetlifyLogo = () => ( + + + +); + +// Add new interface for site actions +interface SiteAction { + name: string; + icon: React.ComponentType; + action: (siteId: string) => Promise; + requiresConfirmation?: boolean; + variant?: 'default' | 'destructive' | 'outline'; +} export default function NetlifyConnection() { const connection = useStore(netlifyConnection); - const connecting = useStore(isConnecting); - const fetchingStats = useStore(isFetchingStats); - const [isSitesExpanded, setIsSitesExpanded] = useState(false); + const [tokenInput, setTokenInput] = useState(''); + const [fetchingStats, setFetchingStats] = useState(false); + const [sites, setSites] = useState([]); + const [deploys, setDeploys] = useState([]); + const [builds, setBuilds] = useState([]); + const [deploymentCount, setDeploymentCount] = useState(0); + const [lastUpdated, setLastUpdated] = useState(''); + const [isStatsOpen, setIsStatsOpen] = useState(false); + const [activeSiteIndex, setActiveSiteIndex] = useState(0); + const [isActionLoading, setIsActionLoading] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); - useEffect(() => { - const fetchSites = async () => { - if (connection.user && connection.token) { - await fetchNetlifyStats(connection.token); - } - }; - fetchSites(); - }, [connection.user, connection.token]); + // Add site actions + const siteActions: SiteAction[] = [ + { + name: 'Clear Cache', + icon: ArrowPathIcon, + action: async (siteId: string) => { + try { + const response = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/cache`, { + method: 'POST', + headers: { + Authorization: `Bearer ${connection.token}`, + }, + }); - const handleConnect = async (event: React.FormEvent) => { - event.preventDefault(); - isConnecting.set(true); + if (!response.ok) { + throw new Error('Failed to clear cache'); + } + toast.success('Site cache cleared successfully'); + } catch (err: unknown) { + const error = err instanceof Error ? err.message : 'Unknown error'; + toast.error(`Failed to clear site cache: ${error}`); + } + }, + }, + { + name: 'Delete Site', + icon: TrashIcon, + action: async (siteId: string) => { + try { + const response = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${connection.token}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to delete site'); + } + + toast.success('Site deleted successfully'); + fetchNetlifyStats(connection.token); + } catch (err: unknown) { + const error = err instanceof Error ? err.message : 'Unknown error'; + toast.error(`Failed to delete site: ${error}`); + } + }, + requiresConfirmation: true, + variant: 'destructive', + }, + ]; + + // Add deploy management functions + const handleDeploy = async (siteId: string, deployId: string, action: 'lock' | 'unlock' | 'publish') => { try { - const response = await fetch('https://api.netlify.com/api/v1/user', { + setIsActionLoading(true); + + const endpoint = + action === 'publish' + ? `https://api.netlify.com/api/v1/sites/${siteId}/deploys/${deployId}/restore` + : `https://api.netlify.com/api/v1/deploys/${deployId}/${action}`; + + const response = await fetch(endpoint, { + method: 'POST', headers: { Authorization: `Bearer ${connection.token}`, - 'Content-Type': 'application/json', }, }); if (!response.ok) { - throw new Error('Invalid token or unauthorized'); + throw new Error(`Failed to ${action} deploy`); + } + + toast.success(`Deploy ${action}ed successfully`); + fetchNetlifyStats(connection.token); + } catch (err: unknown) { + const error = err instanceof Error ? err.message : 'Unknown error'; + toast.error(`Failed to ${action} deploy: ${error}`); + } finally { + setIsActionLoading(false); + } + }; + + useEffect(() => { + // Initialize connection with environment token if available + initializeNetlifyConnection(); + }, []); + + useEffect(() => { + // Check if we have a connection with a token but no stats + if (connection.user && connection.token && (!connection.stats || !connection.stats.sites)) { + fetchNetlifyStats(connection.token); + } + + // Update local state from connection + if (connection.stats) { + setSites(connection.stats.sites || []); + setDeploys(connection.stats.deploys || []); + setBuilds(connection.stats.builds || []); + setDeploymentCount(connection.stats.deploys?.length || 0); + setLastUpdated(connection.stats.lastDeployTime || ''); + } + }, [connection]); + + const handleConnect = async () => { + if (!tokenInput) { + toast.error('Please enter a Netlify API token'); + return; + } + + setIsConnecting(true); + + try { + const response = await fetch('https://api.netlify.com/api/v1/user', { + headers: { + Authorization: `Bearer ${tokenInput}`, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); } const userData = (await response.json()) as NetlifyUser; + + // Update the connection store updateNetlifyConnection({ user: userData, - token: connection.token, + token: tokenInput, }); - await fetchNetlifyStats(connection.token); - toast.success('Successfully connected to Netlify'); + toast.success('Connected to Netlify successfully'); + + // Fetch stats after successful connection + fetchNetlifyStats(tokenInput); } catch (error) { - console.error('Auth error:', error); - logStore.logError('Failed to authenticate with Netlify', { error }); - toast.error('Failed to connect to Netlify'); - updateNetlifyConnection({ user: null, token: '' }); + console.error('Error connecting to Netlify:', error); + toast.error(`Failed to connect to Netlify: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { - isConnecting.set(false); + setIsConnecting(false); + setTokenInput(''); } }; const handleDisconnect = () => { + // Clear from localStorage + localStorage.removeItem('netlify_connection'); + + // Remove cookies + document.cookie = 'netlifyToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + + // Update the store updateNetlifyConnection({ user: null, token: '' }); toast.success('Disconnected from Netlify'); }; + const fetchNetlifyStats = async (token: string) => { + setFetchingStats(true); + + try { + // Fetch sites + const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!sitesResponse.ok) { + throw new Error(`Failed to fetch sites: ${sitesResponse.statusText}`); + } + + const sitesData = (await sitesResponse.json()) as NetlifySite[]; + setSites(sitesData); + + // Fetch recent deploys for the first site (if any) + let deploysData: NetlifyDeploy[] = []; + let buildsData: NetlifyBuild[] = []; + let lastDeployTime = ''; + + if (sitesData && sitesData.length > 0) { + const firstSite = sitesData[0]; + + // Fetch deploys + const deploysResponse = await fetch(`https://api.netlify.com/api/v1/sites/${firstSite.id}/deploys`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (deploysResponse.ok) { + deploysData = (await deploysResponse.json()) as NetlifyDeploy[]; + setDeploys(deploysData); + setDeploymentCount(deploysData.length); + + // Get the latest deploy time + if (deploysData.length > 0) { + lastDeployTime = deploysData[0].created_at; + setLastUpdated(lastDeployTime); + + // Fetch builds for the site + const buildsResponse = await fetch(`https://api.netlify.com/api/v1/sites/${firstSite.id}/builds`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (buildsResponse.ok) { + buildsData = (await buildsResponse.json()) as NetlifyBuild[]; + setBuilds(buildsData); + } + } + } + } + + // Update the stats in the store + updateNetlifyConnection({ + stats: { + sites: sitesData, + deploys: deploysData, + builds: buildsData, + lastDeployTime, + totalSites: sitesData.length, + }, + }); + + toast.success('Netlify stats updated'); + } catch (error) { + console.error('Error fetching Netlify stats:', error); + toast.error(`Failed to fetch Netlify stats: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + setFetchingStats(false); + } + }; + + const renderStats = () => { + if (!connection.user || !connection.stats) { + return null; + } + + return ( +
+ + +
+
+
+ + Netlify Stats + +
+
+
+ + +
+
+ + + {connection.stats.totalSites} Sites + + + + {deploymentCount} Deployments + + {lastUpdated && ( + + + Updated {formatDistanceToNow(new Date(lastUpdated))} ago + + )} +
+ {sites.length > 0 && ( +
+
+
+

+ + Your Sites +

+ +
+
+ {sites.map((site, index) => ( +
{ + setActiveSiteIndex(index); + }} + > +
+
+ + + {site.name} + +
+
+ + {site.published_deploy?.state === 'ready' ? ( + + ) : ( + + )} + + {site.published_deploy?.state || 'Unknown'} + + +
+
+ + + + {activeSiteIndex === index && ( + <> +
+
+ {siteActions.map((action) => ( + + ))} +
+
+ {site.published_deploy && ( +
+
+ + + Published {formatDistanceToNow(new Date(site.published_deploy.published_at))} ago + +
+ {site.published_deploy.branch && ( +
+ + + Branch: {site.published_deploy.branch} + +
+ )} +
+ )} + + )} +
+ ))} +
+
+ {activeSiteIndex !== -1 && deploys.length > 0 && ( +
+
+

+ + Recent Deployments +

+
+
+ {deploys.map((deploy) => ( +
+
+
+ + {deploy.state === 'ready' ? ( + + ) : deploy.state === 'error' ? ( + + ) : ( + + )} + + {deploy.state} + + +
+ + {formatDistanceToNow(new Date(deploy.created_at))} ago + +
+ {deploy.branch && ( +
+ + + Branch: {deploy.branch} + +
+ )} + {deploy.deploy_url && ( + + )} +
+ + {deploy.state === 'ready' ? ( + + ) : ( + + )} +
+
+ ))} +
+
+ )} + {activeSiteIndex !== -1 && builds.length > 0 && ( +
+
+

+ + Recent Builds +

+
+
+ {builds.map((build) => ( +
+
+
+ + {build.done && !build.error ? ( + + ) : build.error ? ( + + ) : ( + + )} + + {build.done ? (build.error ? 'Failed' : 'Completed') : 'In Progress'} + + +
+ + {formatDistanceToNow(new Date(build.created_at))} ago + +
+ {build.error && ( +
+ + Error: {build.error} +
+ )} +
+ ))} +
+
+ )} +
+ )} +
+
+ +
+ ); + }; + return ( - -
+
+
- -

Netlify Connection

+
+ +
+

Netlify Connection

{!connection.user ? ( -
-
- - updateNetlifyConnection({ ...connection, token: e.target.value })} - disabled={connecting} - placeholder="Enter your Netlify personal access token" - className={classNames( - 'w-full px-3 py-2 rounded-lg text-sm', - 'bg-[#F8F8F8] dark:bg-[#1A1A1A]', - 'border border-[#E5E5E5] dark:border-[#333333]', - 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', - 'focus:outline-none focus:ring-1 focus:ring-[#00AD9F]', - 'disabled:opacity-50', - )} - /> - - - + /> +
+ +
+ Get your token +
+ +
+
+ +
) : ( -
-
-
- - -
+
+
+ + +
+ + Connected to Netlify
-
-
- {connection.user.full_name} -
-

{connection.user.full_name}

-

{connection.user.email}

-
-
- - {fetchingStats ? ( -
-
- Fetching Netlify sites... -
- ) : ( -
- - {isSitesExpanded && connection.stats?.sites?.length ? ( -
- {connection.stats.sites.map((site) => ( - -
-
-
-
- {site.name} -
-
- - {site.url} - - {site.published_deploy && ( - <> - - -
- {new Date(site.published_deploy.published_at).toLocaleDateString()} - - - )} -
-
- {site.build_settings?.provider && ( -
- -
- {site.build_settings.provider} - -
- )} -
- - ))} -
- ) : isSitesExpanded ? ( -
-
- No sites found in your Netlify account -
- ) : null} +
+ Dashboard + +
- )} +
+ {renderStats()}
)}
- +
); } diff --git a/app/components/@settings/tabs/connections/components/ConnectionForm.tsx b/app/components/@settings/tabs/connections/components/ConnectionForm.tsx index 04210e2..2c9876b 100644 --- a/app/components/@settings/tabs/connections/components/ConnectionForm.tsx +++ b/app/components/@settings/tabs/connections/components/ConnectionForm.tsx @@ -16,7 +16,7 @@ interface ConnectionFormProps { export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect }: ConnectionFormProps) { // Check for saved token on mount useEffect(() => { - const savedToken = Cookies.get(GITHUB_TOKEN_KEY) || getLocalStorage(GITHUB_TOKEN_KEY); + const savedToken = Cookies.get(GITHUB_TOKEN_KEY) || Cookies.get('githubToken') || getLocalStorage(GITHUB_TOKEN_KEY); if (savedToken && !authState.tokenInfo?.token) { setAuthState((prev: GitHubAuthState) => ({ @@ -30,6 +30,9 @@ export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect } followers: 0, }, })); + + // Ensure the token is also saved with the correct key for API requests + Cookies.set('githubToken', savedToken); } }, []); diff --git a/app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx b/app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx index 5f07e72..a175d88 100644 --- a/app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx +++ b/app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx @@ -1,4 +1,4 @@ -import type { GitHubRepoInfo, GitHubContent, RepositoryStats } from '~/types/GitHub'; +import type { GitHubRepoInfo, GitHubContent, RepositoryStats, GitHubUserResponse } from '~/types/GitHub'; import { useState, useEffect } from 'react'; import { toast } from 'react-toastify'; import * as Dialog from '@radix-ui/react-dialog'; @@ -7,6 +7,7 @@ import { getLocalStorage } from '~/lib/persistence'; import { motion } from 'framer-motion'; import { formatSize } from '~/utils/formatSize'; import { Input } from '~/components/ui/Input'; +import Cookies from 'js-cookie'; interface GitHubTreeResponse { tree: Array<{ @@ -122,6 +123,184 @@ function StatsDialog({ isOpen, onClose, onConfirm, stats, isLargeRepo }: StatsDi ); } +function GitHubAuthDialog({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) { + const [token, setToken] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [tokenType, setTokenType] = useState<'classic' | 'fine-grained'>('classic'); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!token.trim()) { + return; + } + + setIsSubmitting(true); + + try { + const response = await fetch('https://api.github.com/user', { + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `Bearer ${token}`, + }, + }); + + if (response.ok) { + const userData = (await response.json()) as GitHubUserResponse; + + // Save connection data + const connectionData = { + token, + tokenType, + user: { + login: userData.login, + avatar_url: userData.avatar_url, + name: userData.name || userData.login, + }, + connected_at: new Date().toISOString(), + }; + + localStorage.setItem('github_connection', JSON.stringify(connectionData)); + + // Set cookies for API requests + Cookies.set('githubToken', token); + Cookies.set('githubUsername', userData.login); + Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' })); + + toast.success(`Successfully connected as ${userData.login}`); + onClose(); + } else { + if (response.status === 401) { + toast.error('Invalid GitHub token. Please check and try again.'); + } else { + toast.error(`GitHub API error: ${response.status} ${response.statusText}`); + } + } + } catch (error) { + console.error('Error connecting to GitHub:', error); + toast.error('Failed to connect to GitHub. Please try again.'); + } finally { + setIsSubmitting(false); + } + }; + + return ( + !open && onClose()}> + + +
+ + +
+

Access Private Repositories

+ +

+ To access private repositories, you need to connect your GitHub account by providing a personal access + token. +

+ +
+

Connect with GitHub Token

+ +
+
+ + setToken(e.target.value)} + placeholder="ghp_xxxxxxxxxxxxxxxxxxxx" + className="w-full px-3 py-1.5 rounded-lg border border-[#E5E5E5] dark:border-[#333333] bg-white dark:bg-[#1A1A1A] text-[#111111] dark:text-white placeholder-[#999999] text-sm" + /> +
+ Get your token at{' '} + + github.com/settings/tokens + +
+
+ +
+ +
+ + +
+
+ + +
+
+ +
+

+ + Accessing Private Repositories +

+

+ Important things to know about accessing private repositories: +

+
    +
  • You must be granted access to the repository by its owner
  • +
  • Your GitHub token must have the 'repo' scope
  • +
  • For organization repositories, you may need additional permissions
  • +
  • No token can give you access to repositories you don't have permission for
  • +
+
+
+ +
+ + + +
+
+
+
+
+
+ ); +} + export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: RepositorySelectionDialogProps) { const [selectedRepository, setSelectedRepository] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -133,13 +312,78 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit const [branches, setBranches] = useState<{ name: string; default?: boolean }[]>([]); const [selectedBranch, setSelectedBranch] = useState(''); const [filters, setFilters] = useState({}); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [stats, setStats] = useState(null); const [showStatsDialog, setShowStatsDialog] = useState(false); const [currentStats, setCurrentStats] = useState(null); const [pendingGitUrl, setPendingGitUrl] = useState(''); + const [showAuthDialog, setShowAuthDialog] = useState(false); - // Fetch user's repositories when dialog opens + // Handle GitHub auth dialog close and refresh repositories + const handleAuthDialogClose = () => { + setShowAuthDialog(false); + + // If we're on the my-repos tab, refresh the repository list + if (activeTab === 'my-repos') { + fetchUserRepos(); + } + }; + + // Initialize GitHub connection and fetch repositories + useEffect(() => { + const savedConnection = getLocalStorage('github_connection'); + + // If no connection exists but environment variables are set, create a connection + if (!savedConnection && import.meta.env.VITE_GITHUB_ACCESS_TOKEN) { + const token = import.meta.env.VITE_GITHUB_ACCESS_TOKEN; + const tokenType = import.meta.env.VITE_GITHUB_TOKEN_TYPE === 'fine-grained' ? 'fine-grained' : 'classic'; + + // Fetch GitHub user info to initialize the connection + fetch('https://api.github.com/user', { + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `Bearer ${token}`, + }, + }) + .then((response) => { + if (!response.ok) { + throw new Error('Invalid token or unauthorized'); + } + + return response.json(); + }) + .then((data: unknown) => { + const userData = data as GitHubUserResponse; + + // Save connection to local storage + const newConnection = { + token, + tokenType, + user: { + login: userData.login, + avatar_url: userData.avatar_url, + name: userData.name || userData.login, + }, + connected_at: new Date().toISOString(), + }; + + localStorage.setItem('github_connection', JSON.stringify(newConnection)); + + // Also save as cookies for API requests + Cookies.set('githubToken', token); + Cookies.set('githubUsername', userData.login); + Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' })); + + // Refresh repositories after connection is established + if (isOpen && activeTab === 'my-repos') { + fetchUserRepos(); + } + }) + .catch((error) => { + console.error('Failed to initialize GitHub connection from environment variables:', error); + }); + } + }, [isOpen]); + + // Fetch repositories when dialog opens or tab changes useEffect(() => { if (isOpen && activeTab === 'my-repos') { fetchUserRepos(); @@ -159,6 +403,7 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit try { const response = await fetch('https://api.github.com/user/repos?sort=updated&per_page=100&type=all', { headers: { + Accept: 'application/vnd.github.v3+json', Authorization: `Bearer ${connection.token}`, }, }); @@ -238,10 +483,15 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit setIsLoading(true); try { + const connection = getLocalStorage('github_connection'); + const headers: HeadersInit = connection?.token + ? { + Accept: 'application/vnd.github.v3+json', + Authorization: `Bearer ${connection.token}`, + } + : {}; const response = await fetch(`https://api.github.com/repos/${repo.full_name}/branches`, { - headers: { - Authorization: `Bearer ${getLocalStorage('github_connection')?.token}`, - }, + headers, }); if (!response.ok) { @@ -285,34 +535,97 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit const verifyRepository = async (repoUrl: string): Promise => { try { - const [owner, repo] = repoUrl + // Extract branch from URL if present (format: url#branch) + let branch: string | null = null; + let cleanUrl = repoUrl; + + if (repoUrl.includes('#')) { + const parts = repoUrl.split('#'); + cleanUrl = parts[0]; + branch = parts[1]; + } + + const [owner, repo] = cleanUrl .replace(/\.git$/, '') .split('/') .slice(-2); + // Try to get token from local storage first const connection = getLocalStorage('github_connection'); - const headers: HeadersInit = connection?.token ? { Authorization: `Bearer ${connection.token}` } : {}; - const repoObjResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { - headers, - }); - const repoObjData = (await repoObjResponse.json()) as any; - if (!repoObjData.default_branch) { - throw new Error('Failed to fetch repository branch'); + // If no connection in local storage, check environment variables + let headers: HeadersInit = {}; + + if (connection?.token) { + headers = { + Accept: 'application/vnd.github.v3+json', + Authorization: `Bearer ${connection.token}`, + }; + } else if (import.meta.env.VITE_GITHUB_ACCESS_TOKEN) { + // Use token from environment variables + headers = { + Accept: 'application/vnd.github.v3+json', + Authorization: `Bearer ${import.meta.env.VITE_GITHUB_ACCESS_TOKEN}`, + }; } - const defaultBranch = repoObjData.default_branch; + // First, get the repository info to determine the default branch + const repoInfoResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { + headers, + }); - // Fetch repository tree - const treeResponse = await fetch( + if (!repoInfoResponse.ok) { + if (repoInfoResponse.status === 401 || repoInfoResponse.status === 403) { + throw new Error( + `Authentication failed (${repoInfoResponse.status}). Your GitHub token may be invalid or missing the required permissions.`, + ); + } else if (repoInfoResponse.status === 404) { + throw new Error( + `Repository not found or is private (${repoInfoResponse.status}). To access private repositories, you need to connect your GitHub account or provide a valid token with appropriate permissions.`, + ); + } else { + throw new Error( + `Failed to fetch repository information: ${repoInfoResponse.statusText} (${repoInfoResponse.status})`, + ); + } + } + + const repoInfo = (await repoInfoResponse.json()) as { default_branch: string }; + let defaultBranch = repoInfo.default_branch || 'main'; + + // If a branch was specified in the URL, use that instead of the default + if (branch) { + defaultBranch = branch; + } + + // Try to fetch the repository tree using the selected branch + let treeResponse = await fetch( `https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`, { headers, }, ); + // If the selected branch doesn't work, try common branch names if (!treeResponse.ok) { - throw new Error('Failed to fetch repository structure'); + // Try 'master' branch if default branch failed + treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/master?recursive=1`, { + headers, + }); + + // If master also fails, try 'main' branch + if (!treeResponse.ok) { + treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/main?recursive=1`, { + headers, + }); + } + + // If all common branches fail, throw an error + if (!treeResponse.ok) { + throw new Error( + 'Failed to fetch repository structure. Please check the repository URL and your access permissions.', + ); + } } const treeData = (await treeResponse.json()) as GitHubTreeResponse; @@ -369,12 +682,27 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit hasDependencies, }; - setStats(stats); - return stats; } catch (error) { console.error('Error verifying repository:', error); - toast.error('Failed to verify repository'); + + // Check if it's an authentication error and show the auth dialog + const errorMessage = error instanceof Error ? error.message : 'Failed to verify repository'; + + if ( + errorMessage.includes('Authentication failed') || + errorMessage.includes('may be private') || + errorMessage.includes('Repository not found or is private') || + errorMessage.includes('Unauthorized') || + errorMessage.includes('401') || + errorMessage.includes('403') || + errorMessage.includes('404') || + errorMessage.includes('access permissions') + ) { + setShowAuthDialog(true); + } + + toast.error(errorMessage); return null; } @@ -408,7 +736,36 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit setShowStatsDialog(true); } catch (error) { console.error('Error preparing repository:', error); - toast.error('Failed to prepare repository. Please try again.'); + + // Check if it's an authentication error + const errorMessage = error instanceof Error ? error.message : 'Failed to prepare repository. Please try again.'; + + // Show the GitHub auth dialog for any authentication or permission errors + if ( + errorMessage.includes('Authentication failed') || + errorMessage.includes('may be private') || + errorMessage.includes('Repository not found or is private') || + errorMessage.includes('Unauthorized') || + errorMessage.includes('401') || + errorMessage.includes('403') || + errorMessage.includes('404') || + errorMessage.includes('access permissions') + ) { + // Directly show the auth dialog instead of just showing a toast + setShowAuthDialog(true); + + toast.error( +
+

{errorMessage}

+ +
, + { autoClose: 10000 }, // Keep the toast visible longer + ); + } else { + toast.error(errorMessage); + } } }; @@ -441,182 +798,210 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit }; return ( - { - if (!open) { - handleClose(); - } - }} - > - - - -
- - Import GitHub Repository - - - -
- -
-
- setActiveTab('my-repos')}> - - My Repos - - setActiveTab('search')}> - - Search - - setActiveTab('url')}> - - URL - + <> + { + if (!open) { + handleClose(); + } + }} + > + + + +
+ + Import GitHub Repository + + +
- {activeTab === 'url' ? ( -
- setCustomUrl(e.target.value)} - className={classNames('w-full', { - 'border-red-500': false, - })} - /> - +
+
+ + + Need to access private repositories? +
- ) : ( - <> - {activeTab === 'search' && ( -
-
- { - setSearchQuery(e.target.value); - handleSearch(e.target.value); - }} - className="flex-1 px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] text-bolt-elements-textPrimary" - /> - -
-
- { - setFilters({ ...filters, language: e.target.value }); - handleSearch(searchQuery); - }} - className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]" - /> + +
+ +
+
+ setActiveTab('my-repos')}> + + My Repos + + setActiveTab('search')}> + + Search + + setActiveTab('url')}> + + URL + +
+ + {activeTab === 'url' ? ( +
+ setCustomUrl(e.target.value)} + className="w-full" + /> + + +
+ ) : ( + <> + {activeTab === 'search' && ( +
+
+ { + setSearchQuery(e.target.value); + handleSearch(e.target.value); + }} + className="flex-1 px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] text-bolt-elements-textPrimary" + /> + +
+
+ { + setFilters({ ...filters, language: e.target.value }); + handleSearch(searchQuery); + }} + className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]" + /> + handleFilterChange('stars', e.target.value)} + className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]" + /> +
handleFilterChange('stars', e.target.value)} + placeholder="Min forks..." + value={filters.forks || ''} + onChange={(e) => handleFilterChange('forks', e.target.value)} className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]" />
- handleFilterChange('forks', e.target.value)} - className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]" - /> -
- )} - -
- {selectedRepository ? ( -
-
- -

{selectedRepository.full_name}

-
-
- - - -
-
- ) : ( - )} -
- - )} -
- - - {currentStats && ( - 50 * 1024 * 1024} - /> - )} - + +
+ {selectedRepository ? ( +
+
+ +

{selectedRepository.full_name}

+
+
+ + + +
+
+ ) : ( + + )} +
+ + )} +
+ + + + {/* GitHub Auth Dialog */} + + + {/* Repository Stats Dialog */} + {currentStats && ( + setShowStatsDialog(false)} + onConfirm={handleStatsConfirm} + stats={currentStats} + isLargeRepo={currentStats.totalSize > 50 * 1024 * 1024} + /> + )} + + ); } @@ -670,7 +1055,7 @@ function RepositoryList({ function RepositoryCard({ repo, onSelect }: { repo: GitHubRepoInfo; onSelect: () => void }) { return ( -
+
diff --git a/app/components/chat/GitCloneButton.tsx b/app/components/chat/GitCloneButton.tsx index 28aa169..6cb2378 100644 --- a/app/components/chat/GitCloneButton.tsx +++ b/app/components/chat/GitCloneButton.tsx @@ -159,10 +159,10 @@ ${escapeBoltTags(file.content)} variant="outline" size="lg" className={classNames( - 'gap-2 bg-[#F5F5F5] dark:bg-[#252525]', - 'text-bolt-elements-textPrimary dark:text-white', - 'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]', - 'border-[#E5E5E5] dark:border-[#333333]', + 'gap-2 bg-bolt-elements-background-depth-1', + 'text-bolt-elements-textPrimary', + 'hover:bg-bolt-elements-background-depth-2', + 'border-[rgba(0,0,0,0.08)] dark:border-[rgba(255,255,255,0.08)]', 'h-10 px-4 py-2 min-w-[120px] justify-center', 'transition-all duration-200 ease-in-out', className, diff --git a/app/components/chat/ImportFolderButton.tsx b/app/components/chat/ImportFolderButton.tsx index 2788711..7d1801e 100644 --- a/app/components/chat/ImportFolderButton.tsx +++ b/app/components/chat/ImportFolderButton.tsx @@ -123,10 +123,10 @@ export const ImportFolderButton: React.FC = ({ classNam variant="outline" size="lg" className={classNames( - 'gap-2 bg-[#F5F5F5] dark:bg-[#252525]', - 'text-bolt-elements-textPrimary dark:text-white', - 'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]', - 'border-[#E5E5E5] dark:border-[#333333]', + 'gap-2 bg-bolt-elements-background-depth-1', + 'text-bolt-elements-textPrimary', + 'hover:bg-bolt-elements-background-depth-2', + 'border-[rgba(0,0,0,0.08)] dark:border-[rgba(255,255,255,0.08)]', 'h-10 px-4 py-2 min-w-[120px] justify-center', 'transition-all duration-200 ease-in-out', className, diff --git a/app/components/chat/chatExportAndImport/ImportButtons.tsx b/app/components/chat/chatExportAndImport/ImportButtons.tsx index b91aab3..cc792c3 100644 --- a/app/components/chat/chatExportAndImport/ImportButtons.tsx +++ b/app/components/chat/chatExportAndImport/ImportButtons.tsx @@ -67,10 +67,10 @@ export function ImportButtons(importChat: ((description: string, messages: Messa variant="outline" size="lg" className={classNames( - 'gap-2 bg-[#F5F5F5] dark:bg-[#252525]', - 'text-bolt-elements-textPrimary dark:text-white', - 'hover:bg-[#E5E5E5] dark:hover:bg-[#333333]', - 'border-[#E5E5E5] dark:border-[#333333]', + 'gap-2 bg-bolt-elements-background-depth-1', + 'text-bolt-elements-textPrimary', + 'hover:bg-bolt-elements-background-depth-2', + 'border-[rgba(0,0,0,0.08)] dark:border-[rgba(255,255,255,0.08)]', 'h-10 px-4 py-2 min-w-[120px] justify-center', 'transition-all duration-200 ease-in-out', )} @@ -81,10 +81,10 @@ export function ImportButtons(importChat: ((description: string, messages: Messa { icon="i-ph:gear" size="xl" title="Settings" + data-testid="settings-button" className="text-[#666] hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive/10 transition-colors" /> ); diff --git a/app/lib/hooks/useGit.ts b/app/lib/hooks/useGit.ts index 82c650c..b726d97 100644 --- a/app/lib/hooks/useGit.ts +++ b/app/lib/hooks/useGit.ts @@ -50,6 +50,11 @@ export function useGit() { fileData.current = {}; + /* + * Skip Git initialization for now - let isomorphic-git handle it + * This avoids potential issues with our manual initialization + */ + const headers: { [x: string]: string; } = { @@ -72,18 +77,23 @@ export function useGit() { singleBranch: true, corsProxy: '/api/git-proxy', headers, - + onProgress: (event) => { + console.log('Git clone progress:', event); + }, onAuth: (url) => { let auth = lookupSavedPassword(url); if (auth) { + console.log('Using saved authentication for', url); return auth; } + console.log('Repository requires authentication:', url); + if (confirm('This repo is password protected. Ready to enter a username & password?')) { auth = { - username: prompt('Enter username'), - password: prompt('Enter password'), + username: prompt('Enter username') || '', + password: prompt('Enter password') || '', }; return auth; } else { @@ -91,10 +101,12 @@ export function useGit() { } }, onAuthFailure: (url, _auth) => { + console.error(`Authentication failed for ${url}`); toast.error(`Error Authenticating with ${url.split('/')[2]}`); throw `Error Authenticating with ${url.split('/')[2]}`; }, onAuthSuccess: (url, auth) => { + console.log(`Authentication successful for ${url}`); saveGitAuth(url, auth); }, }); @@ -136,18 +148,26 @@ const getFs = ( throw error; } }, - writeFile: async (path: string, data: any, options: any) => { - const encoding = options.encoding; + writeFile: async (path: string, data: any, options: any = {}) => { const relativePath = pathUtils.relative(webcontainer.workdir, path); if (record.current) { - record.current[relativePath] = { data, encoding }; + record.current[relativePath] = { data, encoding: options?.encoding }; } try { - const result = await webcontainer.fs.writeFile(relativePath, data, { ...options, encoding }); + // Handle encoding properly based on data type + if (data instanceof Uint8Array) { + // For binary data, don't pass encoding + const result = await webcontainer.fs.writeFile(relativePath, data); + return result; + } else { + // For text data, use the encoding if provided + const encoding = options?.encoding || 'utf8'; + const result = await webcontainer.fs.writeFile(relativePath, data, encoding); - return result; + return result; + } } catch (error) { throw error; } @@ -208,33 +228,80 @@ const getFs = ( stat: async (path: string) => { try { const relativePath = pathUtils.relative(webcontainer.workdir, path); - const resp = await webcontainer.fs.readdir(pathUtils.dirname(relativePath), { withFileTypes: true }); - const name = pathUtils.basename(relativePath); - const fileInfo = resp.find((x) => x.name == name); + const dirPath = pathUtils.dirname(relativePath); + const fileName = pathUtils.basename(relativePath); + + // Special handling for .git/index file + if (relativePath === '.git/index') { + return { + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false, + size: 12, // Size of our empty index + mode: 0o100644, // Regular file + mtimeMs: Date.now(), + ctimeMs: Date.now(), + birthtimeMs: Date.now(), + atimeMs: Date.now(), + uid: 1000, + gid: 1000, + dev: 1, + ino: 1, + nlink: 1, + rdev: 0, + blksize: 4096, + blocks: 1, + mtime: new Date(), + ctime: new Date(), + birthtime: new Date(), + atime: new Date(), + }; + } + + const resp = await webcontainer.fs.readdir(dirPath, { withFileTypes: true }); + const fileInfo = resp.find((x) => x.name === fileName); if (!fileInfo) { - throw new Error(`ENOENT: no such file or directory, stat '${path}'`); + const err = new Error(`ENOENT: no such file or directory, stat '${path}'`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + err.errno = -2; + err.syscall = 'stat'; + err.path = path; + throw err; } return { isFile: () => fileInfo.isFile(), isDirectory: () => fileInfo.isDirectory(), isSymbolicLink: () => false, - size: 1, - mode: 0o666, // Default permissions + size: fileInfo.isDirectory() ? 4096 : 1, + mode: fileInfo.isDirectory() ? 0o040755 : 0o100644, // Directory or regular file mtimeMs: Date.now(), + ctimeMs: Date.now(), + birthtimeMs: Date.now(), + atimeMs: Date.now(), uid: 1000, gid: 1000, + dev: 1, + ino: 1, + nlink: 1, + rdev: 0, + blksize: 4096, + blocks: 8, + mtime: new Date(), + ctime: new Date(), + birthtime: new Date(), + atime: new Date(), }; } catch (error: any) { - console.log(error?.message); + if (!error.code) { + error.code = 'ENOENT'; + error.errno = -2; + error.syscall = 'stat'; + error.path = path; + } - const err = new Error(`ENOENT: no such file or directory, stat '${path}'`) as NodeJS.ErrnoException; - err.code = 'ENOENT'; - err.errno = -2; - err.syscall = 'stat'; - err.path = path; - throw err; + throw error; } }, lstat: async (path: string) => { diff --git a/app/lib/stores/netlify.ts b/app/lib/stores/netlify.ts index 8c8befc..e9383fb 100644 --- a/app/lib/stores/netlify.ts +++ b/app/lib/stores/netlify.ts @@ -1,15 +1,18 @@ import { atom } from 'nanostores'; -import type { NetlifyConnection } from '~/types/netlify'; +import type { NetlifyConnection, NetlifyUser } from '~/types/netlify'; import { logStore } from './logs'; import { toast } from 'react-toastify'; -// Initialize with stored connection or defaults +// Initialize with stored connection or environment variable const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('netlify_connection') : null; +const envToken = import.meta.env.VITE_NETLIFY_ACCESS_TOKEN; + +// If we have an environment token but no stored connection, initialize with the env token const initialConnection: NetlifyConnection = storedConnection ? JSON.parse(storedConnection) : { user: null, - token: '', + token: envToken || '', stats: undefined, }; @@ -17,6 +20,52 @@ export const netlifyConnection = atom(initialConnection); export const isConnecting = atom(false); export const isFetchingStats = atom(false); +// Function to initialize Netlify connection with environment token +export async function initializeNetlifyConnection() { + const currentState = netlifyConnection.get(); + + // If we already have a connection, don't override it + if (currentState.user || !envToken) { + return; + } + + try { + isConnecting.set(true); + + const response = await fetch('https://api.netlify.com/api/v1/user', { + headers: { + Authorization: `Bearer ${envToken}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to connect to Netlify: ${response.statusText}`); + } + + const userData = await response.json(); + + // Update the connection state + const connectionData: Partial = { + user: userData as NetlifyUser, + token: envToken, + }; + + // Store in localStorage for persistence + localStorage.setItem('netlify_connection', JSON.stringify(connectionData)); + + // Update the store + updateNetlifyConnection(connectionData); + + // Fetch initial stats + await fetchNetlifyStats(envToken); + } catch (error) { + console.error('Error initializing Netlify connection:', error); + logStore.logError('Failed to initialize Netlify connection', { error }); + } finally { + isConnecting.set(false); + } +} + export const updateNetlifyConnection = (updates: Partial) => { const currentState = netlifyConnection.get(); const newState = { ...currentState, ...updates }; diff --git a/app/routes/api.git-proxy.$.ts b/app/routes/api.git-proxy.$.ts index a4e8f1e..a533450 100644 --- a/app/routes/api.git-proxy.$.ts +++ b/app/routes/api.git-proxy.$.ts @@ -117,6 +117,7 @@ async function handleProxyRequest(request: Request, path: string | undefined) { // Add body for non-GET/HEAD requests if (!['GET', 'HEAD'].includes(request.method)) { fetchOptions.body = request.body; + fetchOptions.duplex = 'half'; /* * Note: duplex property is removed to ensure TypeScript compatibility diff --git a/app/routes/api.system.diagnostics.ts b/app/routes/api.system.diagnostics.ts new file mode 100644 index 0000000..8c61966 --- /dev/null +++ b/app/routes/api.system.diagnostics.ts @@ -0,0 +1,142 @@ +import { json, type LoaderFunction, type LoaderFunctionArgs } from '@remix-run/cloudflare'; + +/** + * Diagnostic API for troubleshooting connection issues + */ + +interface AppContext { + env?: { + GITHUB_ACCESS_TOKEN?: string; + NETLIFY_TOKEN?: string; + }; +} + +export const loader: LoaderFunction = async ({ request, context }: LoaderFunctionArgs & { context: AppContext }) => { + // Get environment variables + const envVars = { + hasGithubToken: Boolean(process.env.GITHUB_ACCESS_TOKEN || context.env?.GITHUB_ACCESS_TOKEN), + hasNetlifyToken: Boolean(process.env.NETLIFY_TOKEN || context.env?.NETLIFY_TOKEN), + nodeEnv: process.env.NODE_ENV, + }; + + // Check cookies + const cookieHeader = request.headers.get('Cookie') || ''; + const cookies = cookieHeader.split(';').reduce( + (acc, cookie) => { + const [key, value] = cookie.trim().split('='); + + if (key) { + acc[key] = value; + } + + return acc; + }, + {} as Record, + ); + + const hasGithubTokenCookie = Boolean(cookies.githubToken); + const hasGithubUsernameCookie = Boolean(cookies.githubUsername); + const hasNetlifyCookie = Boolean(cookies.netlifyToken); + + // Get local storage status (this can only be checked client-side) + const localStorageStatus = { + explanation: 'Local storage can only be checked on the client side. Use browser devtools to check.', + githubKeysToCheck: ['github_connection'], + netlifyKeysToCheck: ['netlify_connection'], + }; + + // Check if CORS might be an issue + const corsStatus = { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }, + }; + + // Check if API endpoints are reachable + const apiEndpoints = { + githubUser: '/api/system/git-info?action=getUser', + githubRepos: '/api/system/git-info?action=getRepos', + githubOrgs: '/api/system/git-info?action=getOrgs', + githubActivity: '/api/system/git-info?action=getActivity', + gitInfo: '/api/system/git-info', + }; + + // Test GitHub API connectivity + let githubApiStatus; + + try { + const githubResponse = await fetch('https://api.github.com/zen', { + method: 'GET', + headers: { + Accept: 'application/vnd.github.v3+json', + }, + }); + + githubApiStatus = { + isReachable: githubResponse.ok, + status: githubResponse.status, + statusText: githubResponse.statusText, + }; + } catch (error) { + githubApiStatus = { + isReachable: false, + error: error instanceof Error ? error.message : String(error), + }; + } + + // Test Netlify API connectivity + let netlifyApiStatus; + + try { + const netlifyResponse = await fetch('https://api.netlify.com/api/v1/', { + method: 'GET', + }); + + netlifyApiStatus = { + isReachable: netlifyResponse.ok, + status: netlifyResponse.status, + statusText: netlifyResponse.statusText, + }; + } catch (error) { + netlifyApiStatus = { + isReachable: false, + error: error instanceof Error ? error.message : String(error), + }; + } + + // Provide technical details about the environment + const technicalDetails = { + serverTimestamp: new Date().toISOString(), + userAgent: request.headers.get('User-Agent'), + referrer: request.headers.get('Referer'), + host: request.headers.get('Host'), + method: request.method, + url: request.url, + }; + + // Return diagnostics + return json( + { + status: 'success', + environment: envVars, + cookies: { + hasGithubTokenCookie, + hasGithubUsernameCookie, + hasNetlifyCookie, + }, + localStorage: localStorageStatus, + apiEndpoints, + externalApis: { + github: githubApiStatus, + netlify: netlifyApiStatus, + }, + corsStatus, + technicalDetails, + }, + { + headers: corsStatus.headers, + }, + ); +}; diff --git a/app/routes/api.system.git-info.ts b/app/routes/api.system.git-info.ts index 63c879c..84ef260 100644 --- a/app/routes/api.system.git-info.ts +++ b/app/routes/api.system.git-info.ts @@ -1,4 +1,4 @@ -import { json, type LoaderFunction } from '@remix-run/cloudflare'; +import { json, type LoaderFunction, type LoaderFunctionArgs } from '@remix-run/cloudflare'; interface GitInfo { local: { @@ -20,6 +20,31 @@ interface GitInfo { }; }; isForked?: boolean; + timestamp?: string; +} + +// Define context type +interface AppContext { + env?: { + GITHUB_ACCESS_TOKEN?: string; + }; +} + +interface GitHubRepo { + name: string; + full_name: string; + html_url: string; + description: string; + stargazers_count: number; + forks_count: number; + language: string | null; + languages_url: string; +} + +interface GitHubGist { + id: string; + html_url: string; + description: string; } // These values will be replaced at build time @@ -31,7 +56,260 @@ declare const __GIT_EMAIL: string; declare const __GIT_REMOTE_URL: string; declare const __GIT_REPO_NAME: string; -export const loader: LoaderFunction = async () => { +/* + * Remove unused variable to fix linter error + * declare const __GIT_REPO_URL: string; + */ + +export const loader: LoaderFunction = async ({ request, context }: LoaderFunctionArgs & { context: AppContext }) => { + console.log('Git info API called with URL:', request.url); + + // Handle CORS preflight requests + if (request.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }, + }); + } + + const { searchParams } = new URL(request.url); + const action = searchParams.get('action'); + + console.log('Git info action:', action); + + if (action === 'getUser' || action === 'getRepos' || action === 'getOrgs' || action === 'getActivity') { + // Use server-side token instead of client-side token + const serverGithubToken = process.env.GITHUB_ACCESS_TOKEN || context.env?.GITHUB_ACCESS_TOKEN; + const cookieToken = request.headers + .get('Cookie') + ?.split(';') + .find((cookie) => cookie.trim().startsWith('githubToken=')) + ?.split('=')[1]; + + // Also check for token in Authorization header + const authHeader = request.headers.get('Authorization'); + const headerToken = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null; + + const token = serverGithubToken || headerToken || cookieToken; + + console.log( + 'Using GitHub token from:', + serverGithubToken ? 'server env' : headerToken ? 'auth header' : cookieToken ? 'cookie' : 'none', + ); + + if (!token) { + console.error('No GitHub token available'); + return json( + { error: 'No GitHub token available' }, + { + status: 401, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + }, + }, + ); + } + + try { + if (action === 'getUser') { + const response = await fetch('https://api.github.com/user', { + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + console.error('GitHub user API error:', response.status); + throw new Error(`GitHub API error: ${response.status}`); + } + + const userData = await response.json(); + + return json( + { user: userData }, + { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + }, + }, + ); + } + + if (action === 'getRepos') { + const reposResponse = await fetch('https://api.github.com/user/repos?per_page=100&sort=updated', { + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `Bearer ${token}`, + }, + }); + + if (!reposResponse.ok) { + console.error('GitHub repos API error:', reposResponse.status); + throw new Error(`GitHub API error: ${reposResponse.status}`); + } + + const repos = (await reposResponse.json()) as GitHubRepo[]; + + // Get user's gists + const gistsResponse = await fetch('https://api.github.com/gists', { + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `Bearer ${token}`, + }, + }); + + const gists = gistsResponse.ok ? ((await gistsResponse.json()) as GitHubGist[]) : []; + + // Calculate language statistics + const languageStats: Record = {}; + let totalStars = 0; + let totalForks = 0; + + for (const repo of repos) { + totalStars += repo.stargazers_count || 0; + totalForks += repo.forks_count || 0; + + if (repo.language && repo.language !== 'null') { + languageStats[repo.language] = (languageStats[repo.language] || 0) + 1; + } + + /* + * Optionally fetch languages for each repo for more accurate stats + * This is commented out to avoid rate limiting + * + * if (repo.languages_url) { + * try { + * const langResponse = await fetch(repo.languages_url, { + * headers: { + * Accept: 'application/vnd.github.v3+json', + * Authorization: `Bearer ${token}`, + * }, + * }); + * + * if (langResponse.ok) { + * const languages = await langResponse.json(); + * Object.keys(languages).forEach(lang => { + * languageStats[lang] = (languageStats[lang] || 0) + languages[lang]; + * }); + * } + * } catch (error) { + * console.error(`Error fetching languages for ${repo.name}:`, error); + * } + * } + */ + } + + return json( + { + repos, + stats: { + totalStars, + totalForks, + languages: languageStats, + totalGists: gists.length, + }, + }, + { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + }, + }, + ); + } + + if (action === 'getOrgs') { + const response = await fetch('https://api.github.com/user/orgs', { + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + console.error('GitHub orgs API error:', response.status); + throw new Error(`GitHub API error: ${response.status}`); + } + + const orgs = await response.json(); + + return json( + { organizations: orgs }, + { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + }, + }, + ); + } + + if (action === 'getActivity') { + const username = request.headers + .get('Cookie') + ?.split(';') + .find((cookie) => cookie.trim().startsWith('githubUsername=')) + ?.split('=')[1]; + + if (!username) { + console.error('GitHub username not found in cookies'); + return json( + { error: 'GitHub username not found in cookies' }, + { + status: 400, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + }, + }, + ); + } + + const response = await fetch(`https://api.github.com/users/${username}/events?per_page=30`, { + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + console.error('GitHub activity API error:', response.status); + throw new Error(`GitHub API error: ${response.status}`); + } + + const events = await response.json(); + + return json( + { recentActivity: events }, + { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + }, + }, + ); + } + } catch (error) { + console.error('GitHub API error:', error); + return json( + { error: error instanceof Error ? error.message : 'Unknown error' }, + { + status: 500, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + }, + }, + ); + } + } + const gitInfo: GitInfo = { local: { commitHash: typeof __COMMIT_HASH !== 'undefined' ? __COMMIT_HASH : 'development', @@ -42,7 +320,13 @@ export const loader: LoaderFunction = async () => { remoteUrl: typeof __GIT_REMOTE_URL !== 'undefined' ? __GIT_REMOTE_URL : 'local', repoName: typeof __GIT_REPO_NAME !== 'undefined' ? __GIT_REPO_NAME : 'bolt.diy', }, + timestamp: new Date().toISOString(), }; - return json(gitInfo); + return json(gitInfo, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + }, + }); }; diff --git a/app/types/netlify.ts b/app/types/netlify.ts index 02654cc..b587751 100644 --- a/app/types/netlify.ts +++ b/app/types/netlify.ts @@ -2,18 +2,64 @@ export interface NetlifySite { id: string; name: string; url: string; + ssl_url?: string; admin_url: string; + screenshot_url?: string; + created_at: string; + updated_at: string; + state?: string; + branch?: string; + custom_domain?: string; build_settings: { provider: string; repo_url: string; + repo_branch?: string; cmd: string; }; published_deploy: { + id?: string; published_at: string; deploy_time: number; + state?: string; + branch?: string; + commit_ref?: string; + commit_url?: string; + error_message?: string; + framework?: string; }; } +export interface NetlifyDeploy { + id: string; + site_id: string; + state: string; + name: string; + url: string; + ssl_url?: string; + admin_url?: string; + deploy_url: string; + deploy_ssl_url?: string; + screenshot_url?: string; + branch: string; + commit_ref?: string; + commit_url?: string; + created_at: string; + updated_at: string; + published_at?: string; + title?: string; + framework?: string; + error_message?: string; +} + +export interface NetlifyBuild { + id: string; + deploy_id: string; + sha?: string; + done: boolean; + error?: string; + created_at: string; +} + export interface NetlifyUser { id: string; slug: string; @@ -25,6 +71,9 @@ export interface NetlifyUser { export interface NetlifyStats { sites: NetlifySite[]; totalSites: number; + deploys?: NetlifyDeploy[]; + builds?: NetlifyBuild[]; + lastDeployTime?: string; } export interface NetlifyConnection { diff --git a/app/utils/selectStarterTemplate.ts b/app/utils/selectStarterTemplate.ts index 0bbb189..55386b8 100644 --- a/app/utils/selectStarterTemplate.ts +++ b/app/utils/selectStarterTemplate.ts @@ -126,7 +126,7 @@ const getGitHubRepoContent = async ( // Add your GitHub token if needed if (token) { - headers.Authorization = 'token ' + token; + headers.Authorization = 'Bearer ' + token; } // Fetch contents of the path diff --git a/package.json b/package.json index 4945567..74f9504 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@codemirror/state": "^6.4.1", "@codemirror/view": "^6.35.0", "@headlessui/react": "^2.2.0", + "@heroicons/react": "^2.2.0", "@iconify-json/svg-spinners": "^1.2.1", "@lezer/highlight": "^1.2.1", "@nanostores/react": "^0.7.3", @@ -167,7 +168,7 @@ "@testing-library/react": "^16.2.0", "@types/diff": "^5.2.3", "@types/dom-speech-recognition": "^0.0.4", - "@types/electron": "^1.6.10", + "@types/electron": "^1.6.12", "@types/file-saver": "^2.0.7", "@types/js-cookie": "^3.0.6", "@types/path-browserify": "^1.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c29d7f0..2f87d14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: '@headlessui/react': specifier: ^2.2.0 version: 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@heroicons/react': + specifier: ^2.2.0 + version: 2.2.0(react@18.3.1) '@iconify-json/svg-spinners': specifier: ^1.2.1 version: 1.2.2 @@ -364,7 +367,7 @@ importers: specifier: ^0.0.4 version: 0.0.4 '@types/electron': - specifier: ^1.6.10 + specifier: ^1.6.12 version: 1.6.12 '@types/file-saver': specifier: ^2.0.7 @@ -1842,6 +1845,11 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + '@heroicons/react@2.2.0': + resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==} + peerDependencies: + react: '>= 16 || ^19.0.0-rc' + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -9546,6 +9554,10 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + '@heroicons/react@2.2.0(react@18.3.1)': + dependencies: + react: 18.3.1 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6':