* feat: add service tabs refactor with GitHub, GitLab, Supabase, Vercel, and Netlify integration This commit introduces a comprehensive refactor of the connections system, replacing the single connections tab with dedicated service integration tabs: ✨ New Service Tabs: - GitHub Tab: Complete integration with repository management, stats, and API - GitLab Tab: GitLab project integration and management - Supabase Tab: Database project management with comprehensive analytics - Vercel Tab: Project deployment management and monitoring - Netlify Tab: Site deployment and build management 🔧 Supporting Infrastructure: - Enhanced store management for each service with auto-connect via env vars - API routes for secure server-side token handling and data fetching - Updated TypeScript types with missing properties and interfaces - Comprehensive hooks for service connections and state management - Security utilities for API endpoint validation 🎨 UI/UX Improvements: - Individual service tabs with tailored functionality - Motion animations and improved loading states - Connection testing and health monitoring - Advanced analytics dashboards for each service - Consistent design patterns across all service tabs 🛠️ Technical Changes: - Removed legacy connection tab in favor of individual service tabs - Updated tab configuration and routing system - Added comprehensive error handling and loading states - Enhanced type safety with extended interfaces - Implemented environment variable auto-connection features Note: Some TypeScript errors remain and will need to be resolved in follow-up commits. The dev server runs successfully and the service tabs are functional. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: comprehensive service integration refactor with enhanced tabs architecture Major architectural improvements to service integrations: **Service Integration Refactor:** - Complete restructure of service connection tabs (GitHub, GitLab, Vercel, Netlify, Supabase) - Migrated from centralized ConnectionsTab to dedicated service-specific tabs - Added shared service integration components for consistent UX - Implemented auto-connection feature using environment variables **New Components & Architecture:** - ServiceIntegrationLayout for consistent service tab structure - ConnectionStatus, ServiceCard components for reusable UI patterns - BranchSelector component for repository branch management - Enhanced authentication dialogs with improved error handling **API & Backend Enhancements:** - New API endpoints: github-branches, gitlab-branches, gitlab-projects, vercel-user - Enhanced GitLab API service with comprehensive project management - Improved connection testing hooks (useConnectionTest) - Better error handling and rate limiting across all services **Configuration & Environment:** - Updated .env.example with comprehensive service integration guides - Added auto-connection support for all major services - Improved development and production environment configurations - Enhanced tab management with proper service icons **Code Quality & TypeScript:** - Fixed all TypeScript errors across service integration components - Enhanced type definitions for Vercel, Supabase, and other service integrations - Improved type safety with proper optional chaining and type assertions - Better separation of concerns between UI and business logic **Removed Legacy Code:** - Removed redundant connection components and consolidated into service tabs - Cleaned up unused imports and deprecated connection patterns - Streamlined authentication flows across all services This refactor provides a more maintainable, scalable architecture for service integrations while significantly improving the user experience for managing external connections. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: clean up dead code and consolidate utilities - Remove legacy .eslintrc.json (replaced by flat config) - Remove duplicate app/utils/types.ts (unused type definitions) - Remove app/utils/cn.ts and consolidate with classNames utility - Clean up unused ServiceErrorHandler class implementation - Enhance classNames utility to support boolean values - Update GlowingEffect.tsx to use consolidated classNames utility Removes ~150+ lines of unused code while maintaining all functionality. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Simplify terminal health checks and improve project setup Removed aggressive health checking and reconnection logic from TerminalManager to prevent issues with terminal responsiveness. Updated TerminalTabs to remove onReconnect handlers. Enhanced projectCommands utility to generate non-interactive setup commands and detect shadcn projects, improving automation and reliability of project setup. * fix: resolve GitLab deployment issues and enhance GitHub deployment reliability GitLab Deployment Fixes: - Fix COEP header issue for avatar images by adding crossOrigin and referrerPolicy attributes - Implement repository name sanitization to handle special characters and ensure GitLab compliance - Enhance error handling with detailed validation error parsing and user-friendly messages - Add explicit path field and description to project creation requests - Improve URL encoding and project path resolution for proper API calls - Add graceful file commit handling with timeout and error recovery GitHub Deployment Enhancements: - Add comprehensive repository name validation and sanitization - Implement real-time feedback for invalid characters in repository name input - Enhance error handling with specific error types and retry suggestions - Improve user experience with better error messages and validation feedback - Add repository name length limits and character restrictions - Show sanitized name preview to users before submission General Improvements: - Add GitLabAuthDialog component for improved authentication flow - Enhance logging and debugging capabilities for deployment operations - Improve accessibility with proper dialog titles and descriptions - Add better user notifications for name sanitization and validation issues 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
509 lines
15 KiB
TypeScript
509 lines
15 KiB
TypeScript
import type {
|
|
GitLabUserResponse,
|
|
GitLabProjectInfo,
|
|
GitLabEvent,
|
|
GitLabGroupInfo,
|
|
GitLabProjectResponse,
|
|
GitLabCommitRequest,
|
|
} from '~/types/GitLab';
|
|
|
|
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
|
|
|
interface CacheEntry<T> {
|
|
data: T;
|
|
timestamp: number;
|
|
expiresAt: number;
|
|
}
|
|
|
|
class GitLabCache {
|
|
private _cache = new Map<string, CacheEntry<any>>();
|
|
|
|
set<T>(key: string, data: T, duration = CACHE_DURATION): void {
|
|
const timestamp = Date.now();
|
|
this._cache.set(key, {
|
|
data,
|
|
timestamp,
|
|
expiresAt: timestamp + duration,
|
|
});
|
|
}
|
|
|
|
get<T>(key: string): T | null {
|
|
const entry = this._cache.get(key);
|
|
|
|
if (!entry) {
|
|
return null;
|
|
}
|
|
|
|
if (Date.now() > entry.expiresAt) {
|
|
this._cache.delete(key);
|
|
return null;
|
|
}
|
|
|
|
return entry.data;
|
|
}
|
|
|
|
clear(): void {
|
|
this._cache.clear();
|
|
}
|
|
|
|
isExpired(key: string): boolean {
|
|
const entry = this._cache.get(key);
|
|
return !entry || Date.now() > entry.expiresAt;
|
|
}
|
|
}
|
|
|
|
const gitlabCache = new GitLabCache();
|
|
|
|
async function fetchWithRetry(url: string, options: RequestInit, maxRetries = 3): Promise<Response> {
|
|
let lastError: Error;
|
|
|
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
try {
|
|
const response = await fetch(url, options);
|
|
|
|
// Don't retry on client errors (4xx) except 429 (rate limit)
|
|
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
|
|
return response;
|
|
}
|
|
|
|
// Retry on server errors (5xx) and rate limits
|
|
if (response.status >= 500 || response.status === 429) {
|
|
if (attempt === maxRetries) {
|
|
return response;
|
|
}
|
|
|
|
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
|
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
continue;
|
|
}
|
|
|
|
return response;
|
|
} catch (error) {
|
|
lastError = error as Error;
|
|
|
|
if (attempt === maxRetries) {
|
|
throw lastError;
|
|
}
|
|
|
|
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
|
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
}
|
|
}
|
|
|
|
throw lastError!;
|
|
}
|
|
|
|
export class GitLabApiService {
|
|
private _baseUrl: string;
|
|
private _token: string;
|
|
|
|
constructor(token: string, baseUrl = 'https://gitlab.com') {
|
|
this._token = token;
|
|
this._baseUrl = baseUrl;
|
|
}
|
|
|
|
private get _headers() {
|
|
// Log token format for debugging
|
|
console.log('GitLab API token info:', {
|
|
tokenLength: this._token.length,
|
|
tokenPrefix: this._token.substring(0, 10) + '...',
|
|
tokenType: this._token.startsWith('glpat-') ? 'personal-access-token' : 'unknown',
|
|
});
|
|
|
|
return {
|
|
'Content-Type': 'application/json',
|
|
'PRIVATE-TOKEN': this._token,
|
|
};
|
|
}
|
|
|
|
private async _request(endpoint: string, options: RequestInit = {}): Promise<Response> {
|
|
const url = `${this._baseUrl}/api/v4${endpoint}`;
|
|
return fetchWithRetry(url, {
|
|
...options,
|
|
headers: {
|
|
...this._headers,
|
|
...options.headers,
|
|
},
|
|
});
|
|
}
|
|
|
|
async getUser(): Promise<GitLabUserResponse> {
|
|
const response = await this._request('/user');
|
|
|
|
if (!response.ok) {
|
|
let errorMessage = `Failed to fetch user: ${response.status}`;
|
|
|
|
// Provide more specific error messages based on status code
|
|
if (response.status === 401) {
|
|
errorMessage =
|
|
'401 Unauthorized: Invalid or expired GitLab access token. Please check your token and ensure it has the required scopes (api, read_repository).';
|
|
} else if (response.status === 403) {
|
|
errorMessage = '403 Forbidden: GitLab access token does not have sufficient permissions.';
|
|
} else if (response.status === 404) {
|
|
errorMessage = '404 Not Found: GitLab API endpoint not found. Please check your GitLab URL configuration.';
|
|
} else if (response.status === 429) {
|
|
errorMessage = '429 Too Many Requests: GitLab API rate limit exceeded. Please try again later.';
|
|
}
|
|
|
|
// Try to get more details from response body
|
|
try {
|
|
const errorData = (await response.json()) as any;
|
|
|
|
if (errorData.message) {
|
|
errorMessage += ` Details: ${errorData.message}`;
|
|
}
|
|
} catch {
|
|
// If we can't parse the error response, continue with the default message
|
|
}
|
|
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
const user: GitLabUserResponse = await response.json();
|
|
|
|
// Get rate limit information from headers if available
|
|
const rateLimit = {
|
|
limit: parseInt(response.headers.get('ratelimit-limit') || '0'),
|
|
remaining: parseInt(response.headers.get('ratelimit-remaining') || '0'),
|
|
reset: parseInt(response.headers.get('ratelimit-reset') || '0'),
|
|
};
|
|
|
|
// Handle different avatar URL fields that GitLab might return
|
|
const processedUser = {
|
|
...user,
|
|
avatar_url: user.avatar_url || (user as any).avatarUrl || (user as any).profile_image_url || null,
|
|
};
|
|
|
|
return { ...processedUser, rateLimit } as GitLabUserResponse & { rateLimit: typeof rateLimit };
|
|
}
|
|
|
|
async getProjects(membership = true, minAccessLevel = 20, perPage = 50): Promise<GitLabProjectInfo[]> {
|
|
const cacheKey = `projects_${this._token}_${membership}_${minAccessLevel}`;
|
|
const cached = gitlabCache.get<GitLabProjectInfo[]>(cacheKey);
|
|
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
let allProjects: any[] = [];
|
|
let page = 1;
|
|
const maxPages = 10; // Limit to prevent excessive API calls
|
|
|
|
while (page <= maxPages) {
|
|
const response = await this._request(
|
|
`/projects?membership=${membership}&min_access_level=${minAccessLevel}&per_page=${perPage}&page=${page}&order_by=updated_at&sort=desc`,
|
|
);
|
|
|
|
if (!response.ok) {
|
|
let errorMessage = `Failed to fetch projects: ${response.status} ${response.statusText}`;
|
|
|
|
try {
|
|
const errorData = await response.json();
|
|
console.error('GitLab projects API error:', errorData);
|
|
errorMessage = `Failed to fetch projects: ${JSON.stringify(errorData)}`;
|
|
} catch (parseError) {
|
|
console.error('Could not parse GitLab error response:', parseError);
|
|
}
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
const projects: any[] = await response.json();
|
|
|
|
if (projects.length === 0) {
|
|
break;
|
|
}
|
|
|
|
allProjects = [...allProjects, ...projects];
|
|
|
|
// Break if we have enough projects for initial load
|
|
if (allProjects.length >= 100) {
|
|
break;
|
|
}
|
|
|
|
page++;
|
|
}
|
|
|
|
// Transform to our interface
|
|
const transformedProjects: GitLabProjectInfo[] = allProjects.map((project: any) => ({
|
|
id: project.id,
|
|
name: project.name,
|
|
path_with_namespace: project.path_with_namespace,
|
|
description: project.description,
|
|
http_url_to_repo: project.http_url_to_repo,
|
|
star_count: project.star_count,
|
|
forks_count: project.forks_count,
|
|
default_branch: project.default_branch,
|
|
updated_at: project.updated_at,
|
|
visibility: project.visibility,
|
|
}));
|
|
|
|
gitlabCache.set(cacheKey, transformedProjects);
|
|
|
|
return transformedProjects;
|
|
}
|
|
|
|
async getEvents(perPage = 10): Promise<GitLabEvent[]> {
|
|
const response = await this._request(`/events?per_page=${perPage}`);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch events: ${response.statusText}`);
|
|
}
|
|
|
|
const events: any[] = await response.json();
|
|
|
|
return events.slice(0, 5).map((event: any) => ({
|
|
id: event.id,
|
|
action_name: event.action_name,
|
|
project_id: event.project_id,
|
|
project: event.project,
|
|
created_at: event.created_at,
|
|
}));
|
|
}
|
|
|
|
async getGroups(minAccessLevel = 10): Promise<GitLabGroupInfo[]> {
|
|
const response = await this._request(`/groups?min_access_level=${minAccessLevel}`);
|
|
|
|
if (response.ok) {
|
|
return await response.json();
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
async getSnippets(): Promise<any[]> {
|
|
const response = await this._request('/snippets');
|
|
|
|
if (response.ok) {
|
|
return await response.json();
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
async createProject(name: string, isPrivate: boolean = false): Promise<GitLabProjectResponse> {
|
|
// Sanitize project name to ensure it's valid for GitLab
|
|
const sanitizedName = name
|
|
.replace(/[^a-zA-Z0-9-_.]/g, '-') // Replace invalid chars with hyphens
|
|
.replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
|
|
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
|
|
.toLowerCase();
|
|
|
|
const response = await this._request('/projects', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
name: sanitizedName,
|
|
path: sanitizedName, // Explicitly set path to match name
|
|
visibility: isPrivate ? 'private' : 'public',
|
|
initialize_with_readme: false, // Don't initialize with README to avoid conflicts
|
|
default_branch: 'main', // Explicitly set default branch
|
|
description: `Project created from Bolt.diy`,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
let errorMessage = `Failed to create project: ${response.status} ${response.statusText}`;
|
|
|
|
try {
|
|
const errorData = (await response.json()) as any;
|
|
|
|
if (errorData.message) {
|
|
if (typeof errorData.message === 'object') {
|
|
// Handle validation errors
|
|
const messages = Object.entries(errorData.message as Record<string, any>)
|
|
.map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(', ') : value}`)
|
|
.join('; ');
|
|
errorMessage = `Failed to create project: ${messages}`;
|
|
} else {
|
|
errorMessage = `Failed to create project: ${errorData.message}`;
|
|
}
|
|
}
|
|
} catch (parseError) {
|
|
console.error('Could not parse error response:', parseError);
|
|
}
|
|
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
async getProject(owner: string, name: string): Promise<GitLabProjectResponse | null> {
|
|
const response = await this._request(`/projects/${encodeURIComponent(`${owner}/${name}`)}`);
|
|
|
|
if (response.ok) {
|
|
return await response.json();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async createBranch(projectId: number, branchName: string, ref: string): Promise<any> {
|
|
const response = await this._request(`/projects/${projectId}/repository/branches`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
branch: branchName,
|
|
ref,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to create branch: ${response.statusText}`);
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
async commitFiles(projectId: number, commitRequest: GitLabCommitRequest): Promise<any> {
|
|
const response = await this._request(`/projects/${projectId}/repository/commits`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(commitRequest),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
let errorMessage = `Failed to commit files: ${response.status} ${response.statusText}`;
|
|
|
|
try {
|
|
const errorData = (await response.json()) as { message?: string; error?: string };
|
|
|
|
if (errorData.message) {
|
|
errorMessage = errorData.message;
|
|
} else if (errorData.error) {
|
|
errorMessage = errorData.error;
|
|
}
|
|
} catch {
|
|
// If JSON parsing fails, keep the default error message
|
|
}
|
|
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
async getFile(projectId: number, filePath: string, ref: string): Promise<Response> {
|
|
return this._request(`/projects/${projectId}/repository/files/${encodeURIComponent(filePath)}?ref=${ref}`);
|
|
}
|
|
|
|
async getProjectByPath(projectPath: string): Promise<GitLabProjectResponse | null> {
|
|
try {
|
|
// Double encode the project path as GitLab API requires it
|
|
const encodedPath = encodeURIComponent(projectPath);
|
|
const response = await this._request(`/projects/${encodedPath}`);
|
|
|
|
if (response.ok) {
|
|
return await response.json();
|
|
}
|
|
|
|
if (response.status === 404) {
|
|
console.log(`Project not found: ${projectPath}`);
|
|
return null;
|
|
}
|
|
|
|
const errorText = await response.text();
|
|
console.error(`Failed to fetch project ${projectPath}:`, response.status, errorText);
|
|
throw new Error(`Failed to fetch project: ${response.status} ${response.statusText}`);
|
|
} catch (error) {
|
|
if (error instanceof Error && (error.message.includes('404') || error.message.includes('Not Found'))) {
|
|
return null;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async updateProjectVisibility(projectId: number, visibility: 'public' | 'private'): Promise<void> {
|
|
const response = await this._request(`/projects/${projectId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ visibility }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to update project visibility: ${response.status} ${response.statusText}`);
|
|
}
|
|
}
|
|
|
|
async createProjectWithFiles(
|
|
name: string,
|
|
isPrivate: boolean,
|
|
files: Record<string, string>,
|
|
): Promise<GitLabProjectResponse> {
|
|
// Create the project first
|
|
const project = await this.createProject(name, isPrivate);
|
|
|
|
// If we have files to commit, commit them
|
|
if (Object.keys(files).length > 0) {
|
|
// Wait a moment for the project to be fully created
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
|
|
const actions = Object.entries(files).map(([filePath, content]) => ({
|
|
action: 'create' as const,
|
|
file_path: filePath,
|
|
content,
|
|
}));
|
|
|
|
const commitRequest: GitLabCommitRequest = {
|
|
branch: 'main',
|
|
commit_message: 'Initial commit from Bolt.diy',
|
|
actions,
|
|
};
|
|
|
|
try {
|
|
await this.commitFiles(project.id, commitRequest);
|
|
} catch (error) {
|
|
console.error('Failed to commit files to new project:', error);
|
|
|
|
/*
|
|
* Don't throw the error, as the project was created successfully
|
|
* The user can still access it and add files manually
|
|
*/
|
|
}
|
|
}
|
|
|
|
return project;
|
|
}
|
|
|
|
async updateProjectWithFiles(projectId: number, files: Record<string, string>): Promise<void> {
|
|
if (Object.keys(files).length === 0) {
|
|
return;
|
|
}
|
|
|
|
// For existing projects, we need to determine which files exist and which are new
|
|
const actions = Object.entries(files).map(([filePath, content]) => ({
|
|
action: 'create' as const, // Start with create, we'll handle conflicts in the API response
|
|
file_path: filePath,
|
|
content,
|
|
}));
|
|
|
|
const commitRequest: GitLabCommitRequest = {
|
|
branch: 'main',
|
|
commit_message: 'Update from Bolt.diy',
|
|
actions,
|
|
};
|
|
|
|
try {
|
|
await this.commitFiles(projectId, commitRequest);
|
|
} catch (error) {
|
|
// If we get file conflicts, retry with update actions
|
|
if (error instanceof Error && error.message.includes('already exists')) {
|
|
const updateActions = Object.entries(files).map(([filePath, content]) => ({
|
|
action: 'update' as const,
|
|
file_path: filePath,
|
|
content,
|
|
}));
|
|
|
|
const updateCommitRequest: GitLabCommitRequest = {
|
|
branch: 'main',
|
|
commit_message: 'Update from Bolt.diy',
|
|
actions: updateActions,
|
|
};
|
|
|
|
await this.commitFiles(projectId, updateCommitRequest);
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export { gitlabCache };
|