import axios, { AxiosInstance } from 'axios'; import chalk from 'chalk'; import inquirer from 'inquirer'; import ora from 'ora'; import { TokenStorage, StoredTokens } from './token-storage'; /** * Standalone Supabase Management API Client for vcsys-cli * Handles direct OAuth authentication with Supabase and provides typed methods */ export interface SupabaseOrganization { id: string; name: string; slug: string; billing_email: string; tier: string; } export interface SupabaseProject { id: string; ref: string; name: string; organization_id: string; region: string; status: string; database?: { host: string; version: string; }; } export interface SupabaseApiKeys { anon: string; service_role: string; } export interface CreateProjectRequest { organization_id: string; name: string; region: string; db_pass: string; kps_enabled?: boolean; plan?: string; } export class SupabaseManagementError extends Error { constructor( message: string, public status?: number, public requiresReauth: boolean = false ) { super(message); this.name = 'SupabaseManagementError'; } } export class SupabaseManagementClient { private baseUrl = 'https://api.supabase.com/v1'; private oAuthBaseUrl = 'https://api.supabase.com/v1/oauth'; private apiClient: AxiosInstance; private tokenStorage: TokenStorage; constructor() { this.tokenStorage = new TokenStorage('vcsys-cli-supabase'); this.apiClient = axios.create({ baseURL: this.baseUrl, timeout: 30000, headers: { 'Content-Type': 'application/json', 'User-Agent': 'vcsys-cli/1.0.0' } }); } /** * Authenticate with Supabase OAuth */ async authenticate(): Promise { console.log(chalk.blue('🔐 Authenticating with Supabase...')); // Check if already authenticated if (await this.isAuthenticated()) { console.log(chalk.green('✅ Already authenticated with Supabase!')); return; } const { authMethod } = await inquirer.prompt([ { type: 'list', name: 'authMethod', message: 'How would you like to authenticate with Supabase?', choices: [ { name: 'Open browser to sign in with Supabase', value: 'browser' }, { name: 'Enter access token manually', value: 'manual' } ] } ]); if (authMethod === 'browser') { await this.authenticateWithBrowser(); } else { await this.authenticateWithToken(); } } /** * Check if user is authenticated */ async isAuthenticated(): Promise { try { const tokens = await this.tokenStorage.getStoredTokens(); if (!tokens) return false; // Check if token is expired (with 5 minute buffer) const expiresAt = new Date(tokens.expiresAt); const isExpired = expiresAt <= new Date(Date.now() + 5 * 60 * 1000); if (isExpired) { // Try to refresh token const refreshed = await this.refreshTokens(tokens.refreshToken); if (!refreshed) { await this.clearStoredTokens(); return false; } } // Verify token works await this.listOrganizations(); return true; } catch (error) { return false; } } /** * Authenticate using browser OAuth flow */ private async authenticateWithBrowser(): Promise { console.log(chalk.blue('🌐 Opening browser for Supabase authentication...')); console.log(chalk.gray('Please sign in to your Supabase account and authorize vcsys-cli')); // For now, redirect to manual token entry // In a full implementation, this would use a local server to handle OAuth callback console.log(chalk.yellow('📝 Browser OAuth not yet implemented. Using manual token entry...')); await this.authenticateWithToken(); } /** * Authenticate using manual token entry */ private async authenticateWithToken(): Promise { console.log(chalk.blue('📝 Manual token authentication')); console.log(chalk.gray('1. Visit: https://supabase.com/dashboard/account/tokens')); console.log(chalk.gray('2. Create a new access token')); console.log(chalk.gray('3. Copy and paste it below')); const { token } = await inquirer.prompt([ { type: 'password', name: 'token', message: 'Enter your Supabase access token:', mask: '*' } ]); const spinner = ora('Verifying token...').start(); try { // Test the token by making a request const response = await axios.get(`${this.baseUrl}/organizations`, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); if (response.status === 200) { // Store the token (we'll treat it as a long-lived token) const tokens: StoredTokens = { accessToken: token, refreshToken: '', // Manual tokens don't have refresh tokens expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString() // 1 year }; await this.tokenStorage.storeTokens(tokens); spinner.succeed('Authentication successful!'); } else { throw new Error('Invalid token'); } } catch (error) { spinner.fail('Token verification failed'); if (axios.isAxiosError(error) && error.response?.status === 401) { throw new Error('Invalid or expired token. Please check your token and try again.'); } throw error; } } /** * Make authenticated request to Supabase Management API */ private async makeRequest(endpoint: string, options: any = {}): Promise { const tokens = await this.tokenStorage.getStoredTokens(); if (!tokens) { throw new SupabaseManagementError( 'Not authenticated. Please run: vcsys supabase auth login', 401, true ); } try { const response = await this.apiClient.request({ url: endpoint, headers: { 'Authorization': `Bearer ${tokens.accessToken}`, ...options.headers }, ...options }); return response.data; } catch (error) { if (axios.isAxiosError(error)) { if (error.response?.status === 401 || error.response?.status === 403) { throw new SupabaseManagementError( 'Authentication failed - please re-authenticate', error.response.status, true ); } const errorMessage = error.response?.data?.message || error.response?.data?.error || `API request failed: ${error.response?.status}`; throw new SupabaseManagementError(errorMessage, error.response?.status); } throw error; } } /** * Refresh OAuth tokens */ private async refreshTokens(refreshToken: string): Promise { if (!refreshToken) return false; try { const response = await axios.post(`${this.oAuthBaseUrl}/token/refresh`, { refresh_token: refreshToken }); const { access_token, refresh_token: newRefreshToken, expires_in } = response.data; const tokens: StoredTokens = { accessToken: access_token, refreshToken: newRefreshToken || refreshToken, expiresAt: new Date(Date.now() + expires_in * 1000).toISOString() }; await this.tokenStorage.storeTokens(tokens); return true; } catch (error) { return false; } } /** * Clear stored tokens */ async clearStoredTokens(): Promise { await this.tokenStorage.clearTokens(); } /** * Logout */ async logout(): Promise { console.log(chalk.blue('🔓 Logging out of Supabase...')); await this.clearStoredTokens(); console.log(chalk.green('✅ Logged out successfully')); } // Supabase Management API Methods /** * Lists all organizations the user has access to */ async listOrganizations(): Promise { return this.makeRequest('/organizations'); } /** * Lists all projects in an organization */ async listProjects(organizationId?: string): Promise { const endpoint = organizationId ? `/organizations/${organizationId}/projects` : '/projects'; return this.makeRequest(endpoint); } /** * Creates a new Supabase project */ async createProject(projectData: CreateProjectRequest): Promise { return this.makeRequest('/projects', { method: 'POST', data: projectData }); } /** * Gets a specific project by reference */ async getProject(projectRef: string): Promise { return this.makeRequest(`/projects/${projectRef}`); } /** * Gets API keys for a project */ async getProjectApiKeys(projectRef: string): Promise { return this.makeRequest(`/projects/${projectRef}/api-keys`); } /** * Executes SQL on a project's database */ async executeSQL(projectRef: string, sql: string): Promise { return this.makeRequest(`/projects/${projectRef}/database/query`, { method: 'POST', data: { query: sql } }); } /** * Gets project configuration including environment variables */ async getProjectConfig(projectRef: string): Promise { return this.makeRequest(`/projects/${projectRef}/config`); } /** * Generate environment variables for a project */ async generateEnvVars(projectRef: string): Promise> { const [project, apiKeys] = await Promise.all([ this.getProject(projectRef), this.getProjectApiKeys(projectRef) ]); return { NEXT_PUBLIC_SUPABASE_URL: `https://${projectRef}.supabase.co`, NEXT_PUBLIC_SUPABASE_ANON_KEY: apiKeys.anon, SUPABASE_SERVICE_ROLE_KEY: apiKeys.service_role, SUPABASE_PROJECT_REF: projectRef, SUPABASE_PROJECT_ID: project.id, DATABASE_URL: `postgresql://postgres:[YOUR-PASSWORD]@db.${projectRef}.supabase.co:5432/postgres` }; } }