diff --git a/README.md b/README.md index 72177e08..4bb31f2f 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ Each loom is a fully isolated container for your work: * **Git Worktree:** A separate filesystem at ~/project-looms/issue-25/. No stashing, no branch switching overhead. -* **Database Branch:** (Neon support) Schema changes in this loom are isolated—they won't break your main environment or your other active looms. +* **Database Branch:** (Neon and Supabase support) Schema changes in this loom are isolated—they won't break your main environment or your other active looms. * **Environment Variables:** Each loom has its own environment files (`.env`, `.env.local`, `.env.development`, `.env.development.local`). Uses `development` by default, override with `DOTENV_FLOW_NODE_ENV`. See [Secret Storage Limitations](#multi-language-project-support) for frameworks with encrypted credentials. @@ -153,7 +153,7 @@ Configuration ### 1. Interactive Setup (Recommended) -The easiest way to configure iloom is the interactive wizard. It guides you through setting up your environment (GitHub/Linear, Neon, IDE). +The easiest way to configure iloom is the interactive wizard. It guides you through setting up your environment (GitHub/Linear, Neon/Supabase, IDE). You can even use natural language to jump-start the process: @@ -206,6 +206,21 @@ This example shows how to configure a project-wide default (e.g., GitHub remote) } ``` +Or, if using Supabase (requires a paid plan): + +```json +{ + "databaseProviders": { + "supabase": { + "projectRef": "abcdefghijklmnop", + "parentBranch": "main" + } + } +} +``` + +> Only one database provider can be active at a time. See [Database Branching](docs/iloom-commands.md#database-branching) for full details. + **.iloom/settings.local.json (Gitignored)** ```json diff --git a/docs/iloom-commands.md b/docs/iloom-commands.md index f497ba78..5ffb8541 100644 --- a/docs/iloom-commands.md +++ b/docs/iloom-commands.md @@ -2107,7 +2107,7 @@ il init "configure neon database with project ID abc-123" **Configuration Areas:** - Issue tracker (GitHub/Linear/Jira) -- Database provider (Neon) +- Database provider (Neon, Supabase) - IDE preference (VS Code, Cursor, Windsurf, etc.) - Merge behavior (local, pr, draft-pr) - Permission modes @@ -2184,6 +2184,64 @@ iloom supports multiple version control providers for PR operations. By default, --- +**Database Provider Settings:** + +iloom supports database branching to create isolated database copies per workspace. Configure one provider under `databaseProviders` in `.iloom/settings.json`. Only one provider may be active at a time. + +**Neon:** + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `databaseProviders.neon.projectId` | string | (required) | Neon project ID from your project URL (e.g., `"fantastic-fox-3566354"`) | +| `databaseProviders.neon.parentBranch` | string | (required) | Branch from which new database branches are created | + +**Supabase:** + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `databaseProviders.supabase.projectRef` | string | (required) | Supabase project reference ID (e.g., `"abcdefghijklmnop"`) | +| `databaseProviders.supabase.withData` | boolean | `true` | Whether to include data when creating a new branch | + +**Prerequisites (Supabase):** + +- Supabase CLI installed and authenticated (`supabase login`) +- Supabase project with database branching enabled + +> **Paid plan required:** Supabase database branching requires a paid Supabase plan (Pro or higher). Free-tier projects do not support branching. + +**`withData` option:** When `withData` is `true` (the default), new branches include a copy of the parent branch's data. Set to `false` to create branches with schema only (no data), which is faster for large databases. + +**Example Configuration (Neon):** + +`.iloom/settings.json`: +```json +{ + "databaseProviders": { + "neon": { + "projectId": "fantastic-fox-3566354", + "parentBranch": "main" + } + } +} +``` + +**Example Configuration (Supabase):** + +`.iloom/settings.json`: +```json +{ + "databaseProviders": { + "supabase": { + "projectRef": "abcdefghijklmnop" + } + } +} +``` + +**Note:** Configuring both `neon` and `supabase` simultaneously will cause an error at startup. + +--- + ### il update Update iloom CLI to the latest version. diff --git a/src/cli.ts b/src/cli.ts index 9c380ec0..45b68d7d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2171,58 +2171,60 @@ testJiraCommand } }) -// Test command for Neon integration +// Test command for database provider integration program - .command('test-neon') - .description('Test Neon integration and debug configuration') + .command('test-db') + .description('Test database provider integration and debug configuration') .action(async () => { try { const { SettingsManager } = await import('./lib/SettingsManager.js') - const { createNeonProviderFromSettings } = await import('./utils/neon-helpers.js') + const { createDatabaseProviderFromSettings } = await import('./utils/database-helpers.js') - logger.info('Testing Neon Integration\n') + logger.info('Testing Database Provider Integration\n') // Test 1: Settings Configuration logger.info('1. Settings Configuration:') const settingsManager = new SettingsManager() const settings = await settingsManager.loadSettings() - const neonConfig = settings.databaseProviders?.neon - logger.info(` projectId: ${neonConfig?.projectId ?? '(not configured)'}`) - logger.info(` parentBranch: ${neonConfig?.parentBranch ?? '(not configured)'}`) // Test 2: Create provider and test initialization - logger.info('\n2. Creating NeonProvider...') + logger.info('\n2. Creating database provider...') try { - const neonProvider = createNeonProviderFromSettings(settings) - logger.success(' NeonProvider created successfully') + const provider = createDatabaseProviderFromSettings(settings) + logger.info(` Provider: ${provider.displayName}`) + const isConfigured = provider.isConfigured() + if (isConfigured) { + logger.success(` ${provider.displayName} is configured`) + } else { + logger.warn(` ${provider.displayName} is not configured`) + } // Test 3: CLI availability - logger.info('\n3. Testing Neon CLI availability...') - const isAvailable = await neonProvider.isCliAvailable() + logger.info(`\n3. Testing ${provider.displayName} CLI availability...`) + const isAvailable = await provider.isCliAvailable() if (isAvailable) { - logger.success(' Neon CLI is available') + logger.success(` ${provider.displayName} CLI is available`) } else { - logger.error(' Neon CLI not found') - logger.info(' Install with: npm install -g @neon/cli') + logger.error(` ${provider.displayName} CLI not found`) + logger.info(` Install with: ${provider.installHint}`) return } // Test 4: Authentication - logger.info('\n4. Testing Neon CLI authentication...') - const isAuthenticated = await neonProvider.isAuthenticated() + logger.info(`\n4. Testing ${provider.displayName} CLI authentication...`) + const isAuthenticated = await provider.isAuthenticated() if (isAuthenticated) { - logger.success(' Neon CLI is authenticated') + logger.success(` ${provider.displayName} CLI is authenticated`) } else { - logger.error(' Neon CLI not authenticated') - logger.info(' Run: neon auth') + logger.error(` ${provider.displayName} CLI not authenticated`) return } // Test 5: List branches (if config is valid) - if (neonConfig?.projectId) { + if (isConfigured) { logger.info('\n5. Testing branch listing...') try { - const branches = await neonProvider.listBranches() + const branches = await provider.listBranches() logger.success(` Found ${branches.length} branches:`) for (const branch of branches.slice(0, 5)) { // Show first 5 logger.info(` - ${branch}`) @@ -2234,19 +2236,19 @@ program logger.error(` Failed to list branches: ${error instanceof Error ? error.message : 'Unknown error'}`) } } else { - logger.warn('\n5. Skipping branch listing (Neon not configured in settings)') + logger.warn(`\n5. Skipping branch listing (${provider.displayName} not configured in settings)`) } } catch (error) { - logger.error(` Failed to create NeonProvider: ${error instanceof Error ? error.message : 'Unknown error'}`) + logger.error(` Failed to create database provider: ${error instanceof Error ? error.message : 'Unknown error'}`) if (error instanceof Error && error.message.includes('not configured')) { - logger.info('\n This is expected if Neon is not configured.') - logger.info(' Configure databaseProviders.neon in .iloom/settings.json to test fully.') + logger.info('\n This is expected if no database provider is configured.') + logger.info(' Configure databaseProviders in .iloom/settings.json to test fully.') } } logger.info('\n' + '='.repeat(50)) - logger.success('Neon integration test complete!') + logger.success('Database provider integration test complete!') } catch (error) { logger.error(`Test failed: ${error instanceof Error ? error.message : 'Unknown error'}`) diff --git a/src/commands/cleanup.ts b/src/commands/cleanup.ts index 3d0dd16f..785382de 100644 --- a/src/commands/cleanup.ts +++ b/src/commands/cleanup.ts @@ -9,7 +9,7 @@ import { SettingsManager } from '../lib/SettingsManager.js' import { promptConfirmation } from '../utils/prompt.js' import { IdentifierParser } from '../utils/IdentifierParser.js' import { loadEnvIntoProcess } from '../utils/env.js' -import { createNeonProviderFromSettings } from '../utils/neon-helpers.js' +import { createDatabaseProviderFromSettings } from '../utils/database-helpers.js' import { LoomManager } from '../lib/LoomManager.js' import { TelemetryService } from '../lib/TelemetryService.js' import { MetadataManager } from '../lib/MetadataManager.js' @@ -104,8 +104,8 @@ export class CleanupCommand { const databaseUrlEnvVarName = settings.capabilities?.database?.databaseUrlEnvVarName ?? 'DATABASE_URL' const environmentManager = new EnvironmentManager() - const neonProvider = createNeonProviderFromSettings(settings) - const databaseManager = new DatabaseManager(neonProvider, environmentManager, databaseUrlEnvVarName) + const databaseProvider = createDatabaseProviderFromSettings(settings) + const databaseManager = new DatabaseManager(databaseProvider, environmentManager, databaseUrlEnvVarName) const cliIsolationManager = new CLIIsolationManager() this.resourceCleanup ??= new ResourceCleanup( diff --git a/src/commands/finish.ts b/src/commands/finish.ts index 13671ae3..3970bd61 100644 --- a/src/commands/finish.ts +++ b/src/commands/finish.ts @@ -21,7 +21,7 @@ import { SessionSummaryService } from '../lib/SessionSummaryService.js' import { findMainWorktreePathWithSettings, pushBranchToRemote, extractIssueNumber, getMergeTargetBranch, isPlaceholderCommit, findPlaceholderCommitSha, removePlaceholderCommitFromHead, removePlaceholderCommitFromHistory, executeGitCommand } from '../utils/git.js' import { loadEnvIntoProcess } from '../utils/env.js' import { installDependencies } from '../utils/package-manager.js' -import { createNeonProviderFromSettings } from '../utils/neon-helpers.js' +import { createDatabaseProviderFromSettings } from '../utils/database-helpers.js' import { getConfiguredRepoFromSettings, hasMultipleRemotes } from '../utils/remote.js' import { promptConfirmation } from '../utils/prompt.js' import { UserAbortedCommitError, type FinishResult } from '../types/index.js' @@ -116,8 +116,8 @@ export class FinishCommand { const databaseUrlEnvVarName = settings.capabilities?.database?.databaseUrlEnvVarName ?? 'DATABASE_URL' const environmentManager = new EnvironmentManager() - const neonProvider = createNeonProviderFromSettings(settings) - const databaseManager = new DatabaseManager(neonProvider, environmentManager, databaseUrlEnvVarName) + const databaseProvider = createDatabaseProviderFromSettings(settings) + const databaseManager = new DatabaseManager(databaseProvider, environmentManager, databaseUrlEnvVarName) const cliIsolationManager = new CLIIsolationManager() // Initialize LoomManager if not provided diff --git a/src/commands/ignite.test.ts b/src/commands/ignite.test.ts index ef3ea02e..e938b64c 100644 --- a/src/commands/ignite.test.ts +++ b/src/commands/ignite.test.ts @@ -3190,6 +3190,7 @@ describe('IgniteCommand', () => { const mockTrack = TelemetryService.getInstance().track expect(mockTrack).toHaveBeenCalledWith('session.started', { has_neon: false, + has_supabase: false, language: 'typescript', }) } finally { @@ -3226,6 +3227,7 @@ describe('IgniteCommand', () => { const mockTrack = TelemetryService.getInstance().track expect(mockTrack).toHaveBeenCalledWith('session.started', { has_neon: true, + has_supabase: false, language: 'typescript', }) } finally { @@ -3260,6 +3262,79 @@ describe('IgniteCommand', () => { const mockTrack = TelemetryService.getInstance().track expect(mockTrack).toHaveBeenCalledWith('session.started', { has_neon: false, + has_supabase: false, + language: 'typescript', + }) + } finally { + process.cwd = originalCwd + launchClaudeSpy.mockRestore() + } + }) + + it('has_supabase is true when supabase settings are configured', async () => { + const launchClaudeSpy = vi.spyOn(claudeUtils, 'launchClaude').mockResolvedValue(undefined) + + const mockSettingsManager = { + loadSettings: vi.fn().mockResolvedValue({ + databaseProviders: { + supabase: { projectRef: 'abcdefghijklmnop', parentBranch: 'main' }, + }, + }), + getSpinModel: vi.fn().mockReturnValue('opus'), + } + + const originalCwd = process.cwd + process.cwd = vi.fn().mockReturnValue('/path/to/feat/issue-53__supabase-test') + + const commandWithSupabase = new IgniteCommand( + mockTemplateManager, + mockGitWorktreeManager, + undefined, + mockSettingsManager as never, + ) + + try { + await commandWithSupabase.execute() + + const mockTrack = TelemetryService.getInstance().track + expect(mockTrack).toHaveBeenCalledWith('session.started', { + has_neon: false, + has_supabase: true, + language: 'typescript', + }) + } finally { + process.cwd = originalCwd + launchClaudeSpy.mockRestore() + } + }) + + it('has_supabase is false when supabase settings are absent', async () => { + const launchClaudeSpy = vi.spyOn(claudeUtils, 'launchClaude').mockResolvedValue(undefined) + + const mockSettingsManager = { + loadSettings: vi.fn().mockResolvedValue({ + databaseProviders: {}, + }), + getSpinModel: vi.fn().mockReturnValue('opus'), + } + + const originalCwd = process.cwd + process.cwd = vi.fn().mockReturnValue('/path/to/feat/issue-54__no-supabase') + + const commandWithoutSupabase = new IgniteCommand( + mockTemplateManager, + mockGitWorktreeManager, + undefined, + mockSettingsManager as never, + ) + + try { + await commandWithoutSupabase.execute() + + const mockTrack = TelemetryService.getInstance().track + expect(mockTrack).toHaveBeenCalledWith('session.started', { + has_neon: false, + has_supabase: false, language: 'typescript', }) } finally { diff --git a/src/commands/ignite.ts b/src/commands/ignite.ts index 67de5f0a..fbe30744 100644 --- a/src/commands/ignite.ts +++ b/src/commands/ignite.ts @@ -253,9 +253,11 @@ export class IgniteCommand { // Step 2.0.5.1: Track session.started telemetry try { const hasNeon = !!this.settings?.databaseProviders?.neon + const hasSupabase = !!this.settings?.databaseProviders?.supabase const language = await detectProjectLanguage(context.workspacePath) TelemetryService.getInstance().track('session.started', { has_neon: hasNeon, + has_supabase: hasSupabase, language, }) } catch (error) { diff --git a/src/commands/start.ts b/src/commands/start.ts index 4dc3769b..3181434f 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -16,7 +16,7 @@ import { findMainWorktreePathWithSettings } from '../utils/git.js' import { matchIssueIdentifier } from '../utils/IdentifierParser.js' import { loadEnvIntoProcess } from '../utils/env.js' import { extractSettingsOverrides } from '../utils/cli-overrides.js' -import { createNeonProviderFromSettings } from '../utils/neon-helpers.js' +import { createDatabaseProviderFromSettings } from '../utils/database-helpers.js' import { getConfiguredRepoFromSettings, hasMultipleRemotes } from '../utils/remote.js' import { capitalizeFirstLetter } from '../utils/text.js' import type { StartOptions, StartResult } from '../types/index.js' @@ -100,10 +100,10 @@ export class StartCommand { // Create DatabaseManager with NeonProvider and EnvironmentManager const environmentManager = new EnvironmentManager() - const neonProvider = createNeonProviderFromSettings(settings) + const databaseProvider = createDatabaseProviderFromSettings(settings) const databaseUrlEnvVarName = settings.capabilities?.database?.databaseUrlEnvVarName ?? 'DATABASE_URL' - const databaseManager = new DatabaseManager(neonProvider, environmentManager, databaseUrlEnvVarName) + const databaseManager = new DatabaseManager(databaseProvider, environmentManager, databaseUrlEnvVarName) // Create BranchNamingService (defaults to Claude-based strategy) const branchNaming = new DefaultBranchNamingService({ useClaude: true }) diff --git a/src/lib/DatabaseManager.ts b/src/lib/DatabaseManager.ts index 8b011b0a..ea6e12f3 100644 --- a/src/lib/DatabaseManager.ts +++ b/src/lib/DatabaseManager.ts @@ -80,16 +80,15 @@ export class DatabaseManager { // Check CLI availability and authentication if (!(await this.provider.isCliAvailable())) { - getLogger().warn('Skipping database branch creation: Neon CLI not available') - getLogger().warn('Install with: npm install -g neonctl') + getLogger().warn(`Skipping database branch creation: ${this.provider.displayName} CLI not available`) + getLogger().warn(`Install with: ${this.provider.installHint}`) return null } try { const isAuth = await this.provider.isAuthenticated(cwd) if (!isAuth) { - getLogger().warn('Skipping database branch creation: Not authenticated with Neon CLI') - getLogger().warn('Run: neon auth') + getLogger().warn(`Skipping database branch creation: Not authenticated with ${this.provider.displayName} CLI`) return null } } catch (error) { @@ -151,12 +150,12 @@ export class DatabaseManager { // Check CLI availability and authentication if (!(await this.provider.isCliAvailable())) { - getLogger().info('Skipping database branch deletion: CLI tool not available') + getLogger().info(`Skipping database branch deletion: ${this.provider.displayName} CLI not available. Install with: ${this.provider.installHint}`) return { success: false, deleted: false, notFound: true, - error: "CLI tool not available", + error: `${this.provider.displayName} CLI not available`, branchName } } @@ -164,12 +163,12 @@ export class DatabaseManager { try { const isAuth = await this.provider.isAuthenticated(cwd) if (!isAuth) { - getLogger().warn('Skipping database branch deletion: Not authenticated with DB Provider') + getLogger().warn(`Skipping database branch deletion: Not authenticated with ${this.provider.displayName}`) return { success: false, deleted: false, notFound: false, - error: "Not authenticated with DB Provider", + error: `Not authenticated with ${this.provider.displayName}`, branchName } } diff --git a/src/lib/SettingsManager.ts b/src/lib/SettingsManager.ts index ad269faa..cefae6f2 100644 --- a/src/lib/SettingsManager.ts +++ b/src/lib/SettingsManager.ts @@ -405,6 +405,25 @@ export const NeonSettingsSchema = z.object({ .describe('Branch from which new database branches are created'), }) +/** + * Zod schema for Supabase database provider settings + */ +export const SupabaseSettingsSchema = z.object({ + projectRef: z + .string() + .min(1) + .describe('Supabase project reference ID (e.g., "abcdefghijklmnop")'), + parentBranch: z + .string() + .min(1) + .optional() + .describe('Reserved for future use. Supabase currently always branches from the default branch.'), + withData: z + .boolean() + .optional() + .describe('Whether to include data when creating a new branch (defaults to true)'), +}) + /** * Zod schema for database provider settings */ @@ -413,6 +432,9 @@ export const DatabaseProvidersSettingsSchema = z neon: NeonSettingsSchema.optional().describe( 'Neon database configuration. Requires Neon CLI installed and authenticated for database branching.', ), + supabase: SupabaseSettingsSchema.optional().describe( + 'Supabase database configuration. Requires Supabase CLI installed and authenticated for database branching.', + ), }) .optional() @@ -989,6 +1011,11 @@ export type DevServerSettings = z.infer */ export type NeonSettings = z.infer +/** + * TypeScript type for Supabase settings derived from Zod schema + */ +export type SupabaseSettings = z.infer + /** * TypeScript type for database providers settings derived from Zod schema */ diff --git a/src/lib/providers/NeonProvider.ts b/src/lib/providers/NeonProvider.ts index 383ffde7..fff6d43c 100644 --- a/src/lib/providers/NeonProvider.ts +++ b/src/lib/providers/NeonProvider.ts @@ -52,6 +52,8 @@ export function validateNeonConfig(config: { * Ports functionality from bash/utils/neon-utils.sh */ export class NeonProvider implements DatabaseProvider { + readonly displayName = 'Neon' + readonly installHint = 'npm install -g neonctl' private _isConfigured: boolean = false constructor(private config: NeonConfig) { diff --git a/src/lib/providers/SupabaseProvider.ts b/src/lib/providers/SupabaseProvider.ts new file mode 100644 index 00000000..e1f57c79 --- /dev/null +++ b/src/lib/providers/SupabaseProvider.ts @@ -0,0 +1,396 @@ +import { execa, type ExecaError } from 'execa' +import type { DatabaseProvider, DatabaseDeletionResult } from '../../types/index.js' +import { getLogger } from '../../utils/logger-context.js' + +export interface SupabaseConfig { + projectRef: string + parentBranch?: string + withData?: boolean // default: true +} + +/** + * Validate Supabase configuration + * Checks that required configuration values are present + */ +export function validateSupabaseConfig(config: { + projectRef?: string + parentBranch?: string +}): { valid: boolean; error?: string } { + if (!config.projectRef) { + return { + valid: false, + error: + 'Supabase projectRef is required. Configure in .iloom/settings.json under databaseProviders.supabase', + } + } + + // parentBranch is optional — Supabase currently always branches from the default branch + + // Basic validation for project ref format (alphanumeric and hyphens) + if (!/^[a-zA-Z0-9-]+$/.test(config.projectRef)) { + return { + valid: false, + error: 'Supabase projectRef contains invalid characters', + } + } + + return { valid: true } +} + +/** + * Supabase database provider implementation + * Provides database branching via the Supabase CLI + */ +export class SupabaseProvider implements DatabaseProvider { + private _isConfigured: boolean = false + + readonly displayName = 'Supabase CLI' + readonly installHint = 'Install with: npm install -g supabase' + + constructor(private config: SupabaseConfig) { + getLogger().debug('SupabaseProvider initialized with config:', { + projectRef: config.projectRef, + parentBranch: config.parentBranch, + withData: config.withData, + hasProjectRef: !!config.projectRef, + hasParentBranch: !!config.parentBranch, + }) + + // Validate config but don't throw - just mark as not configured + // This allows the provider to be instantiated even when Supabase is not being used + const validation = validateSupabaseConfig(config) + if (!validation.valid) { + getLogger().debug(`SupabaseProvider not configured: ${validation.error}`) + getLogger().debug('Supabase database branching will not be used') + this._isConfigured = false + } else { + this._isConfigured = true + } + + if (config.parentBranch) { + getLogger().debug( + `parentBranch '${config.parentBranch}' is stored but Supabase currently always branches from the default branch` + ) + } + } + + /** + * Check if provider is properly configured + * Returns true if projectRef and parentBranch are valid in settings + */ + isConfigured(): boolean { + return this._isConfigured + } + + /** + * Execute a Supabase CLI command and return stdout + * Throws an error if the command fails + * + * @param args - Command arguments to pass to supabase CLI + * @param cwd - Optional working directory to run the command from (defaults to current directory) + */ + private async executeSupabaseCommand(args: string[], cwd?: string, timeout: number = 30000): Promise { + // Check if provider is properly configured + if (!this._isConfigured) { + throw new Error( + 'SupabaseProvider is not configured. Check databaseProviders.supabase configuration in .iloom/settings.json' + ) + } + + // Log the exact command being executed for debugging + const command = `supabase ${args.join(' ')}` + getLogger().debug(`Executing Supabase CLI command: ${command}`) + getLogger().debug(`Project ref being used: ${this.config.projectRef}`) + if (cwd) { + getLogger().debug(`Working directory: ${cwd}`) + } + + const result = await execa('supabase', args, { + timeout, + encoding: 'utf8', + stdio: 'pipe', + ...(cwd && { cwd }), + }) + return result.stdout + } + + /** + * Check if supabase CLI is available + */ + async isCliAvailable(): Promise { + try { + await execa('supabase', ['--version'], { + timeout: 5000, + stdio: 'pipe', + }) + return true + } catch (error) { + const errorCode = (error as NodeJS.ErrnoException).code + // ENOENT means the binary was not found on the system + // EACCES means the binary exists but has no execute permission + if (errorCode === 'ENOENT' || errorCode === 'EACCES') { + return false + } + // Any other error (e.g., non-zero exit) still means CLI is present + return true + } + } + + /** + * Check if user is authenticated with Supabase CLI + * + * @param cwd - Optional working directory to run the command from (prevents issues with deleted directories) + * @throws Error if authentication check fails for reasons other than not being authenticated + */ + async isAuthenticated(cwd?: string): Promise { + const cliAvailable = await this.isCliAvailable() + if (!cliAvailable) { + return false + } + + try { + await execa('supabase', ['projects', 'list'], { + timeout: 10000, + stdio: 'pipe', + ...(cwd && { cwd }), + }) + return true + } catch (error) { + const execaError = error as ExecaError + const stderr = execaError.stderr?.trim() ?? '' + + // Check for authentication failure patterns (should return false, not throw) + const isAuthError = + stderr.toLowerCase().includes('not authenticated') || + stderr.toLowerCase().includes('not logged in') || + stderr.toLowerCase().includes('authentication required') || + stderr.toLowerCase().includes('login required') || + stderr.toLowerCase().includes('access token not provided') || + stderr.toLowerCase().includes('you need to be logged in') + + if (isAuthError) { + return false + } + + // For any other error, let it bubble up + throw error + } + } + + /** + * Sanitize branch name for Supabase (replace slashes with hyphens) + * Supabase uses hyphens as separator (not underscores like Neon) + */ + sanitizeBranchName(branchName: string): string { + let sanitized = branchName + .replace(/\//g, '-') // replace slashes with hyphens + .replace(/[^a-zA-Z0-9_-]/g, '') // remove chars that aren't alphanumeric, hyphens, or underscores + .replace(/^-+/, '') // strip leading hyphens (prevents CLI flag injection) + return sanitized || 'unnamed-branch' + } + + /** + * List all branches in the Supabase project + * + * @param cwd - Optional working directory to run commands from + */ + async listBranches(cwd?: string): Promise { + const output = await this.executeSupabaseCommand( + ['branches', 'list', '--project-ref', this.config.projectRef, '-o', 'json'], + cwd + ) + + interface SupabaseBranch { + name: string + [key: string]: unknown + } + + let jsonString = output + // CLI tools can prepend warnings to stdout; strip non-JSON prefixes + const firstBracket = output.indexOf('[') + if (firstBracket > 0) { + jsonString = output.slice(firstBracket) + } + + let branches: SupabaseBranch[] + try { + branches = JSON.parse(jsonString) + } catch (parseError) { + throw new Error( + `Failed to parse Supabase branch list as JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}` + ) + } + return branches.map((branch) => branch.name) + } + + /** + * Check if a branch exists + * Uses `supabase branches get` for a direct lookup (more efficient than listing all) + * + * @param name - Branch name to check + * @param cwd - Optional working directory to run commands from + */ + async branchExists(name: string, cwd?: string): Promise { + const sanitizedName = this.sanitizeBranchName(name) + try { + await this.executeSupabaseCommand( + ['branches', 'get', sanitizedName, '--project-ref', this.config.projectRef], + cwd + ) + return true + } catch (error) { + const execaError = error as ExecaError + const stderr = execaError.stderr?.toLowerCase() ?? '' + const stdout = execaError.stdout?.toLowerCase() ?? '' + const message = (error instanceof Error ? error.message : String(error)).toLowerCase() + + // Only return false for explicit "not found" error signatures + // Note: Supabase CLI uses exitCode=1 for "not found" and exitCode=2 for auth errors + const isNotFound = + stderr.includes('not found') || + stderr.includes('does not exist') || + stderr.includes('no branch') || + stdout.includes('not found') || + message.includes('not found') || + message.includes('does not exist') + + if (isNotFound) { + return false + } + + // For any other error (auth, network, CLI unavailable), rethrow + throw error + } + } + + /** + * Get connection string for a specific branch + * Parses POSTGRES_URL_NON_POOLING from `supabase branches get -o env` output + * Connection strings are never logged at info level or above (security) + * + * @param branch - Branch name to get connection string for + * @param cwd - Optional working directory to run commands from + */ + async getConnectionString(branch: string, cwd?: string): Promise { + const sanitizedBranch = this.sanitizeBranchName(branch) + const output = await this.executeSupabaseCommand( + ['branches', 'get', sanitizedBranch, '--project-ref', this.config.projectRef, '-o', 'env'], + cwd + ) + + // Parse POSTGRES_URL_NON_POOLING from env output + const match = output.match(/^POSTGRES_URL_NON_POOLING=(.+)$/m) + if (!match?.[1]) { + throw new Error( + `Could not find POSTGRES_URL_NON_POOLING in branch '${branch}' environment output` + ) + } + + const connectionString = match[1].trim() + // Log only at debug level - never at info level or above (security) + getLogger().debug(`Connection string retrieved for branch '${branch}'`) + return connectionString + } + + /** + * Create a new database branch + * Returns connection string for the branch + * + * Note: Supabase preview branches always branch from the production database. + * The fromBranch parameter is accepted for interface compatibility but ignored. + * + * @param name - Name for the new branch + * @param fromBranch - Accepted for interface compatibility but ignored (Supabase always branches from production) + * @param cwd - Optional working directory to run commands from + */ + async createBranch(name: string, fromBranch?: string, cwd?: string): Promise { + void fromBranch // accepted for interface compatibility but ignored - Supabase always branches from production + + const sanitizedName = this.sanitizeBranchName(name) + + getLogger().info('Creating Supabase database branch...') + getLogger().info(` New branch: ${sanitizedName}`) + + const args = [ + 'branches', + 'create', + sanitizedName, + '--project-ref', + this.config.projectRef, + ] + + // Add --with-data flag when withData is true (default: true per acceptance criteria) + if (this.config.withData !== false) { + args.push('--with-data') + } + + await this.executeSupabaseCommand(args, cwd, 300000) + + getLogger().success('Database branch created successfully') + + // Get the connection string for the new branch + getLogger().info('Getting connection string for new database branch...') + const connectionString = await this.getConnectionString(sanitizedName, cwd) + + return connectionString + } + + /** + * Delete a database branch + * + * @param name - Name of the branch to delete + * @param isPreview - Accepted but ignored (Neon-specific concept for Vercel preview databases) + * @param cwd - Optional working directory to run commands from (prevents issues with deleted directories) + */ + async deleteBranch( + name: string, + isPreview: boolean = false, + cwd?: string + ): Promise { + void isPreview // accepted but ignored - Neon-specific concept + + const sanitizedName = this.sanitizeBranchName(name) + + getLogger().info(`Checking for Supabase database branch: ${sanitizedName}`) + + try { + const exists = await this.branchExists(sanitizedName, cwd) + + if (!exists) { + getLogger().info(`No database branch found for '${name}'`) + return { + success: true, + deleted: false, + notFound: true, + branchName: sanitizedName, + } + } + + // Branch exists - delete it + getLogger().info(`Deleting Supabase database branch: ${sanitizedName}`) + await this.executeSupabaseCommand( + ['branches', 'delete', sanitizedName, '--project-ref', this.config.projectRef], + cwd + ) + getLogger().success('Database branch deleted successfully') + + return { + success: true, + deleted: true, + notFound: false, + branchName: sanitizedName, + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + getLogger().error(`Failed to delete database branch: ${errorMessage}`) + return { + success: false, + deleted: false, + notFound: false, + error: errorMessage, + branchName: sanitizedName, + } + } + } + +} diff --git a/src/types/index.ts b/src/types/index.ts index 6c187091..92faa9c7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -110,6 +110,10 @@ export interface DatabaseDeletionResult { } export interface DatabaseProvider { + // Human-readable provider metadata + displayName: string + installHint: string + // Core operations createBranch(name: string, fromBranch?: string, cwd?: string): Promise deleteBranch(name: string, isPreview?: boolean, cwd?: string): Promise @@ -117,9 +121,7 @@ export interface DatabaseProvider { listBranches(cwd?: string): Promise branchExists(name: string, cwd?: string): Promise - // Additional operations for Vercel integration and validation - findPreviewBranch(branchName: string, cwd?: string): Promise - getBranchNameFromEndpoint(endpointId: string, cwd?: string): Promise + // Additional operations for validation sanitizeBranchName(branchName: string): string isAuthenticated(cwd?: string): Promise isCliAvailable(): Promise diff --git a/src/types/telemetry.ts b/src/types/telemetry.ts index bca6df91..85a7a11c 100644 --- a/src/types/telemetry.ts +++ b/src/types/telemetry.ts @@ -78,6 +78,7 @@ export interface ContributeStartedProperties { export interface SessionStartedProperties { has_neon: boolean + has_supabase: boolean language: string } diff --git a/src/utils/database-helpers.test.ts b/src/utils/database-helpers.test.ts new file mode 100644 index 00000000..9483c1f1 --- /dev/null +++ b/src/utils/database-helpers.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest' +import { NeonProvider } from '../lib/providers/NeonProvider.js' +import { SupabaseProvider } from '../lib/providers/SupabaseProvider.js' +import type { IloomSettings } from '../lib/SettingsManager.js' +import { createDatabaseProviderFromSettings } from './database-helpers.js' + +vi.mock('../lib/providers/NeonProvider.js', () => { + const NeonProvider = vi.fn(function (this: { projectId: string; parentBranch: string; isConfigured: () => boolean }, config: { projectId: string; parentBranch: string }) { + this.projectId = config.projectId + this.parentBranch = config.parentBranch + this.isConfigured = () => !!(config.projectId && config.parentBranch) + }) + return { NeonProvider } +}) + +vi.mock('../lib/providers/SupabaseProvider.js', () => { + const SupabaseProvider = vi.fn(function (this: { projectRef: string; parentBranch: string; isConfigured: () => boolean }, config: { projectRef: string; parentBranch: string; withData?: boolean }) { + this.projectRef = config.projectRef + this.parentBranch = config.parentBranch + this.isConfigured = () => !!(config.projectRef && config.parentBranch) + }) + return { SupabaseProvider } +}) + +function makeSettings(overrides: Partial = {}): IloomSettings { + return overrides as IloomSettings +} + +describe('createDatabaseProviderFromSettings', () => { + describe('when neon is configured', () => { + it('returns a NeonProvider configured with the neon settings', () => { + const settings = makeSettings({ + databaseProviders: { neon: { projectId: 'proj-123', parentBranch: 'main' } }, + }) + + const provider = createDatabaseProviderFromSettings(settings) + + expect(NeonProvider).toHaveBeenCalledWith({ projectId: 'proj-123', parentBranch: 'main' }) + expect(provider.isConfigured()).toBe(true) + }) + }) + + describe('when supabase is configured', () => { + it('returns a SupabaseProvider configured with the supabase settings', () => { + const settings = makeSettings({ + databaseProviders: { + supabase: { projectRef: 'ref-abc', parentBranch: 'main', withData: true }, + }, + }) + + createDatabaseProviderFromSettings(settings) + + expect(SupabaseProvider).toHaveBeenCalledWith({ + projectRef: 'ref-abc', + parentBranch: 'main', + withData: true, + }) + }) + + it('omits withData when not specified in settings', () => { + const settings = makeSettings({ + databaseProviders: { + supabase: { projectRef: 'ref-abc', parentBranch: 'main' }, + }, + }) + + createDatabaseProviderFromSettings(settings) + + expect(SupabaseProvider).toHaveBeenCalledWith({ + projectRef: 'ref-abc', + parentBranch: 'main', + }) + }) + }) + + describe('when neither is configured', () => { + it('returns an unconfigured NeonProvider when databaseProviders is undefined', () => { + const settings = makeSettings({}) + + const provider = createDatabaseProviderFromSettings(settings) + + expect(NeonProvider).toHaveBeenCalledWith({ projectId: '', parentBranch: '' }) + expect(provider.isConfigured()).toBe(false) + }) + + it('returns an unconfigured NeonProvider when databaseProviders is empty', () => { + const settings = makeSettings({ databaseProviders: {} }) + + const provider = createDatabaseProviderFromSettings(settings) + + expect(NeonProvider).toHaveBeenCalledWith({ projectId: '', parentBranch: '' }) + expect(provider.isConfigured()).toBe(false) + }) + }) + + describe('when both neon and supabase are configured', () => { + it('throws an error with a clear message', () => { + const settings = makeSettings({ + databaseProviders: { + neon: { projectId: 'proj-123', parentBranch: 'main' }, + supabase: { projectRef: 'ref-abc', parentBranch: 'main', withData: true }, + }, + }) + + expect(() => createDatabaseProviderFromSettings(settings)).toThrow( + 'Cannot configure both Neon and Supabase database providers simultaneously.', + ) + }) + }) +}) diff --git a/src/utils/database-helpers.ts b/src/utils/database-helpers.ts new file mode 100644 index 00000000..94861d0e --- /dev/null +++ b/src/utils/database-helpers.ts @@ -0,0 +1,37 @@ +import { NeonProvider } from '../lib/providers/NeonProvider.js' +import { SupabaseProvider } from '../lib/providers/SupabaseProvider.js' +import type { IloomSettings } from '../lib/SettingsManager.js' +import type { DatabaseProvider } from '../types/index.js' + +/** + * Create the appropriate database provider from iloom settings. + * + * - Returns a NeonProvider when databaseProviders.neon is configured + * - Returns a SupabaseProvider when databaseProviders.supabase is configured + * - Throws if both neon and supabase are configured simultaneously + * - Returns an unconfigured NeonProvider (isConfigured() = false) when neither is configured + */ +export function createDatabaseProviderFromSettings(settings: IloomSettings): DatabaseProvider { + const neonConfig = settings.databaseProviders?.neon + const supabaseConfig = settings.databaseProviders?.supabase + + if (neonConfig && supabaseConfig) { + throw new Error( + 'Cannot configure both Neon and Supabase database providers simultaneously. ' + + 'Remove one from databaseProviders in .iloom/settings.json.', + ) + } + + if (supabaseConfig) { + return new SupabaseProvider({ + projectRef: supabaseConfig.projectRef, + ...(supabaseConfig.parentBranch && { parentBranch: supabaseConfig.parentBranch }), + ...(supabaseConfig.withData !== undefined && { withData: supabaseConfig.withData }), + }) + } + + return new NeonProvider({ + projectId: neonConfig?.projectId ?? '', + parentBranch: neonConfig?.parentBranch ?? '', + }) +} diff --git a/src/utils/neon-helpers.ts b/src/utils/neon-helpers.ts deleted file mode 100644 index c0ba3c82..00000000 --- a/src/utils/neon-helpers.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NeonProvider } from '../lib/providers/NeonProvider.js' -import type { IloomSettings } from '../lib/SettingsManager.js' - -/** - * Create NeonProvider from settings configuration - * Returns provider with isConfigured() = false if neon settings missing - */ -export function createNeonProviderFromSettings(settings: IloomSettings): NeonProvider { - const neonConfig = settings.databaseProviders?.neon - - return new NeonProvider({ - projectId: neonConfig?.projectId ?? '', - parentBranch: neonConfig?.parentBranch ?? '', - }) -} diff --git a/templates/prompts/issue-prompt.txt b/templates/prompts/issue-prompt.txt index d36a6372..35851e82 100644 --- a/templates/prompts/issue-prompt.txt +++ b/templates/prompts/issue-prompt.txt @@ -226,7 +226,7 @@ The `il` command can also be used as a shorter alias. ### Loom Isolation Features Each loom provides: - Dedicated Git worktree (no branch conflicts) -- Unique database branch via Neon integration +- Unique database branch via database branching integration - Color-coded terminal/VS Code for visual context switching - Deterministic port assignment (3000 + issue number) diff --git a/templates/prompts/regular-prompt.txt b/templates/prompts/regular-prompt.txt index e799082d..4a05f26e 100644 --- a/templates/prompts/regular-prompt.txt +++ b/templates/prompts/regular-prompt.txt @@ -133,7 +133,7 @@ The `il` command can also be used as a shorter alias. ### Loom Isolation Features Each loom provides: - Dedicated Git worktree (no branch conflicts) -- Unique database branch via Neon integration +- Unique database branch via database branching integration - Color-coded terminal/VS Code for visual context switching - Deterministic port assignment (3000 + issue number) diff --git a/tests/lib/DatabaseManager.test.ts b/tests/lib/DatabaseManager.test.ts index 8c36ff36..377fade5 100644 --- a/tests/lib/DatabaseManager.test.ts +++ b/tests/lib/DatabaseManager.test.ts @@ -38,6 +38,8 @@ describe('DatabaseManager', () => { beforeEach(() => { // Create mock provider mockProvider = { + displayName: 'TestDB', + installHint: 'npm install -g testdb-cli', isCliAvailable: vi.fn().mockResolvedValue(true), isAuthenticated: vi.fn().mockResolvedValue(true), isConfigured: vi.fn().mockReturnValue(true), @@ -52,8 +54,6 @@ describe('DatabaseManager', () => { branchExists: vi.fn().mockResolvedValue(false), listBranches: vi.fn().mockResolvedValue([]), getConnectionString: vi.fn().mockResolvedValue('postgresql://test-connection'), - findPreviewBranch: vi.fn().mockResolvedValue(null), - getBranchNameFromEndpoint: vi.fn().mockResolvedValue(null), } // Create mock environment @@ -368,7 +368,7 @@ describe('DatabaseManager', () => { success: false, deleted: false, notFound: true, - error: 'CLI tool not available', + error: 'TestDB CLI not available', branchName: 'feature-branch' }) }) @@ -386,7 +386,7 @@ describe('DatabaseManager', () => { success: false, deleted: false, notFound: false, - error: 'Not authenticated with DB Provider', + error: 'Not authenticated with TestDB', branchName: 'feature-branch' }) }) diff --git a/tests/lib/providers/SupabaseProvider.test.ts b/tests/lib/providers/SupabaseProvider.test.ts new file mode 100644 index 00000000..f77023d8 --- /dev/null +++ b/tests/lib/providers/SupabaseProvider.test.ts @@ -0,0 +1,682 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { execa, type ExecaReturnValue, type ExecaError } from 'execa' +import { SupabaseProvider, validateSupabaseConfig } from '../../../src/lib/providers/SupabaseProvider.js' + +// Mock execa for CLI command execution +vi.mock('execa') + +describe('SupabaseProvider', () => { + let provider: SupabaseProvider + + beforeEach(() => { + provider = new SupabaseProvider({ + projectRef: 'test-project-ref', + parentBranch: 'main', + withData: true, + }) + }) + + describe('validateSupabaseConfig', () => { + it('should return valid for correct config', () => { + const result = validateSupabaseConfig({ + projectRef: 'valid-project-ref', + parentBranch: 'main', + }) + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + }) + + it('should return invalid when projectRef is missing', () => { + const result = validateSupabaseConfig({ parentBranch: 'main' }) + expect(result.valid).toBe(false) + expect(result.error).toContain('projectRef is required') + }) + + it('should return valid when parentBranch is omitted (optional for Supabase)', () => { + const result = validateSupabaseConfig({ projectRef: 'test-ref' }) + expect(result.valid).toBe(true) + }) + + it('should return invalid when projectRef contains invalid characters', () => { + const result = validateSupabaseConfig({ + projectRef: 'invalid_ref!@#', + parentBranch: 'main', + }) + expect(result.valid).toBe(false) + expect(result.error).toContain('invalid characters') + }) + }) + + describe('constructor / isConfigured', () => { + it('should return true when configured with valid config', () => { + expect(provider.isConfigured()).toBe(true) + }) + + it('should return false when projectRef is missing', () => { + const unconfiguredProvider = new SupabaseProvider({ + projectRef: '', + parentBranch: 'main', + }) + expect(unconfiguredProvider.isConfigured()).toBe(false) + }) + + it('should return true when parentBranch is omitted (optional for Supabase)', () => { + const unconfiguredProvider = new SupabaseProvider({ + projectRef: 'test-ref', + }) + expect(unconfiguredProvider.isConfigured()).toBe(true) + }) + + it('should not throw when config is invalid (graceful degradation)', () => { + expect(() => new SupabaseProvider({ projectRef: '', parentBranch: '' })).not.toThrow() + }) + }) + + describe('displayName and installHint', () => { + it('should return "Supabase CLI" as displayName', () => { + expect(provider.displayName).toBe('Supabase CLI') + }) + + it('should return install instruction as installHint', () => { + expect(provider.installHint).toContain('supabase') + }) + }) + + describe('isCliAvailable', () => { + it('should return true when supabase CLI is available', async () => { + vi.mocked(execa).mockResolvedValue({ stdout: '2.24.3', stderr: '' } as ExecaReturnValue) + + const result = await provider.isCliAvailable() + + expect(result).toBe(true) + expect(execa).toHaveBeenCalledWith('supabase', ['--version'], expect.any(Object)) + }) + + it('should return false when supabase CLI is not installed (ENOENT)', async () => { + const enoentError = Object.assign(new Error('spawn supabase ENOENT'), { + code: 'ENOENT', + }) + vi.mocked(execa).mockRejectedValue(enoentError) + + const result = await provider.isCliAvailable() + + expect(result).toBe(false) + }) + + it('should return false when supabase CLI has no execute permission (EACCES)', async () => { + const eaccesError = Object.assign(new Error('spawn supabase EACCES'), { + code: 'EACCES', + }) + vi.mocked(execa).mockRejectedValue(eaccesError) + + const result = await provider.isCliAvailable() + + expect(result).toBe(false) + }) + + it('should return true when CLI is present but version flag fails for other reasons', async () => { + // Non-ENOENT/EACCES errors mean CLI is present but something else is wrong + const otherError = Object.assign(new Error('some other error'), { + code: 'EPERM', + exitCode: 1, + }) + vi.mocked(execa).mockRejectedValue(otherError) + + const result = await provider.isCliAvailable() + + expect(result).toBe(true) + }) + }) + + describe('isAuthenticated', () => { + it('should return true when authenticated', async () => { + // First call: CLI available + vi.mocked(execa).mockResolvedValueOnce({ stdout: '', stderr: '' } as ExecaReturnValue) + // Second call: supabase projects list succeeds + vi.mocked(execa).mockResolvedValueOnce({ + stdout: '[{"id":"proj-123","name":"My Project"}]', + stderr: '', + } as ExecaReturnValue) + + const result = await provider.isAuthenticated() + + expect(result).toBe(true) + expect(execa).toHaveBeenCalledWith('supabase', ['projects', 'list'], expect.any(Object)) + }) + + it('should return false when CLI not available', async () => { + const enoentError = Object.assign(new Error('spawn supabase ENOENT'), { + code: 'ENOENT', + }) + vi.mocked(execa).mockRejectedValue(enoentError) + + const result = await provider.isAuthenticated() + + expect(result).toBe(false) + }) + + it('should return false when not authenticated (not authenticated error)', async () => { + // First call: CLI available + vi.mocked(execa).mockResolvedValueOnce({ stdout: '', stderr: '' } as ExecaReturnValue) + // Second call: projects list fails with auth error + const authError = Object.assign(new Error('not authenticated'), { + stderr: 'Error: not authenticated', + exitCode: 1, + }) as ExecaError + vi.mocked(execa).mockRejectedValueOnce(authError) + + const result = await provider.isAuthenticated() + + expect(result).toBe(false) + }) + + it('should return false when not logged in', async () => { + vi.mocked(execa).mockResolvedValueOnce({ stdout: '', stderr: '' } as ExecaReturnValue) + const authError = Object.assign(new Error('not logged in'), { + stderr: 'Error: you need to be logged in to use this command', + exitCode: 1, + }) as ExecaError + vi.mocked(execa).mockRejectedValueOnce(authError) + + const result = await provider.isAuthenticated() + + expect(result).toBe(false) + }) + + it('should return false when access token not provided', async () => { + vi.mocked(execa).mockResolvedValueOnce({ stdout: '', stderr: '' } as ExecaReturnValue) + const authError = Object.assign(new Error('access token not provided'), { + stderr: 'Error: access token not provided', + exitCode: 1, + }) as ExecaError + vi.mocked(execa).mockRejectedValueOnce(authError) + + const result = await provider.isAuthenticated() + + expect(result).toBe(false) + }) + + it('should throw for unexpected non-auth errors', async () => { + vi.mocked(execa).mockResolvedValueOnce({ stdout: '', stderr: '' } as ExecaReturnValue) + const unexpectedError = Object.assign(new Error('unexpected error'), { + stderr: 'Error: something unexpected happened', + exitCode: 2, + }) as ExecaError + vi.mocked(execa).mockRejectedValueOnce(unexpectedError) + + await expect(provider.isAuthenticated()).rejects.toThrow('unexpected error') + }) + }) + + describe('sanitizeBranchName', () => { + it('should replace forward slashes with hyphens', () => { + const result = provider.sanitizeBranchName('feat/issue-5__database') + + expect(result).toBe('feat-issue-5__database') + }) + + it('should handle multiple slashes', () => { + const result = provider.sanitizeBranchName('feature/issue/25/test') + + expect(result).toBe('feature-issue-25-test') + }) + + it('should return unchanged string with no slashes', () => { + const result = provider.sanitizeBranchName('issue-25') + + expect(result).toBe('issue-25') + }) + + it('should return unnamed-branch for empty string', () => { + const result = provider.sanitizeBranchName('') + + expect(result).toBe('unnamed-branch') + }) + + it('should strip leading hyphens to prevent CLI flag injection', () => { + const result = provider.sanitizeBranchName('--malicious-flag') + + expect(result).toBe('malicious-flag') + }) + + it('should remove invalid characters', () => { + const result = provider.sanitizeBranchName('feat@issue#5!test') + + expect(result).toBe('featissue5test') + }) + + it('should return unnamed-branch when all characters are invalid', () => { + const result = provider.sanitizeBranchName('!@#$%') + + expect(result).toBe('unnamed-branch') + }) + }) + + describe('listBranches', () => { + it('should return array of branch names', async () => { + const mockBranches = [ + { name: 'main', id: 'branch-main-123' }, + { name: 'development', id: 'branch-dev-456' }, + { name: 'feat-issue-5-database', id: 'branch-feat-789' }, + ] + vi.mocked(execa).mockResolvedValue({ + stdout: JSON.stringify(mockBranches), + stderr: '', + } as ExecaReturnValue) + + const result = await provider.listBranches() + + expect(result).toEqual(['main', 'development', 'feat-issue-5-database']) + expect(execa).toHaveBeenCalledWith( + 'supabase', + ['branches', 'list', '--project-ref', 'test-project-ref', '-o', 'json'], + expect.any(Object) + ) + }) + + it('should handle empty branch list', async () => { + vi.mocked(execa).mockResolvedValue({ + stdout: '[]', + stderr: '', + } as ExecaReturnValue) + + const result = await provider.listBranches() + + expect(result).toEqual([]) + }) + + it('should handle stdout with warning prefix before JSON', async () => { + const mockBranches = [{ name: 'main', id: 'branch-main-123' }] + vi.mocked(execa).mockResolvedValue({ + stdout: `WARNING: some deprecation notice\n${JSON.stringify(mockBranches)}`, + stderr: '', + } as ExecaReturnValue) + + const result = await provider.listBranches() + + expect(result).toEqual(['main']) + }) + + it('should throw descriptive error on invalid JSON output', async () => { + vi.mocked(execa).mockResolvedValue({ + stdout: 'this is not valid json', + stderr: '', + } as ExecaReturnValue) + + await expect(provider.listBranches()).rejects.toThrow('Failed to parse Supabase branch list as JSON') + }) + + it('should throw on CLI error', async () => { + const cliError = Object.assign(new Error('command failed'), { + stderr: 'Error: project not found', + exitCode: 1, + }) as ExecaError + vi.mocked(execa).mockRejectedValue(cliError) + + await expect(provider.listBranches()).rejects.toThrow('command failed') + }) + + it('should throw when provider is not configured', async () => { + const unconfiguredProvider = new SupabaseProvider({ projectRef: '', parentBranch: '' }) + + await expect(unconfiguredProvider.listBranches()).rejects.toThrow( + 'SupabaseProvider is not configured' + ) + }) + }) + + describe('branchExists', () => { + it('should return true when branch exists', async () => { + vi.mocked(execa).mockResolvedValue({ + stdout: '{"name":"feat-issue-5-database","id":"branch-feat-789"}', + stderr: '', + } as ExecaReturnValue) + + const result = await provider.branchExists('feat-issue-5-database') + + expect(result).toBe(true) + expect(execa).toHaveBeenCalledWith( + 'supabase', + ['branches', 'get', 'feat-issue-5-database', '--project-ref', 'test-project-ref'], + expect.any(Object) + ) + }) + + it('should return false when branch does not exist (not found in error message)', async () => { + const notFoundError = Object.assign(new Error('branch not found'), { + stderr: 'Error: branch not found', + exitCode: 1, + }) + vi.mocked(execa).mockRejectedValue(notFoundError) + + const result = await provider.branchExists('nonexistent-branch') + + expect(result).toBe(false) + }) + + it('should rethrow when exit code is 1 but no "not found" message (ambiguous error)', async () => { + const ambiguousError = Object.assign(new Error('command failed'), { + stderr: '', + exitCode: 1, + }) + vi.mocked(execa).mockRejectedValue(ambiguousError) + + await expect(provider.branchExists('nonexistent-branch')).rejects.toThrow('command failed') + }) + + it('should rethrow auth errors instead of returning false', async () => { + const authError = Object.assign(new Error('not authenticated'), { + stderr: 'Error: not authenticated', + exitCode: 2, + code: 'ERR_NON_ZERO_EXIT', + }) + vi.mocked(execa).mockRejectedValue(authError) + + await expect(provider.branchExists('some-branch')).rejects.toThrow('not authenticated') + }) + }) + + describe('getConnectionString', () => { + it('should parse POSTGRES_URL_NON_POOLING from env output', async () => { + const envOutput = [ + 'DB_HOST=db.example.supabase.co', + 'DB_PORT=5432', + 'POSTGRES_URL_NON_POOLING=postgresql://postgres:password@db.example.supabase.co:5432/postgres', + 'DB_USER=postgres', + ].join('\n') + vi.mocked(execa).mockResolvedValue({ + stdout: envOutput, + stderr: '', + } as ExecaReturnValue) + + const result = await provider.getConnectionString('feat-issue-5') + + expect(result).toBe( + 'postgresql://postgres:password@db.example.supabase.co:5432/postgres' + ) + expect(execa).toHaveBeenCalledWith( + 'supabase', + [ + 'branches', + 'get', + 'feat-issue-5', + '--project-ref', + 'test-project-ref', + '-o', + 'env', + ], + expect.any(Object) + ) + }) + + it('should throw when branch not found', async () => { + vi.mocked(execa).mockRejectedValue(new Error('branch not found')) + + await expect(provider.getConnectionString('nonexistent-branch')).rejects.toThrow( + 'branch not found' + ) + }) + + it('should throw when POSTGRES_URL_NON_POOLING not in output', async () => { + vi.mocked(execa).mockResolvedValue({ + stdout: 'DB_HOST=db.example.supabase.co\nDB_PORT=5432', + stderr: '', + } as ExecaReturnValue) + + await expect(provider.getConnectionString('feat-issue-5')).rejects.toThrow( + 'Could not find POSTGRES_URL_NON_POOLING' + ) + }) + }) + + describe('createBranch', () => { + it('should create branch with --with-data when config.withData is true', async () => { + const mockConnectionString = + 'postgresql://postgres:pass@db.example.supabase.co:5432/postgres' + // First call: create branch + vi.mocked(execa).mockResolvedValueOnce({ + stdout: 'Branch created successfully', + stderr: '', + } as ExecaReturnValue) + // Second call: get connection string + vi.mocked(execa).mockResolvedValueOnce({ + stdout: `POSTGRES_URL_NON_POOLING=${mockConnectionString}`, + stderr: '', + } as ExecaReturnValue) + + const result = await provider.createBranch('feat/issue-5') + + expect(result).toBe(mockConnectionString) + // Supabase CLI uses positional name arg; no --branch-name flag exists + expect(execa).toHaveBeenCalledWith( + 'supabase', + [ + 'branches', + 'create', + 'feat-issue-5', + '--project-ref', + 'test-project-ref', + '--with-data', + ], + expect.any(Object) + ) + }) + + it('should create branch without --with-data when config.withData is false', async () => { + const providerNoData = new SupabaseProvider({ + projectRef: 'test-project-ref', + parentBranch: 'main', + withData: false, + }) + const mockConnectionString = 'postgresql://postgres:pass@db.example.supabase.co:5432/postgres' + vi.mocked(execa).mockResolvedValueOnce({ + stdout: 'Branch created', + stderr: '', + } as ExecaReturnValue) + vi.mocked(execa).mockResolvedValueOnce({ + stdout: `POSTGRES_URL_NON_POOLING=${mockConnectionString}`, + stderr: '', + } as ExecaReturnValue) + + const result = await providerNoData.createBranch('feat-issue-5') + + expect(result).toBe(mockConnectionString) + // Should not include --with-data + expect(execa).toHaveBeenCalledWith( + 'supabase', + [ + 'branches', + 'create', + 'feat-issue-5', + '--project-ref', + 'test-project-ref', + ], + expect.any(Object) + ) + }) + + it('should ignore fromBranch parameter (Supabase always branches from production)', async () => { + vi.mocked(execa).mockResolvedValueOnce({ + stdout: 'Branch created', + stderr: '', + } as ExecaReturnValue) + vi.mocked(execa).mockResolvedValueOnce({ + stdout: 'POSTGRES_URL_NON_POOLING=postgresql://connection', + stderr: '', + } as ExecaReturnValue) + + await provider.createBranch('my-feature', 'staging') + + // --branch-name or similar parent flag should NOT be in args + expect(execa).toHaveBeenCalledWith( + 'supabase', + expect.not.arrayContaining(['--branch-name', 'staging']), + expect.any(Object) + ) + }) + + it('should sanitize branch name before creation', async () => { + vi.mocked(execa).mockResolvedValueOnce({ + stdout: 'Branch created', + stderr: '', + } as ExecaReturnValue) + vi.mocked(execa).mockResolvedValueOnce({ + stdout: 'POSTGRES_URL_NON_POOLING=postgresql://connection', + stderr: '', + } as ExecaReturnValue) + + await provider.createBranch('feature/issue/25/test') + + expect(execa).toHaveBeenCalledWith( + 'supabase', + expect.arrayContaining(['create', 'feature-issue-25-test']), + expect.any(Object) + ) + }) + + it('should return connection string after creation', async () => { + const mockConnectionString = 'postgresql://postgres:pass@db.example.supabase.co:5432/postgres' + vi.mocked(execa).mockResolvedValueOnce({ + stdout: 'Branch created', + stderr: '', + } as ExecaReturnValue) + vi.mocked(execa).mockResolvedValueOnce({ + stdout: `POSTGRES_URL_NON_POOLING=${mockConnectionString}`, + stderr: '', + } as ExecaReturnValue) + + const result = await provider.createBranch('feat-issue-5') + + expect(result).toBe(mockConnectionString) + }) + + it('should throw on creation failure', async () => { + vi.mocked(execa).mockRejectedValueOnce(new Error('Failed to create branch')) + + await expect(provider.createBranch('feat-issue-5')).rejects.toThrow( + 'Failed to create branch' + ) + }) + + it('should default withData to true when not specified in config', async () => { + const providerDefaultData = new SupabaseProvider({ + projectRef: 'test-project-ref', + parentBranch: 'main', + // withData not specified - should default to true + }) + vi.mocked(execa).mockResolvedValueOnce({ + stdout: 'Branch created', + stderr: '', + } as ExecaReturnValue) + vi.mocked(execa).mockResolvedValueOnce({ + stdout: 'POSTGRES_URL_NON_POOLING=postgresql://connection', + stderr: '', + } as ExecaReturnValue) + + await providerDefaultData.createBranch('feat-issue-5') + + expect(execa).toHaveBeenCalledWith( + 'supabase', + expect.arrayContaining(['--with-data']), + expect.any(Object) + ) + }) + }) + + describe('deleteBranch', () => { + it('should return deleted=true when branch deleted successfully', async () => { + // First call: branchExists check via 'branches get' + vi.mocked(execa).mockResolvedValueOnce({ + stdout: '{"name":"feat-issue-5","id":"branch-123"}', + stderr: '', + } as ExecaReturnValue) + // Second call: delete branch + vi.mocked(execa).mockResolvedValueOnce({ + stdout: 'Branch deleted', + stderr: '', + } as ExecaReturnValue) + + const result = await provider.deleteBranch('feat-issue-5', false) + + expect(execa).toHaveBeenCalledWith( + 'supabase', + ['branches', 'delete', 'feat-issue-5', '--project-ref', 'test-project-ref'], + expect.any(Object) + ) + expect(result).toEqual({ + success: true, + deleted: true, + notFound: false, + branchName: 'feat-issue-5', + }) + }) + + it('should return notFound=true when branch does not exist', async () => { + // branchExists check via 'branches get' throws (branch not found) + vi.mocked(execa).mockRejectedValueOnce(new Error('branch not found')) + + const result = await provider.deleteBranch('nonexistent-branch', false) + + expect(result).toEqual({ + success: true, + deleted: false, + notFound: true, + branchName: 'nonexistent-branch', + }) + }) + + it('should return success=false on deletion error', async () => { + // First call: branchExists check - branch exists + vi.mocked(execa).mockResolvedValueOnce({ + stdout: '{"name":"feat-issue-5","id":"branch-123"}', + stderr: '', + } as ExecaReturnValue) + // Second call: delete branch fails + vi.mocked(execa).mockRejectedValueOnce(new Error('Supabase CLI error: deletion failed')) + + const result = await provider.deleteBranch('feat-issue-5', false) + + expect(result).toEqual({ + success: false, + deleted: false, + notFound: false, + error: 'Supabase CLI error: deletion failed', + branchName: 'feat-issue-5', + }) + }) + + it('should accept and ignore isPreview parameter', async () => { + // branchExists check - not found + vi.mocked(execa).mockRejectedValueOnce(new Error('branch not found')) + + // Should not throw when isPreview=true; just proceeds normally + const result = await provider.deleteBranch('feat-issue-5', true) + + expect(result.success).toBe(true) + expect(result.notFound).toBe(true) + }) + + it('should sanitize branch name before deletion', async () => { + // branchExists check via 'branches get' with sanitized name + vi.mocked(execa).mockResolvedValueOnce({ + stdout: '{"name":"feat-issue-5","id":"branch-123"}', + stderr: '', + } as ExecaReturnValue) + vi.mocked(execa).mockResolvedValueOnce({ + stdout: 'Branch deleted', + stderr: '', + } as ExecaReturnValue) + + const result = await provider.deleteBranch('feat/issue-5') + + // Should use sanitized name (hyphens instead of slashes) + expect(execa).toHaveBeenCalledWith( + 'supabase', + ['branches', 'delete', 'feat-issue-5', '--project-ref', 'test-project-ref'], + expect.any(Object) + ) + expect(result.branchName).toBe('feat-issue-5') + }) + }) + +}) diff --git a/tests/mocks/MockDatabaseProvider.ts b/tests/mocks/MockDatabaseProvider.ts index 98f844dc..6c5be15d 100644 --- a/tests/mocks/MockDatabaseProvider.ts +++ b/tests/mocks/MockDatabaseProvider.ts @@ -10,6 +10,8 @@ export function createMockDatabaseProvider( overrides?: Partial ): DatabaseProvider { return { + displayName: 'MockDB', + installHint: 'npm install -g mockdb-cli', isCliAvailable: vi.fn().mockResolvedValue(true), isAuthenticated: vi.fn().mockResolvedValue(true), isConfigured: vi.fn().mockReturnValue(true), @@ -24,8 +26,6 @@ export function createMockDatabaseProvider( branchExists: vi.fn().mockResolvedValue(false), listBranches: vi.fn().mockResolvedValue([]), getConnectionString: vi.fn().mockResolvedValue('postgresql://test-connection'), - findPreviewBranch: vi.fn().mockResolvedValue(null), - getBranchNameFromEndpoint: vi.fn().mockResolvedValue(null), ...overrides, } }