From 800e118eb3d35f71f6fce4f6a40121ca10a05719 Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Tue, 10 Mar 2026 17:47:41 -0400 Subject: [PATCH 1/8] feat(issue-887): refactor DatabaseProvider interface for multi-provider support - Remove findPreviewBranch and getBranchNameFromEndpoint from DatabaseProvider interface (Neon-specific) - Add displayName and installHint required string properties to DatabaseProvider interface - Update DatabaseManager to use provider.displayName and provider.installHint in all user-facing messages (both createBranchIfConfigured and deleteBranchIfConfigured) - Add displayName='Neon' and installHint='npm install -g neonctl' to NeonProvider - Update MockDatabaseProvider and DatabaseManager tests to match new interface --- src/lib/DatabaseManager.ts | 15 +++++++-------- src/lib/providers/NeonProvider.ts | 2 ++ src/types/index.ts | 8 +++++--- tests/lib/DatabaseManager.test.ts | 8 ++++---- tests/mocks/MockDatabaseProvider.ts | 4 ++-- 5 files changed, 20 insertions(+), 17 deletions(-) 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/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/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/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/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, } } From 70e004f063252bcbbfc13330c7940b427bf48e6b Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Tue, 10 Mar 2026 17:59:24 -0400 Subject: [PATCH 2/8] feat(issue-888): add SupabaseSettingsSchema and createDatabaseProviderFromSettings factory - Add SupabaseSettingsSchema to SettingsManager with projectRef, parentBranch, withData fields - Extend DatabaseProvidersSettingsSchema with optional supabase key - Create src/utils/database-helpers.ts with createDatabaseProviderFromSettings factory - Returns NeonProvider when neon is configured - Returns SupabaseProvider when supabase is configured - Throws if both are configured simultaneously - Returns unconfigured NeonProvider when neither is configured - Add SupabaseProvider placeholder stub (isConfigured=false, full impl in separate issue) - Add comprehensive tests for factory branching logic - Document databaseProviders.supabase config in docs/iloom-commands.md --- docs/iloom-commands.md | 54 ++++++++++++- src/lib/SettingsManager.ts | 26 ++++++ src/lib/providers/SupabaseProvider.ts | 73 +++++++++++++++++ src/utils/database-helpers.test.ts | 110 ++++++++++++++++++++++++++ src/utils/database-helpers.ts | 38 +++++++++ 5 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 src/lib/providers/SupabaseProvider.ts create mode 100644 src/utils/database-helpers.test.ts create mode 100644 src/utils/database-helpers.ts diff --git a/docs/iloom-commands.md b/docs/iloom-commands.md index f497ba78..d116b931 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,58 @@ 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.parentBranch` | string | (required) | Branch from which new database branches are created | +| `databaseProviders.supabase.withData` | boolean | `true` | Whether to include data when creating a new branch | + +**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", + "parentBranch": "main", + "withData": true + } + } +} +``` + +**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/lib/SettingsManager.ts b/src/lib/SettingsManager.ts index ad269faa..debdded8 100644 --- a/src/lib/SettingsManager.ts +++ b/src/lib/SettingsManager.ts @@ -405,6 +405,24 @@ 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) + .describe('Branch from which new database branches are created'), + 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 +431,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 +1010,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/SupabaseProvider.ts b/src/lib/providers/SupabaseProvider.ts new file mode 100644 index 00000000..0c50c87f --- /dev/null +++ b/src/lib/providers/SupabaseProvider.ts @@ -0,0 +1,73 @@ +import type { DatabaseDeletionResult, DatabaseProvider } from '../../types/index.js' +import { getLogger } from '../../utils/logger-context.js' + +export interface SupabaseConfig { + projectRef: string + parentBranch: string + withData?: boolean +} + +/** + * Supabase database provider implementation + * Provides database branching via the Supabase CLI + * + * NOTE: This is a placeholder. Full implementation tracked separately. + */ +export class SupabaseProvider implements DatabaseProvider { + readonly displayName = 'Supabase CLI' + readonly installHint = 'brew install supabase/tap/supabase' + + constructor(config: SupabaseConfig) { + getLogger().debug('SupabaseProvider initialized with config:', { + projectRef: config.projectRef, + parentBranch: config.parentBranch, + hasProjectRef: !!config.projectRef, + hasParentBranch: !!config.parentBranch, + }) + } + + isConfigured(): boolean { + // Returns false until the full implementation is available + return false + } + + sanitizeBranchName(branchName: string): string { + return branchName.replace(/[^a-zA-Z0-9-]/g, '-') + } + + async isCliAvailable(): Promise { + throw new Error('SupabaseProvider: not yet implemented') + } + + async isAuthenticated(_cwd?: string): Promise { + throw new Error('SupabaseProvider: not yet implemented') + } + + async listBranches(_cwd?: string): Promise { + throw new Error('SupabaseProvider: not yet implemented') + } + + async branchExists(_name: string, _cwd?: string): Promise { + throw new Error('SupabaseProvider: not yet implemented') + } + + async getConnectionString(_branch: string, _cwd?: string): Promise { + throw new Error('SupabaseProvider: not yet implemented') + } + + async createBranch(_name: string, _fromBranch?: string, _cwd?: string): Promise { + throw new Error('SupabaseProvider: not yet implemented') + } + + async deleteBranch(_name: string, _isPreview?: boolean, _cwd?: string): Promise { + throw new Error('SupabaseProvider: not yet implemented') + } + + async findPreviewBranch(_branchName: string, _cwd?: string): Promise { + throw new Error('SupabaseProvider: not yet implemented') + } + + async getBranchNameFromEndpoint(_endpointId: string, _cwd?: string): Promise { + throw new Error('SupabaseProvider: not yet implemented') + } +} 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..273d7026 --- /dev/null +++ b/src/utils/database-helpers.ts @@ -0,0 +1,38 @@ +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' +export { createNeonProviderFromSettings } from './neon-helpers.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, + parentBranch: supabaseConfig.parentBranch, + ...(supabaseConfig.withData !== undefined && { withData: supabaseConfig.withData }), + }) + } + + return new NeonProvider({ + projectId: neonConfig?.projectId ?? '', + parentBranch: neonConfig?.parentBranch ?? '', + }) +} From 9fde8930860101b3fccbdc2bf1de73ce40622361 Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Tue, 10 Mar 2026 18:07:24 -0400 Subject: [PATCH 3/8] feat(issue-889): implement SupabaseProvider with database branching support Adds SupabaseProvider class implementing DatabaseProvider interface using supabase branches CLI commands. Includes: - Branch creation via supabase branches create with optional --with-data - Branch deletion with existence check before attempting removal - Connection string retrieval parsing POSTGRES_URL_NON_POOLING from env output - branchExists with precise error classification: only not-found errors return false, auth/network errors are rethrown (no silent swallowing) - isCliAvailable using ENOENT-based binary detection via supabase --version - isAuthenticated via supabase projects list with auth error pattern matching - sanitizeBranchName replacing slashes with hyphens (Supabase convention) - Graceful degradation: isConfigured returns false on invalid config without throwing - 48 comprehensive unit tests covering all methods and edge cases Supabase branches always fork from production; the fromBranch parameter is accepted for interface compatibility but ignored. The --branch-name flag does not exist in the Supabase CLI and has been omitted from branch creation. --- src/lib/providers/SupabaseProvider.ts | 445 +++++++++++-- tests/lib/providers/SupabaseProvider.test.ts | 655 +++++++++++++++++++ 2 files changed, 1038 insertions(+), 62 deletions(-) create mode 100644 tests/lib/providers/SupabaseProvider.test.ts diff --git a/src/lib/providers/SupabaseProvider.ts b/src/lib/providers/SupabaseProvider.ts index 0c50c87f..4deef002 100644 --- a/src/lib/providers/SupabaseProvider.ts +++ b/src/lib/providers/SupabaseProvider.ts @@ -1,73 +1,394 @@ -import type { DatabaseDeletionResult, DatabaseProvider } from '../../types/index.js' +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 + 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', + } + } + + if (!config.parentBranch) { + return { + valid: false, + error: + 'Supabase parentBranch is required. Configure in .iloom/settings.json under databaseProviders.supabase', + } + } + + // 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 - * - * NOTE: This is a placeholder. Full implementation tracked separately. */ export class SupabaseProvider implements DatabaseProvider { - readonly displayName = 'Supabase CLI' - readonly installHint = 'brew install supabase/tap/supabase' - - constructor(config: SupabaseConfig) { - getLogger().debug('SupabaseProvider initialized with config:', { - projectRef: config.projectRef, - parentBranch: config.parentBranch, - hasProjectRef: !!config.projectRef, - hasParentBranch: !!config.parentBranch, - }) - } - - isConfigured(): boolean { - // Returns false until the full implementation is available - return false - } - - sanitizeBranchName(branchName: string): string { - return branchName.replace(/[^a-zA-Z0-9-]/g, '-') - } - - async isCliAvailable(): Promise { - throw new Error('SupabaseProvider: not yet implemented') - } - - async isAuthenticated(_cwd?: string): Promise { - throw new Error('SupabaseProvider: not yet implemented') - } - - async listBranches(_cwd?: string): Promise { - throw new Error('SupabaseProvider: not yet implemented') - } - - async branchExists(_name: string, _cwd?: string): Promise { - throw new Error('SupabaseProvider: not yet implemented') - } - - async getConnectionString(_branch: string, _cwd?: string): Promise { - throw new Error('SupabaseProvider: not yet implemented') - } - - async createBranch(_name: string, _fromBranch?: string, _cwd?: string): Promise { - throw new Error('SupabaseProvider: not yet implemented') - } - - async deleteBranch(_name: string, _isPreview?: boolean, _cwd?: string): Promise { - throw new Error('SupabaseProvider: not yet implemented') - } - - async findPreviewBranch(_branchName: string, _cwd?: string): Promise { - throw new Error('SupabaseProvider: not yet implemented') - } - - async getBranchNameFromEndpoint(_endpointId: string, _cwd?: string): Promise { - throw new Error('SupabaseProvider: not yet implemented') - } + 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 + } + } + + /** + * 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): 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: 30000, + 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) { + // ENOENT means the binary was not found on the system + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + 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 { + return branchName.replace(/\//g, '-') + } + + /** + * 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 + } + const branches: SupabaseBranch[] = JSON.parse(output) + 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 { + try { + await this.executeSupabaseCommand( + ['branches', 'get', name, '--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 + 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') || + execaError.exitCode === 1 + + 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 output = await this.executeSupabaseCommand( + ['branches', 'get', branch, '--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) + + 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, + } + } + } + + /** + * Stub: findPreviewBranch is a Neon-specific concept (Vercel preview databases) + * Not applicable to Supabase - always returns null + * + * @param branchName - Branch name (unused) + * @param cwd - Working directory (unused) + */ + async findPreviewBranch(_branchName: string, _cwd?: string): Promise { + return null + } + + /** + * Stub: getBranchNameFromEndpoint is a Neon-specific concept + * Not applicable to Supabase - always returns null + * + * @param endpointId - Endpoint ID (unused) + * @param cwd - Working directory (unused) + */ + async getBranchNameFromEndpoint(_endpointId: string, _cwd?: string): Promise { + return null + } } diff --git a/tests/lib/providers/SupabaseProvider.test.ts b/tests/lib/providers/SupabaseProvider.test.ts new file mode 100644 index 00000000..125f64fe --- /dev/null +++ b/tests/lib/providers/SupabaseProvider.test.ts @@ -0,0 +1,655 @@ +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 invalid when parentBranch is missing', () => { + const result = validateSupabaseConfig({ projectRef: 'test-ref' }) + expect(result.valid).toBe(false) + expect(result.error).toContain('parentBranch is required') + }) + + 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 false when parentBranch is missing', () => { + const unconfiguredProvider = new SupabaseProvider({ + projectRef: 'test-ref', + parentBranch: '', + }) + expect(unconfiguredProvider.isConfigured()).toBe(false) + }) + + 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 true when CLI is present but version flag fails for other reasons', async () => { + // Non-ENOENT 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 handle empty string', () => { + const result = provider.sanitizeBranchName('') + + expect(result).toBe('') + }) + }) + + 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 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 return false when branch does not exist (exit code 1)', async () => { + const notFoundError = Object.assign(new Error('command failed'), { + stderr: '', + exitCode: 1, + }) + vi.mocked(execa).mockRejectedValue(notFoundError) + + const result = await provider.branchExists('nonexistent-branch') + + expect(result).toBe(false) + }) + + 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') + }) + }) + + describe('findPreviewBranch', () => { + it('should always return null (Neon-specific concept not applicable to Supabase)', async () => { + const result = await provider.findPreviewBranch('feat-issue-5') + + expect(result).toBeNull() + // Should not make any CLI calls + expect(execa).not.toHaveBeenCalled() + }) + }) + + describe('getBranchNameFromEndpoint', () => { + it('should always return null (Neon-specific concept not applicable to Supabase)', async () => { + const result = await provider.getBranchNameFromEndpoint('ep-some-endpoint') + + expect(result).toBeNull() + // Should not make any CLI calls + expect(execa).not.toHaveBeenCalled() + }) + }) +}) From 49e2c96eef100ace73e5d81732d20dcb613838b8 Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Tue, 10 Mar 2026 18:23:44 -0400 Subject: [PATCH 4/8] feat(issue-891): wire generic database provider factory into commands and templates - Replace createNeonProviderFromSettings with createDatabaseProviderFromSettings in start.ts, finish.ts, cleanup.ts, and cli.ts - Remove stale re-export of createNeonProviderFromSettings from database-helpers.ts - Delete orphaned src/utils/neon-helpers.ts - Rename CLI debug command test-neon to test-db using provider-agnostic interface - Update regular-prompt.txt and issue-prompt.txt to use "database branching" language - Add clarifying comment to SupabaseProvider.branchExists() about exitCode=1 convention --- src/cli.ts | 58 ++++++++++++++------------- src/commands/cleanup.ts | 6 +-- src/commands/finish.ts | 6 +-- src/commands/start.ts | 6 +-- src/lib/providers/SupabaseProvider.ts | 1 + src/utils/database-helpers.ts | 1 - src/utils/neon-helpers.ts | 15 ------- templates/prompts/issue-prompt.txt | 2 +- templates/prompts/regular-prompt.txt | 2 +- 9 files changed, 42 insertions(+), 55 deletions(-) delete mode 100644 src/utils/neon-helpers.ts 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/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/providers/SupabaseProvider.ts b/src/lib/providers/SupabaseProvider.ts index 4deef002..8a6ffcb1 100644 --- a/src/lib/providers/SupabaseProvider.ts +++ b/src/lib/providers/SupabaseProvider.ts @@ -223,6 +223,7 @@ export class SupabaseProvider implements DatabaseProvider { 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') || diff --git a/src/utils/database-helpers.ts b/src/utils/database-helpers.ts index 273d7026..bd2ae278 100644 --- a/src/utils/database-helpers.ts +++ b/src/utils/database-helpers.ts @@ -2,7 +2,6 @@ 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' -export { createNeonProviderFromSettings } from './neon-helpers.js' /** * Create the appropriate database provider from iloom settings. 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) From 7b378a23f20605fd8e7fea275b68d22a31378b46 Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Tue, 10 Mar 2026 18:24:20 -0400 Subject: [PATCH 5/8] feat(issue-892): add has_supabase telemetry tracking and Supabase docs - Add `has_supabase: boolean` to `SessionStartedProperties` telemetry interface - Wire Supabase detection in ignite command alongside existing has_neon tracking - Add test coverage for Supabase detection in session.started telemetry - Expand Supabase documentation with prerequisites, paid plan requirement, and withData option explanation --- docs/iloom-commands.md | 9 +++++ src/commands/ignite.test.ts | 75 +++++++++++++++++++++++++++++++++++++ src/commands/ignite.ts | 2 + src/types/telemetry.ts | 1 + 4 files changed, 87 insertions(+) diff --git a/docs/iloom-commands.md b/docs/iloom-commands.md index d116b931..cd2fafe2 100644 --- a/docs/iloom-commands.md +++ b/docs/iloom-commands.md @@ -2203,6 +2203,15 @@ iloom supports database branching to create isolated database copies per workspa | `databaseProviders.supabase.parentBranch` | string | (required) | Branch from which new database branches are created | | `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`: 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/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 } From 377ed8ce30794809ea6256b1e6af85cc51815123 Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Tue, 10 Mar 2026 18:31:32 -0400 Subject: [PATCH 6/8] feat(epic-272): add Supabase database branching as alternative to Neon Adds Supabase as a database branching provider, enabling per-loom database isolation via supabase branches CLI. Refactors DatabaseProvider interface to be provider-agnostic, creates a generic factory, wires all commands through it, adds telemetry and documentation. Fixes #272 From 9210bea2629650c14bf9bc3282ca7e9b2c91cd75 Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Wed, 11 Mar 2026 00:23:50 -0400 Subject: [PATCH 7/8] fix: address code review findings in SupabaseProvider --- src/lib/providers/SupabaseProvider.ts | 56 +++++++------ tests/lib/providers/SupabaseProvider.test.ts | 85 +++++++++++++------- 2 files changed, 87 insertions(+), 54 deletions(-) diff --git a/src/lib/providers/SupabaseProvider.ts b/src/lib/providers/SupabaseProvider.ts index 8a6ffcb1..f3fdc0f2 100644 --- a/src/lib/providers/SupabaseProvider.ts +++ b/src/lib/providers/SupabaseProvider.ts @@ -72,6 +72,11 @@ export class SupabaseProvider implements DatabaseProvider { } else { this._isConfigured = true } + + // parentBranch is stored for future use but Supabase currently always branches from the default (production) branch + getLogger().debug( + `parentBranch '${config.parentBranch}' is stored but Supabase currently always branches from the default branch` + ) } /** @@ -125,8 +130,10 @@ export class SupabaseProvider implements DatabaseProvider { }) return true } catch (error) { + const errorCode = (error as NodeJS.ErrnoException).code // ENOENT means the binary was not found on the system - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + // 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 @@ -180,7 +187,11 @@ export class SupabaseProvider implements DatabaseProvider { * Supabase uses hyphens as separator (not underscores like Neon) */ sanitizeBranchName(branchName: string): string { - return branchName.replace(/\//g, '-') + 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' } /** @@ -198,7 +209,22 @@ export class SupabaseProvider implements DatabaseProvider { name: string [key: string]: unknown } - const branches: SupabaseBranch[] = JSON.parse(output) + + 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) } @@ -230,8 +256,7 @@ export class SupabaseProvider implements DatabaseProvider { stderr.includes('no branch') || stdout.includes('not found') || message.includes('not found') || - message.includes('does not exist') || - execaError.exitCode === 1 + message.includes('does not exist') if (isNotFound) { return false @@ -371,25 +396,4 @@ export class SupabaseProvider implements DatabaseProvider { } } - /** - * Stub: findPreviewBranch is a Neon-specific concept (Vercel preview databases) - * Not applicable to Supabase - always returns null - * - * @param branchName - Branch name (unused) - * @param cwd - Working directory (unused) - */ - async findPreviewBranch(_branchName: string, _cwd?: string): Promise { - return null - } - - /** - * Stub: getBranchNameFromEndpoint is a Neon-specific concept - * Not applicable to Supabase - always returns null - * - * @param endpointId - Endpoint ID (unused) - * @param cwd - Working directory (unused) - */ - async getBranchNameFromEndpoint(_endpointId: string, _cwd?: string): Promise { - return null - } } diff --git a/tests/lib/providers/SupabaseProvider.test.ts b/tests/lib/providers/SupabaseProvider.test.ts index 125f64fe..f320dce5 100644 --- a/tests/lib/providers/SupabaseProvider.test.ts +++ b/tests/lib/providers/SupabaseProvider.test.ts @@ -105,8 +105,19 @@ describe('SupabaseProvider', () => { 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 errors mean CLI is present but something else is wrong + // 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, @@ -218,10 +229,28 @@ describe('SupabaseProvider', () => { expect(result).toBe('issue-25') }) - it('should handle empty string', () => { + it('should return unnamed-branch for empty string', () => { const result = provider.sanitizeBranchName('') - expect(result).toBe('') + 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') }) }) @@ -258,6 +287,27 @@ describe('SupabaseProvider', () => { 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', @@ -306,16 +356,14 @@ describe('SupabaseProvider', () => { expect(result).toBe(false) }) - it('should return false when branch does not exist (exit code 1)', async () => { - const notFoundError = Object.assign(new Error('command failed'), { + 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(notFoundError) + vi.mocked(execa).mockRejectedValue(ambiguousError) - const result = await provider.branchExists('nonexistent-branch') - - expect(result).toBe(false) + await expect(provider.branchExists('nonexistent-branch')).rejects.toThrow('command failed') }) it('should rethrow auth errors instead of returning false', async () => { @@ -633,23 +681,4 @@ describe('SupabaseProvider', () => { }) }) - describe('findPreviewBranch', () => { - it('should always return null (Neon-specific concept not applicable to Supabase)', async () => { - const result = await provider.findPreviewBranch('feat-issue-5') - - expect(result).toBeNull() - // Should not make any CLI calls - expect(execa).not.toHaveBeenCalled() - }) - }) - - describe('getBranchNameFromEndpoint', () => { - it('should always return null (Neon-specific concept not applicable to Supabase)', async () => { - const result = await provider.getBranchNameFromEndpoint('ep-some-endpoint') - - expect(result).toBeNull() - // Should not make any CLI calls - expect(execa).not.toHaveBeenCalled() - }) - }) }) From aaab829d7e7349dcf47999acd0e7ed44846b8a67 Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Wed, 11 Mar 2026 00:35:22 -0400 Subject: [PATCH 8/8] fix: make parentBranch optional for Supabase, update README and docs Supabase always branches from the default branch so parentBranch has no effect. Made it optional in schema, config interface, and validation. Updated README with Supabase support mentions and example config. Removed parentBranch from Supabase docs example. --- README.md | 19 ++++++++++-- docs/iloom-commands.md | 5 +--- src/lib/SettingsManager.ts | 3 +- src/lib/providers/SupabaseProvider.ts | 31 +++++++++----------- src/utils/database-helpers.ts | 2 +- tests/lib/providers/SupabaseProvider.test.ts | 10 +++---- 6 files changed, 39 insertions(+), 31 deletions(-) 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 cd2fafe2..5ffb8541 100644 --- a/docs/iloom-commands.md +++ b/docs/iloom-commands.md @@ -2200,7 +2200,6 @@ iloom supports database branching to create isolated database copies per workspa | Setting | Type | Default | Description | |---------|------|---------|-------------| | `databaseProviders.supabase.projectRef` | string | (required) | Supabase project reference ID (e.g., `"abcdefghijklmnop"`) | -| `databaseProviders.supabase.parentBranch` | string | (required) | Branch from which new database branches are created | | `databaseProviders.supabase.withData` | boolean | `true` | Whether to include data when creating a new branch | **Prerequisites (Supabase):** @@ -2233,9 +2232,7 @@ iloom supports database branching to create isolated database copies per workspa { "databaseProviders": { "supabase": { - "projectRef": "abcdefghijklmnop", - "parentBranch": "main", - "withData": true + "projectRef": "abcdefghijklmnop" } } } diff --git a/src/lib/SettingsManager.ts b/src/lib/SettingsManager.ts index debdded8..cefae6f2 100644 --- a/src/lib/SettingsManager.ts +++ b/src/lib/SettingsManager.ts @@ -416,7 +416,8 @@ export const SupabaseSettingsSchema = z.object({ parentBranch: z .string() .min(1) - .describe('Branch from which new database branches are created'), + .optional() + .describe('Reserved for future use. Supabase currently always branches from the default branch.'), withData: z .boolean() .optional() diff --git a/src/lib/providers/SupabaseProvider.ts b/src/lib/providers/SupabaseProvider.ts index f3fdc0f2..e1f57c79 100644 --- a/src/lib/providers/SupabaseProvider.ts +++ b/src/lib/providers/SupabaseProvider.ts @@ -4,7 +4,7 @@ import { getLogger } from '../../utils/logger-context.js' export interface SupabaseConfig { projectRef: string - parentBranch: string + parentBranch?: string withData?: boolean // default: true } @@ -24,13 +24,7 @@ export function validateSupabaseConfig(config: { } } - if (!config.parentBranch) { - return { - valid: false, - error: - 'Supabase parentBranch 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)) { @@ -73,10 +67,11 @@ export class SupabaseProvider implements DatabaseProvider { this._isConfigured = true } - // parentBranch is stored for future use but Supabase currently always branches from the default (production) branch - getLogger().debug( - `parentBranch '${config.parentBranch}' is stored but Supabase currently always branches from the default branch` - ) + if (config.parentBranch) { + getLogger().debug( + `parentBranch '${config.parentBranch}' is stored but Supabase currently always branches from the default branch` + ) + } } /** @@ -94,7 +89,7 @@ export class SupabaseProvider implements DatabaseProvider { * @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): Promise { + private async executeSupabaseCommand(args: string[], cwd?: string, timeout: number = 30000): Promise { // Check if provider is properly configured if (!this._isConfigured) { throw new Error( @@ -111,7 +106,7 @@ export class SupabaseProvider implements DatabaseProvider { } const result = await execa('supabase', args, { - timeout: 30000, + timeout, encoding: 'utf8', stdio: 'pipe', ...(cwd && { cwd }), @@ -236,9 +231,10 @@ export class SupabaseProvider implements DatabaseProvider { * @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', name, '--project-ref', this.config.projectRef], + ['branches', 'get', sanitizedName, '--project-ref', this.config.projectRef], cwd ) return true @@ -276,8 +272,9 @@ export class SupabaseProvider implements DatabaseProvider { * @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', branch, '--project-ref', this.config.projectRef, '-o', 'env'], + ['branches', 'get', sanitizedBranch, '--project-ref', this.config.projectRef, '-o', 'env'], cwd ) @@ -327,7 +324,7 @@ export class SupabaseProvider implements DatabaseProvider { args.push('--with-data') } - await this.executeSupabaseCommand(args, cwd) + await this.executeSupabaseCommand(args, cwd, 300000) getLogger().success('Database branch created successfully') diff --git a/src/utils/database-helpers.ts b/src/utils/database-helpers.ts index bd2ae278..94861d0e 100644 --- a/src/utils/database-helpers.ts +++ b/src/utils/database-helpers.ts @@ -25,7 +25,7 @@ export function createDatabaseProviderFromSettings(settings: IloomSettings): Dat if (supabaseConfig) { return new SupabaseProvider({ projectRef: supabaseConfig.projectRef, - parentBranch: supabaseConfig.parentBranch, + ...(supabaseConfig.parentBranch && { parentBranch: supabaseConfig.parentBranch }), ...(supabaseConfig.withData !== undefined && { withData: supabaseConfig.withData }), }) } diff --git a/tests/lib/providers/SupabaseProvider.test.ts b/tests/lib/providers/SupabaseProvider.test.ts index f320dce5..f77023d8 100644 --- a/tests/lib/providers/SupabaseProvider.test.ts +++ b/tests/lib/providers/SupabaseProvider.test.ts @@ -32,10 +32,9 @@ describe('SupabaseProvider', () => { expect(result.error).toContain('projectRef is required') }) - it('should return invalid when parentBranch is missing', () => { + it('should return valid when parentBranch is omitted (optional for Supabase)', () => { const result = validateSupabaseConfig({ projectRef: 'test-ref' }) - expect(result.valid).toBe(false) - expect(result.error).toContain('parentBranch is required') + expect(result.valid).toBe(true) }) it('should return invalid when projectRef contains invalid characters', () => { @@ -61,12 +60,11 @@ describe('SupabaseProvider', () => { expect(unconfiguredProvider.isConfigured()).toBe(false) }) - it('should return false when parentBranch is missing', () => { + it('should return true when parentBranch is omitted (optional for Supabase)', () => { const unconfiguredProvider = new SupabaseProvider({ projectRef: 'test-ref', - parentBranch: '', }) - expect(unconfiguredProvider.isConfigured()).toBe(false) + expect(unconfiguredProvider.isConfigured()).toBe(true) }) it('should not throw when config is invalid (graceful degradation)', () => {