diff --git a/CHANGELOG.md b/CHANGELOG.md index 14f44dab5d..ea32a7055a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This is the log of notable changes to EAS CLI and related packages. - [eas-cli] Add `eas submit:list`, `eas submit:view`, `eas submit:retry`, `eas submit:cancel`, and `eas submit:status` commands; list and view include the runtime version and fingerprint of the submitted build, and status shows the live App Store version and TestFlight build states cross-referenced with EAS submissions (Google Play app status is not available through EAS yet). ([#4134](https://github.com/expo/eas-cli/pull/4134) by [@brentvatne](https://github.com/brentvatne)) - [eas-cli] Add Supabase integration foundation: GraphQL client, shared integration helpers, and provisioning utilities. ([#4130](https://github.com/expo/eas-cli/pull/4130) by [@gwdp](https://github.com/gwdp)) +- [eas-cli] Add `eas integrations:supabase:connect` command. ([#4106](https://github.com/expo/eas-cli/pull/4106) by [@gwdp](https://github.com/gwdp)) - [eas-build-job] Add optional `ssh` field on build/job payloads. ([#4083](https://github.com/expo/eas-cli/pull/4083) by [@gwdp](https://github.com/gwdp)) - [eas-build-job] Add an `SSH_SESSION` build phase for upcoming worker SSH support. ([#4029](https://github.com/expo/eas-cli/pull/4029) by [@gwdp](https://github.com/gwdp)) - [build-tools] Add `ref` input to the `eas/checkout` step to check out a different git ref (branch, tag, or commit SHA) than the one that triggered the job. ([#4035](https://github.com/expo/eas-cli/pull/4035) by [@sswrk](https://github.com/sswrk)) diff --git a/packages/eas-cli/src/commands/integrations/supabase/__tests__/connect.test.ts b/packages/eas-cli/src/commands/integrations/supabase/__tests__/connect.test.ts new file mode 100644 index 0000000000..baa1d3a621 --- /dev/null +++ b/packages/eas-cli/src/commands/integrations/supabase/__tests__/connect.test.ts @@ -0,0 +1,961 @@ +import spawnAsync from '@expo/spawn-async'; +import openBrowserAsync from 'better-opn'; +import * as fs from 'fs-extra'; + +import { getMockOclifConfig } from '../../../../__tests__/commands/utils'; +import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient'; +import * as supabaseCommandUtils from '../../../../commandUtils/supabase'; +import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants'; +import { + EnvironmentVariableScope, + EnvironmentVariableVisibility, + Role, +} from '../../../../graphql/generated'; +import { EnvironmentVariableMutation } from '../../../../graphql/mutations/EnvironmentVariableMutation'; +import { SupabaseMutation } from '../../../../graphql/mutations/SupabaseMutation'; +import { EnvironmentVariablesQuery } from '../../../../graphql/queries/EnvironmentVariablesQuery'; +import { SupabaseQuery } from '../../../../graphql/queries/SupabaseQuery'; +import { + SupabaseConnectionData, + SupabaseOrganizationData, + SupabaseProjectData, +} from '../../../../graphql/types/SupabaseConnection'; +import Log from '../../../../log'; +import { createOrModifyExpoConfigAsync } from '../../../../project/expoConfig'; +import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils'; +import { confirmAsync, promptAsync, selectAsync } from '../../../../prompts'; +import { printJsonOnlyOutput } from '../../../../utils/json'; +import { + BackgroundJobReceiptPollError, + BackgroundJobReceiptPollErrorType, + pollForBackgroundJobReceiptAsync, +} from '../../../../utils/pollForBackgroundJobReceiptAsync'; +import IntegrationsSupabaseConnect from '../connect'; + +jest.mock('@expo/spawn-async'); +jest.mock('better-opn'); +jest.mock('fs-extra'); +jest.mock('../../../../project/expoConfig'); +jest.mock('../../../../graphql/queries/SupabaseQuery'); +jest.mock('../../../../graphql/queries/EnvironmentVariablesQuery'); +jest.mock('../../../../graphql/mutations/SupabaseMutation'); +jest.mock('../../../../graphql/mutations/EnvironmentVariableMutation'); +jest.mock('../../../../project/projectUtils'); +jest.mock('../../../../prompts'); +jest.mock('../../../../log'); +jest.mock('../../../../utils/json'); +jest.mock('../../../../utils/pollForBackgroundJobReceiptAsync', () => ({ + ...jest.requireActual('../../../../utils/pollForBackgroundJobReceiptAsync'), + pollForBackgroundJobReceiptAsync: jest.fn(), +})); +jest.mock('../../../../ora', () => ({ + ora: () => ({ + start: jest.fn().mockReturnThis(), + succeed: jest.fn().mockReturnThis(), + warn: jest.fn().mockReturnThis(), + fail: jest.fn().mockReturnThis(), + stop: jest.fn().mockReturnThis(), + text: '', + }), +})); + +describe(IntegrationsSupabaseConnect, () => { + const graphqlClient = {} as ExpoGraphqlClient; + const mockConfig = getMockOclifConfig(); + const testAccountId = 'test-account-id'; + const testAccountName = 'testuser'; + + const mockAccount = { + id: testAccountId, + name: testAccountName, + ownerUserActor: { id: 'test-user-id', username: testAccountName }, + users: [{ role: Role.Owner, actor: { id: 'test-user-id' } }], + }; + + const mockConnection: SupabaseConnectionData = { + id: 'connection-1', + supabaseOrganizationSlug: 'org-slug', + supabaseOrganizationName: 'Primary Org', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + const mockOrganizations: SupabaseOrganizationData[] = [ + { id: 'org-1', slug: 'org-slug', name: 'Primary Org' }, + { id: 'org-2', slug: 'other-org', name: 'Other Org' }, + ]; + + const mockProject: SupabaseProjectData = { + id: 'project-1', + supabaseProjectRef: 'abcdefghijklmnop', + supabaseProjectName: 'Test App', + supabaseProjectUrl: 'https://abcdefghijklmnop.supabase.co', + supabaseRegion: 'us-east-1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + function createCommand(argv: string[]): IntegrationsSupabaseConnect { + const command = new IntegrationsSupabaseConnect(argv, mockConfig); + jest.spyOn(command as any, 'getContextAsync').mockReturnValue({ + privateProjectConfig: { + projectId: testProjectId, + exp: { name: 'testapp', slug: 'testapp', plugins: [] }, + projectDir: '/test/project', + }, + loggedIn: { graphqlClient }, + } as never); + return command; + } + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(Log, 'log').mockImplementation(() => {}); + jest.spyOn(Log, 'warn').mockImplementation(() => {}); + jest.spyOn(Log, 'error').mockImplementation(() => {}); + jest.spyOn(Log, 'withTick').mockImplementation(() => {}); + jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {}); + jest.spyOn(Log, 'newLine').mockImplementation(() => {}); + jest.spyOn(Log, 'debug').mockImplementation(() => {}); + + jest.mocked(getOwnerAccountForProjectIdAsync).mockResolvedValue(mockAccount as any); + jest.mocked(selectAsync).mockResolvedValue('americas'); + jest.mocked(confirmAsync).mockResolvedValue(true); + jest.mocked(promptAsync).mockResolvedValue({ linkValue: mockProject.supabaseProjectRef }); + jest.mocked(spawnAsync).mockResolvedValue({} as any); + jest.mocked(fs.pathExists).mockResolvedValue(false as never); + jest.mocked(fs.writeFile).mockResolvedValue(undefined as never); + jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({ type: 'success' } as any); + jest.mocked(openBrowserAsync).mockResolvedValue(true as never); + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([]); + jest + .mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync) + .mockResolvedValue(['production', 'preview', 'development']); + jest.mocked(EnvironmentVariableMutation.createForAppAsync).mockResolvedValue({ + id: 'env-var-1', + scope: EnvironmentVariableScope.Project, + } as any); + jest + .mocked(EnvironmentVariableMutation.updateAsync) + .mockResolvedValue({ id: 'env-var-1' } as any); + jest.mocked(EnvironmentVariableMutation.deleteAsync).mockResolvedValue({ id: 'env-var-extra' }); + + jest + .mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync) + .mockResolvedValue(mockConnection); + jest + .mocked(SupabaseMutation.listSupabaseOrganizationsAsync) + .mockResolvedValue(mockOrganizations); + jest + .mocked(SupabaseMutation.fetchSupabasePublishableKeyAsync) + .mockResolvedValue('sb_publishable_test'); + jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(mockProject); + jest.mocked(SupabaseMutation.linkSupabaseProjectAsync).mockResolvedValue(mockProject); + jest.mocked(SupabaseMutation.provisionSupabaseProjectAsync).mockResolvedValue({ + id: 'receipt-1', + } as any); + jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({ + id: 'receipt-1', + resultData: { supabaseProjectRef: mockProject.supabaseProjectRef }, + } as any); + }); + + it('reuses an existing connection and project and writes public env vars', async () => { + await createCommand([]).runAsync(); + + expect(SupabaseMutation.beginSupabaseOAuthAsync).not.toHaveBeenCalled(); + expect(SupabaseMutation.provisionSupabaseProjectAsync).not.toHaveBeenCalled(); + expect(spawnAsync).toHaveBeenCalledWith( + 'npx', + expect.arrayContaining(['expo', 'install', '@supabase/supabase-js', 'expo-sqlite']), + expect.objectContaining({ + cwd: '/test/project', + }) + ); + expect(createOrModifyExpoConfigAsync).toHaveBeenCalled(); + const createdNames = jest + .mocked(EnvironmentVariableMutation.createForAppAsync) + .mock.calls.map(call => call[1].name); + expect(createdNames).toEqual([ + 'EXPO_PUBLIC_SUPABASE_URL', + 'EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY', + ]); + expect( + jest.mocked(EnvironmentVariableMutation.createForAppAsync).mock.calls[0][1].visibility + ).toBe(EnvironmentVariableVisibility.Public); + }); + + it('loads organizations once when formatting the existing connection label', async () => { + await createCommand([]).runAsync(); + + expect(SupabaseMutation.listSupabaseOrganizationsAsync).toHaveBeenCalledTimes(1); + }); + + it('formats the connection when organization listing fails', async () => { + jest + .mocked(SupabaseMutation.listSupabaseOrganizationsAsync) + .mockRejectedValue(new Error('skip')); + + await createCommand(['--overwrite']).runAsync(); + + expect(Log.withTick).toHaveBeenCalledWith(expect.stringContaining('Primary Org')); + }); + + it('strips EXPO_LOCAL / EXPO_STAGING / EXPO_UNIVERSE_DIR from the expo install env', async () => { + const previous = { + EXPO_LOCAL: process.env.EXPO_LOCAL, + EXPO_STAGING: process.env.EXPO_STAGING, + EXPO_UNIVERSE_DIR: process.env.EXPO_UNIVERSE_DIR, + }; + process.env.EXPO_LOCAL = '1'; + process.env.EXPO_STAGING = '1'; + process.env.EXPO_UNIVERSE_DIR = '/tmp/universe'; + try { + await createCommand([]).runAsync(); + const spawnOptions = jest.mocked(spawnAsync).mock.calls[0]?.[2] as { + env?: NodeJS.ProcessEnv; + }; + expect(spawnOptions.env?.EXPO_LOCAL).toBeUndefined(); + expect(spawnOptions.env?.EXPO_STAGING).toBeUndefined(); + expect(spawnOptions.env?.EXPO_UNIVERSE_DIR).toBeUndefined(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } + }); + + it('links an existing project and skips provisioning', async () => { + jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null); + jest.mocked(SupabaseMutation.linkSupabaseProjectAsync).mockResolvedValue(mockProject); + + await createCommand(['--link', mockProject.supabaseProjectRef]).runAsync(); + + expect(SupabaseMutation.linkSupabaseProjectAsync).toHaveBeenCalledWith(graphqlClient, { + appId: testProjectId, + supabaseProjectRef: mockProject.supabaseProjectRef, + }); + expect(SupabaseMutation.provisionSupabaseProjectAsync).not.toHaveBeenCalled(); + expect(SupabaseMutation.fetchSupabasePublishableKeyAsync).toHaveBeenCalled(); + expect(SupabaseQuery.getSupabaseProjectByAppIdAsync).toHaveBeenCalledTimes(1); + }); + + it('provisions via background receipt when no project is linked', async () => { + jest + .mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(mockProject); + + await createCommand(['--region', 'americas', '--organization', 'org-slug']).runAsync(); + + expect(SupabaseMutation.provisionSupabaseProjectAsync).toHaveBeenCalledWith(graphqlClient, { + appId: testProjectId, + region: 'americas', + }); + expect(pollForBackgroundJobReceiptAsync).toHaveBeenCalledWith( + graphqlClient, + expect.anything(), + expect.objectContaining({ + maxChecks: 420, + maxConsecutiveFetchErrors: 3, + }) + ); + }); + + it('surfaces a permanent provision failure with a --link hint', async () => { + jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null); + jest.mocked(pollForBackgroundJobReceiptAsync).mockRejectedValue( + new BackgroundJobReceiptPollError({ + errorType: BackgroundJobReceiptPollErrorType.JOB_FAILED_NO_WILL_RETRY, + receiptErrorMessage: 'name already taken', + }) + ); + + await expect( + createCommand(['--region', 'americas', '--organization', 'org-slug']).runAsync() + ).rejects.toThrow(/name already taken[\s\S]*--link/); + }); + + it('maps NULL_RECEIPT poll errors to a recoverable timeout hint', async () => { + jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null); + jest.mocked(pollForBackgroundJobReceiptAsync).mockRejectedValue( + new BackgroundJobReceiptPollError({ + errorType: BackgroundJobReceiptPollErrorType.NULL_RECEIPT, + }) + ); + + await expect( + createCommand(['--region', 'americas', '--organization', 'org-slug']).runAsync() + ).rejects.toThrow(/Timed out or lost contact[\s\S]*--link/); + }); + + it('switches organization when --organization differs from the connection', async () => { + jest + .mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(mockProject); + jest.mocked(SupabaseMutation.setSupabaseConnectionOrganizationAsync).mockResolvedValue({ + ...mockConnection, + supabaseOrganizationSlug: 'other-org', + supabaseOrganizationName: 'Other Org', + }); + + await createCommand(['--region', 'americas', '--organization', 'other-org']).runAsync(); + + expect(SupabaseMutation.setSupabaseConnectionOrganizationAsync).toHaveBeenCalledWith( + graphqlClient, + { + supabaseConnectionId: mockConnection.id, + organizationSlug: 'other-org', + } + ); + }); + + it('resets the connection with --reauth, then links an existing project by default', async () => { + jest + .mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync) + .mockResolvedValueOnce(mockConnection) + .mockResolvedValueOnce(null) + .mockResolvedValue(mockConnection); + // Cascade delete removes the EAS project link with the connection. + jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null); + jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({ + url: 'https://api.supabase.com/v1/oauth/authorize', + }); + jest.mocked(SupabaseMutation.disconnectSupabaseAsync).mockResolvedValue(mockConnection.id); + jest.mocked(selectAsync).mockResolvedValueOnce('link').mockResolvedValue('americas'); + jest.mocked(promptAsync).mockResolvedValue({ linkValue: mockProject.supabaseProjectRef }); + jest.mocked(SupabaseMutation.linkSupabaseProjectAsync).mockResolvedValue(mockProject); + + await createCommand(['--reauth']).runAsync(); + + expect(SupabaseMutation.disconnectSupabaseAsync).toHaveBeenCalledWith( + graphqlClient, + mockConnection.id + ); + expect(SupabaseMutation.beginSupabaseOAuthAsync).toHaveBeenCalled(); + expect(openBrowserAsync).toHaveBeenCalled(); + expect(selectAsync).toHaveBeenCalledWith( + expect.stringContaining('previous EAS project link was removed'), + expect.arrayContaining([ + expect.objectContaining({ value: 'link' }), + expect.objectContaining({ value: 'provision' }), + ]), + expect.objectContaining({ initial: 'link' }) + ); + const promptOpts = jest.mocked(promptAsync).mock.calls[0]?.[0] as { + validate?: (value: string) => true | string; + }; + expect(promptOpts.validate?.(mockProject.supabaseProjectRef)).toBe(true); + expect(promptOpts.validate?.('not a ref')).toEqual(expect.any(String)); + const parseSpy = jest + .spyOn(supabaseCommandUtils, 'parseSupabaseProjectRef') + .mockImplementationOnce(() => { + throw 'nope'; + }); + expect(promptOpts.validate?.('x')).toBe('Invalid project ref or URL'); + parseSpy.mockRestore(); + expect(SupabaseMutation.linkSupabaseProjectAsync).toHaveBeenCalledWith(graphqlClient, { + appId: testProjectId, + supabaseProjectRef: mockProject.supabaseProjectRef, + }); + expect(SupabaseMutation.provisionSupabaseProjectAsync).not.toHaveBeenCalled(); + }); + + it('provisions after --reauth when the user chooses provision', async () => { + jest + .mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync) + .mockResolvedValueOnce(mockConnection) + .mockResolvedValueOnce(null) + .mockResolvedValue(mockConnection); + jest + .mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync) + .mockResolvedValueOnce(null) + .mockResolvedValue(mockProject); + jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({ + url: 'https://api.supabase.com/v1/oauth/authorize', + }); + jest.mocked(SupabaseMutation.disconnectSupabaseAsync).mockResolvedValue(mockConnection.id); + jest + .mocked(selectAsync) + .mockResolvedValueOnce('provision') + .mockResolvedValueOnce('org-slug') + .mockResolvedValue('americas'); + + await createCommand(['--reauth']).runAsync(); + + expect(SupabaseMutation.provisionSupabaseProjectAsync).toHaveBeenCalled(); + expect(SupabaseMutation.linkSupabaseProjectAsync).not.toHaveBeenCalled(); + }); + + it('skips the post-reauth prompt when --link is passed', async () => { + jest + .mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync) + .mockResolvedValueOnce(mockConnection) + .mockResolvedValueOnce(null) + .mockResolvedValue(mockConnection); + jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null); + jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({ + url: 'https://api.supabase.com/v1/oauth/authorize', + }); + jest.mocked(SupabaseMutation.disconnectSupabaseAsync).mockResolvedValue(mockConnection.id); + jest.mocked(SupabaseMutation.linkSupabaseProjectAsync).mockResolvedValue(mockProject); + + await createCommand(['--reauth', '--link', mockProject.supabaseProjectRef]).runAsync(); + + expect(SupabaseMutation.linkSupabaseProjectAsync).toHaveBeenCalledWith(graphqlClient, { + appId: testProjectId, + supabaseProjectRef: mockProject.supabaseProjectRef, + }); + expect(SupabaseMutation.provisionSupabaseProjectAsync).not.toHaveBeenCalled(); + expect(selectAsync).not.toHaveBeenCalledWith( + expect.stringContaining('previous EAS project link was removed'), + expect.anything(), + expect.anything() + ); + }); + + it('rejects --reauth in non-interactive mode when a connection exists', async () => { + await expect(createCommand(['--reauth', '--non-interactive']).runAsync()).rejects.toThrow( + /non-interactive/ + ); + expect(SupabaseMutation.disconnectSupabaseAsync).not.toHaveBeenCalled(); + }); + + it('ignores --reauth when there is no connection', async () => { + jest + .mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync) + .mockResolvedValueOnce(null) + .mockResolvedValue(mockConnection); + jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({ + url: 'https://api.supabase.com/v1/oauth/authorize', + }); + + await createCommand(['--reauth']).runAsync(); + + expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('--reauth ignored')); + expect(SupabaseMutation.disconnectSupabaseAsync).not.toHaveBeenCalled(); + }); + + it('prints json output for an already-connected project', async () => { + await createCommand(['--json']).runAsync(); + + expect(printJsonOnlyOutput).toHaveBeenCalledWith( + expect.objectContaining({ + project: expect.objectContaining({ ref: mockProject.supabaseProjectRef }), + envLocalWritten: true, + manualSteps: [], + }) + ); + }); + + it('requires --region in non-interactive mode when provisioning', async () => { + jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null); + + await expect(createCommand(['--non-interactive']).runAsync()).rejects.toThrow(/--region/); + }); + + it('provisions an additional project for --environment preview', async () => { + jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({ + id: 'receipt-additional', + } as any); + jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({ + id: 'receipt-additional', + resultData: { + supabaseProjectRef: 'previewref123456', + supabaseProjectUrl: 'https://previewref123456.supabase.co', + supabaseRegion: 'us-east-1', + publishableKey: 'sb_publishable_preview', + }, + } as any); + + await createCommand([ + '--environment', + 'preview', + '--region', + 'americas', + '--overwrite', + ]).runAsync(); + + expect(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).toHaveBeenCalledWith( + graphqlClient, + expect.objectContaining({ + appId: testProjectId, + region: 'americas', + projectNameSuffix: 'preview', + }) + ); + expect(SupabaseMutation.provisionSupabaseProjectAsync).not.toHaveBeenCalled(); + expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith( + graphqlClient, + expect.objectContaining({ + name: 'EXPO_PUBLIC_SUPABASE_URL', + value: 'https://previewref123456.supabase.co', + environments: ['preview'], + }), + testProjectId + ); + expect(spawnAsync).not.toHaveBeenCalled(); + }); + + it('surfaces additional provision failures with --link guidance', async () => { + jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({ + id: 'receipt-additional', + } as any); + jest.mocked(pollForBackgroundJobReceiptAsync).mockRejectedValue( + new BackgroundJobReceiptPollError({ + errorType: BackgroundJobReceiptPollErrorType.JOB_FAILED_NO_WILL_RETRY, + receiptErrorMessage: 'maximum limits for the number of active free projects', + }) + ); + + await expect( + createCommand(['--environment', 'preview', '--region', 'americas', '--overwrite']).runAsync() + ).rejects.toThrow( + /maximum limits for the number of active free projects[\s\S]*--environment preview(?!.*--link)/ + ); + }); + + it('peels preview off the primary env var when adding --environment preview', async () => { + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ + { + id: 'primary-url', + name: 'EXPO_PUBLIC_SUPABASE_URL', + value: 'https://primary.supabase.co', + scope: EnvironmentVariableScope.Project, + environments: ['production', 'preview', 'development'], + }, + { + id: 'primary-key', + name: 'EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY', + value: 'sb_publishable_primary', + scope: EnvironmentVariableScope.Project, + environments: ['production', 'preview', 'development'], + }, + ] as any); + jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({ + id: 'receipt-additional', + } as any); + jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({ + id: 'receipt-additional', + resultData: { + supabaseProjectRef: 'previewref123456', + supabaseProjectUrl: 'https://previewref123456.supabase.co', + supabaseRegion: 'us-east-1', + publishableKey: 'sb_publishable_preview', + }, + } as any); + + await createCommand([ + '--environment', + 'preview', + '--region', + 'americas', + '--overwrite', + ]).runAsync(); + + expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith( + graphqlClient, + expect.objectContaining({ + id: 'primary-url', + environments: ['production', 'development'], + }) + ); + expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith( + graphqlClient, + expect.objectContaining({ + name: 'EXPO_PUBLIC_SUPABASE_URL', + value: 'https://previewref123456.supabase.co', + environments: ['preview'], + }), + testProjectId + ); + }); + + it('rejects empty --environment', async () => { + await expect(createCommand(['--environment', ' ']).runAsync()).rejects.toThrow( + /at least one EAS environment/ + ); + }); + + it('rejects unknown --environment in non-interactive mode', async () => { + await expect( + createCommand([ + '--environment', + 'prevew', + '--region', + 'americas', + '--non-interactive', + ]).runAsync() + ).rejects.toThrow(/EAS environment\(s\) not found/); + }); + + it('proceeds with unknown --environment after confirmation', async () => { + jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({ + id: 'receipt-additional', + } as any); + jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({ + id: 'receipt-additional', + resultData: { + supabaseProjectRef: 'stagingref123456', + supabaseProjectUrl: 'https://stagingref123456.supabase.co', + supabaseRegion: 'us-east-1', + publishableKey: 'sb_publishable_staging', + }, + } as any); + jest.mocked(confirmAsync).mockResolvedValue(true); + + await createCommand(['--environment', 'staging', '--region', 'americas']).runAsync(); + + expect(confirmAsync).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringMatching( + /EAS environment "staging" is not used on this project yet.*Continue provisioning\?/i + ), + }) + ); + expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith( + graphqlClient, + expect.objectContaining({ + name: 'EXPO_PUBLIC_SUPABASE_URL', + environments: ['staging'], + }), + testProjectId + ); + }); + + it('aborts when declining to create an unknown --environment', async () => { + jest.mocked(confirmAsync).mockResolvedValue(false); + + await expect( + createCommand(['--environment', 'staging', '--region', 'americas']).runAsync() + ).rejects.toThrow(/Canceled\. No additional Supabase project was provisioned/); + expect(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).not.toHaveBeenCalled(); + }); + + it('rejects --environment with --link', async () => { + await expect( + createCommand(['--environment', 'preview', '--link', 'abcdefghijklmnop']).runAsync() + ).rejects.toThrow(/Cannot combine --environment with --link/); + }); + + it('reconciles split env vars on a plain reconnect', async () => { + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ + { + id: 'primary-url', + name: 'EXPO_PUBLIC_SUPABASE_URL', + value: 'https://primary.supabase.co', + scope: EnvironmentVariableScope.Project, + environments: ['production', 'development'], + }, + { + id: 'preview-url', + name: 'EXPO_PUBLIC_SUPABASE_URL', + value: 'https://preview.supabase.co', + scope: EnvironmentVariableScope.Project, + environments: ['preview'], + }, + ] as any); + + await createCommand(['--overwrite']).runAsync(); + + expect(EnvironmentVariableMutation.deleteAsync).toHaveBeenCalledWith( + graphqlClient, + 'preview-url' + ); + expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith( + graphqlClient, + expect.objectContaining({ + id: 'primary-url', + value: mockProject.supabaseProjectUrl, + environments: ['production', 'preview', 'development'], + }) + ); + }); + + it('rejects --environment in non-interactive mode when env vars would be skipped', async () => { + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ + { + id: 'primary-url', + name: 'EXPO_PUBLIC_SUPABASE_URL', + value: 'https://primary.supabase.co', + scope: EnvironmentVariableScope.Project, + environments: ['production', 'preview', 'development'], + }, + ] as any); + + await expect( + createCommand([ + '--environment', + 'preview', + '--region', + 'americas', + '--non-interactive', + ]).runAsync() + ).rejects.toThrow(/--overwrite/); + expect(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).not.toHaveBeenCalled(); + }); + + it('rejects --environment without a primary project', async () => { + jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null); + + await expect( + createCommand(['--environment', 'preview', '--region', 'americas']).runAsync() + ).rejects.toThrow(/without --environment first/); + }); + + it('continues and still writes env vars when SDK installation fails', async () => { + jest.mocked(spawnAsync).mockRejectedValue(new Error('npm exploded')); + + await createCommand(['--overwrite']).runAsync(); + + expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled(); + expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('npx expo install')); + }); + + it('on a dynamic config, shows Expo install guidance and skips a second plugin rewrite', async () => { + const expoGuidance = [ + 'Cannot automatically write to dynamic config at: app.config.js', + 'Add the following to your Expo config', + '', + '{', + ' "plugins": [', + ' "expo-sqlite"', + ' ]', + '}', + ].join('\n'); + jest.mocked(spawnAsync).mockRejectedValue( + Object.assign(new Error('Process exited with non-zero code: 1'), { + stdout: `${expoGuidance}\n`, + stderr: '', + }) + ); + + await createCommand(['--overwrite']).runAsync(); + + expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled(); + expect(createOrModifyExpoConfigAsync).not.toHaveBeenCalled(); + expect(Log.warn).toHaveBeenCalledWith( + expect.stringContaining('Cannot automatically write to dynamic config at: app.config.js') + ); + expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('"expo-sqlite"')); + expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining("didn't install")); + }); + + it('rejects --environment with --reauth', async () => { + await expect( + createCommand(['--environment', 'preview', '--reauth']).runAsync() + ).rejects.toThrow(/Cannot combine --environment with --reauth/); + }); + + it('warns when --link differs from an already linked project', async () => { + await createCommand(['--link', 'differentref12345', '--overwrite']).runAsync(); + + expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('Ignoring --link')); + expect(SupabaseMutation.linkSupabaseProjectAsync).not.toHaveBeenCalled(); + }); + + it('warns when --region is passed for an already linked project', async () => { + await createCommand(['--region', 'emea', '--overwrite']).runAsync(); + + expect(Log.warn).toHaveBeenCalledWith( + expect.stringContaining('Ignoring --region/--organization') + ); + }); + + it('throws when primary provision succeeds without a project link', async () => { + jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null); + jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({ + id: 'receipt-1', + resultData: { supabaseProjectRef: 'newref1234567890' }, + } as any); + + await expect(createCommand(['--region', 'americas', '--overwrite']).runAsync()).rejects.toThrow( + /link with --link newref1234567890/ + ); + }); + + it('throws when primary provision succeeds without a project ref in the receipt', async () => { + jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null); + jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({ + id: 'receipt-1', + resultData: {}, + } as any); + + await expect(createCommand(['--region', 'americas', '--overwrite']).runAsync()).rejects.toThrow( + /Provision succeeded but the Expo project link was not found/ + ); + }); + + it('throws when primary provision receipt data is not an object', async () => { + jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null); + jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({ + id: 'receipt-1', + resultData: 'not-an-object', + } as any); + + await expect(createCommand(['--region', 'americas', '--overwrite']).runAsync()).rejects.toThrow( + /Provision succeeded but the Expo project link was not found/ + ); + }); + + it('rejects --environment without a connection', async () => { + jest.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync).mockResolvedValue(null); + + await expect( + createCommand(['--environment', 'preview', '--region', 'americas']).runAsync() + ).rejects.toThrow(/No Supabase connection found/); + }); + + it('rejects --organization with --environment', async () => { + await expect( + createCommand([ + '--environment', + 'preview', + '--region', + 'americas', + '--organization', + 'other-org', + '--overwrite', + ]).runAsync() + ).rejects.toThrow(/Cannot use --organization with --environment/); + }); + + it('throws when additional provision omits credentials', async () => { + jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({ + id: 'receipt-additional', + } as any); + jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({ + id: 'receipt-additional', + resultData: { supabaseProjectRef: 'previewref123456' }, + } as any); + + await expect( + createCommand(['--environment', 'preview', '--region', 'americas', '--overwrite']).runAsync() + ).rejects.toThrow( + /did not return project credentials[\s\S]*Do not re-run connect --environment/ + ); + }); + + it('throws when additional env writes partially skip', async () => { + jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({ + id: 'receipt-additional', + } as any); + jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({ + id: 'receipt-additional', + resultData: { + supabaseProjectRef: 'previewref123456', + supabaseProjectUrl: 'https://previewref123456.supabase.co', + supabaseRegion: 'us-east-1', + publishableKey: 'sb_publishable_preview', + }, + } as any); + jest + .mocked(EnvironmentVariablesQuery.byAppIdAsync) + .mockResolvedValueOnce([]) // ensure: URL + .mockResolvedValueOnce([]) // ensure: KEY + .mockResolvedValueOnce([]) // upsert URL: create + .mockResolvedValueOnce([ + { + id: 'existing-key', + name: 'EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY', + value: 'different', + scope: EnvironmentVariableScope.Project, + environments: ['preview'], + }, + ] as any); // upsert KEY: exact match + jest.mocked(confirmAsync).mockResolvedValue(false); + + await expect( + createCommand(['--environment', 'preview', '--region', 'americas']).runAsync() + ).rejects.toThrow( + /did not write all EAS environment variables[\s\S]*Do not re-run connect --environment[\s\S]*sb_publishable_preview/ + ); + }); + + it('prints json for additional environment setup', async () => { + jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({ + id: 'receipt-additional', + } as any); + jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({ + id: 'receipt-additional', + resultData: { + supabaseProjectRef: 'previewref123456', + supabaseProjectUrl: 'https://previewref123456.supabase.co', + publishableKey: 'sb_publishable_preview', + }, + } as any); + jest + .mocked(SupabaseMutation.listSupabaseOrganizationsAsync) + .mockRejectedValue(new Error('skip')); + + await createCommand([ + '--environment', + 'preview', + '--region', + 'americas', + '--overwrite', + '--json', + ]).runAsync(); + + expect(printJsonOnlyOutput).toHaveBeenCalledWith( + expect.objectContaining({ + additionalProject: expect.objectContaining({ + ref: 'previewref123456', + region: 'americas', + }), + environments: ['preview'], + }) + ); + }); + + it('wraps env write failures after additional provision', async () => { + jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({ + id: 'receipt-additional', + } as any); + jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({ + id: 'receipt-additional', + resultData: { + supabaseProjectRef: 'previewref123456', + supabaseProjectUrl: 'https://previewref123456.supabase.co', + supabaseRegion: 'us-east-1', + publishableKey: 'sb_publishable_preview', + }, + } as any); + jest + .mocked(EnvironmentVariableMutation.createForAppAsync) + .mockRejectedValue(new Error('graphql down')); + + await expect( + createCommand(['--environment', 'preview', '--region', 'americas', '--overwrite']).runAsync() + ).rejects.toThrow( + /could not write EAS environment variables.*graphql down[\s\S]*Do not re-run connect --environment[\s\S]*EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_preview/ + ); + }); + + it('wraps non-Error env write failures after additional provision', async () => { + jest.mocked(SupabaseMutation.provisionAdditionalSupabaseProjectAsync).mockResolvedValue({ + id: 'receipt-additional', + } as any); + jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue({ + id: 'receipt-additional', + resultData: { + supabaseProjectRef: 'previewref123456', + supabaseProjectUrl: 'https://previewref123456.supabase.co', + supabaseRegion: 'us-east-1', + publishableKey: 'sb_publishable_preview', + }, + } as any); + jest.mocked(EnvironmentVariableMutation.createForAppAsync).mockRejectedValue('graphql down'); + + await expect( + createCommand(['--environment', 'preview', '--region', 'americas', '--overwrite']).runAsync() + ).rejects.toThrow( + /could not write EAS environment variables.*graphql down[\s\S]*Do not re-run connect --environment/ + ); + }); +}); diff --git a/packages/eas-cli/src/commands/integrations/supabase/connect.ts b/packages/eas-cli/src/commands/integrations/supabase/connect.ts new file mode 100644 index 0000000000..d1fc38a987 --- /dev/null +++ b/packages/eas-cli/src/commands/integrations/supabase/connect.ts @@ -0,0 +1,663 @@ +import { Flags } from '@oclif/core'; +import chalk from 'chalk'; +import { z } from 'zod'; + +import EasCommand from '../../../commandUtils/EasCommand'; +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { + EasNonInteractiveAndJsonFlags, + resolveNonInteractiveAndJsonFlags, +} from '../../../commandUtils/flags'; +import { + formatSupabaseOrganization, + formatSupabaseProjectLabel, + getSupabaseProjectDashboardUrl, + parseSupabaseProjectRef, +} from '../../../commandUtils/supabase'; +import { SupabaseMutation } from '../../../graphql/mutations/SupabaseMutation'; +import { SupabaseQuery } from '../../../graphql/queries/SupabaseQuery'; +import { + SupabaseConnectionData, + SupabaseOrganizationData, + SupabaseProjectData, +} from '../../../graphql/types/SupabaseConnection'; +import Log, { link } from '../../../log'; +import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils'; +import { promptAsync, selectAsync } from '../../../prompts'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; + +import { + parseEnvironmentFlag, + resolveTargetEnvironmentsAsync, +} from '../../../environments/resolve'; +import { upsertEnvVarAsync, upsertEnvVarsSequentiallyAsync } from '../../../environments/variables'; +import { writeEnvLocalAsync } from '../../../integrations/shared/envFile'; +import { setupSdkAndConfigAsync } from '../../../integrations/shared/sdk'; +import { + EAS_SUPABASE_ENVIRONMENTS, + EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME, + EAS_SUPABASE_URL_ENV_VAR_NAME, + SUPABASE_ENV_LABEL, + confirmOverwriteForAdditionalProjectAsync, + createSupabaseEnvVars, + supabaseEnvironmentCancelMessage, + supabaseMoveConfirmMessage, +} from '../../../integrations/supabase/env'; +import { + additionalProvisionFailureHint, + authorizeViaBrowserAsync, + loadOrganizationsBestEffortAsync, + pollProvisionReceiptAsync, + primaryProvisionFailureHint, + projectNameSuffixForEnvironments, + resolveOrganizationAsync, + resolvePublishableKeyAsync, + resolveRegionAsync, +} from '../../../integrations/supabase/provision'; + +const SDK_PACKAGES = ['@supabase/supabase-js', 'react-native-url-polyfill', 'expo-sqlite']; +const CONFIG_PLUGIN = 'expo-sqlite'; + +const PrimaryProvisionResultSchema = z.object({ + supabaseProjectId: z.string().optional(), + supabaseProjectRef: z.string().optional(), +}); + +const AdditionalProvisionResultSchema = z.object({ + supabaseProjectRef: z.string(), + supabaseProjectName: z.string().optional(), + supabaseProjectUrl: z.string(), + supabaseRegion: z.string().optional(), + publishableKey: z.string(), +}); + +type AdditionalProvisionResult = { + supabaseProjectRef: string; + supabaseProjectName: string; + supabaseProjectUrl: string; + supabaseRegion: string; + publishableKey: string; +}; + +export default class IntegrationsSupabaseConnect extends EasCommand { + static override description = + 'authorize Supabase, link or provision a project, install the SDK, and write EXPO_PUBLIC_SUPABASE_URL / EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY'; + + static override examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --link ', + '<%= config.bin %> <%= command.id %> --environment preview', + '<%= config.bin %> <%= command.id %> --environment preview --region americas --non-interactive --overwrite', + '<%= config.bin %> <%= command.id %> --reauth', + ]; + + static override contextDefinition = { + ...this.ContextOptions.ProjectConfig, + }; + + static override flags = { + ...EasNonInteractiveAndJsonFlags, + region: Flags.string({ + description: + 'Region when provisioning (americas | emea | apac, or e.g. us-east-1). Required with --non-interactive if provisioning. Ignored with --link or if already linked', + }), + organization: Flags.string({ + description: + 'Supabase org slug for a new primary project. Ignored with --link; incompatible with --environment', + }), + link: Flags.string({ + description: + 'Existing project URL or Reference ID (Project Settings → General). Sets the app primary instead of provisioning. Incompatible with --environment', + }), + reauth: Flags.boolean({ + description: + 'Disconnect the existing Supabase OAuth connection, then re-authorize in the browser. Removes the EAS connection and primary project link (Supabase projects are kept). Then prompts to link an existing project (default) or provision a new one; pass --link to skip the prompt. Interactive only; incompatible with --environment', + default: false, + }), + overwrite: Flags.boolean({ + description: + 'Replace existing EXPO_PUBLIC_SUPABASE_* in .env.local and EAS without prompting', + default: false, + }), + environment: Flags.string({ + description: + 'EAS environments for a separate hosted project (e.g. preview). Requires prior primary connect; writes URL/key only there. Incompatible with --link and --reauth', + }), + }; + + async runAsync(): Promise { + const { flags } = await this.parse(IntegrationsSupabaseConnect); + const { + region: regionFlag, + organization: organizationFlag, + link: linkRefFlag, + reauth, + overwrite, + environment: environmentFlag, + } = flags; + const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags); + const requestedEnvironments = parseEnvironmentFlag(environmentFlag); + const linkProjectRef = linkRefFlag ? parseSupabaseProjectRef(linkRefFlag) : undefined; + if (jsonFlag) { + enableJsonOutput(); + } + + const { + privateProjectConfig: { projectId, projectDir, exp }, + loggedIn: { graphqlClient }, + } = await this.getContextAsync(IntegrationsSupabaseConnect, { + nonInteractive, + withServerSideEnvironment: null, + }); + + if (requestedEnvironments) { + if (linkRefFlag) { + throw new Error( + 'Cannot combine --environment with --link. --link sets the primary project; --environment provisions an additional one for the listed EAS environments.' + ); + } + if (reauth) { + throw new Error( + 'Cannot combine --environment with --reauth. Re-authorize first, then re-run with --environment.' + ); + } + if (organizationFlag) { + throw new Error( + 'Cannot use --organization with --environment. The additional project uses the organization from the existing connection.' + ); + } + const targetEnvironments = await resolveTargetEnvironmentsAsync( + graphqlClient, + projectId, + requestedEnvironments, + nonInteractive, + { + defaultEnvironments: EAS_SUPABASE_ENVIRONMENTS, + cancelMessage: supabaseEnvironmentCancelMessage, + } + ); + await this.runAdditionalEnvironmentSetupAsync({ + graphqlClient, + projectId, + targetEnvironments, + regionFlag, + nonInteractive, + overwrite, + jsonFlag, + }); + return; + } + + const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId); + + let connection = await SupabaseQuery.getSupabaseConnectionByAccountIdAsync( + graphqlClient, + account.id + ); + let didReauth = false; + if (reauth) { + if (!connection) { + if (!nonInteractive) { + Log.warn('--reauth ignored: no existing Supabase connection to reset.'); + } + } else if (nonInteractive) { + throw new Error( + "--reauth re-authorizes in the browser, which isn't possible in non-interactive mode. Re-run `eas integrations:supabase:connect --reauth` interactively." + ); + } else { + Log.warn( + 'Resetting the Supabase connection. This removes the EAS connection and its project link (your Supabase projects are preserved). After authorizing, you will be asked to link an existing project (recommended) or provision a new one.' + ); + await SupabaseMutation.disconnectSupabaseAsync(graphqlClient, connection.id); + Log.withTick('Reset the existing Supabase connection'); + connection = null; + didReauth = true; + } + } + + let organizations: SupabaseOrganizationData[] | null = null; + let authorizedConnection: SupabaseConnectionData; + if (connection) { + organizations = await loadOrganizationsBestEffortAsync(graphqlClient, account.id); + Log.withTick( + `Using existing Supabase organization ${chalk.bold( + formatSupabaseOrganization(connection, organizations ?? undefined) + )}` + ); + authorizedConnection = connection; + } else { + authorizedConnection = await authorizeViaBrowserAsync(graphqlClient, account, nonInteractive); + } + connection = authorizedConnection; + + let project = await SupabaseQuery.getSupabaseProjectByAppIdAsync(graphqlClient, projectId); + if (project) { + Log.withTick( + `Using existing Supabase project ${chalk.bold(formatSupabaseProjectLabel(project))}` + ); + if (linkProjectRef && linkProjectRef !== project.supabaseProjectRef) { + Log.warn( + `Ignoring --link ${chalk.bold(linkProjectRef)}: this app is already linked to ${chalk.bold( + project.supabaseProjectRef + )}. Run \`eas integrations:supabase:disconnect\` first to link a different project.` + ); + } else if (regionFlag || organizationFlag) { + Log.warn( + 'Ignoring --region/--organization: this app is already linked to a Supabase project.' + ); + } + } else if (linkProjectRef) { + // No org picker here: the project ref determines its organization, and the server validates + // that it matches the connection's organization. + project = await SupabaseMutation.linkSupabaseProjectAsync(graphqlClient, { + appId: projectId, + supabaseProjectRef: linkProjectRef, + }); + Log.withTick(`Linked Supabase project ${chalk.bold(formatSupabaseProjectLabel(project))}`); + } else if (didReauth) { + const afterReauth = await this.resolveProjectAfterReauthAsync({ + graphqlClient, + projectId, + accountId: account.id, + connection: authorizedConnection, + organizationFlag, + regionFlag, + nonInteractive, + organizations, + }); + project = afterReauth.project; + authorizedConnection = afterReauth.connection; + connection = authorizedConnection; + } else { + authorizedConnection = await resolveOrganizationAsync( + graphqlClient, + account.id, + authorizedConnection, + organizationFlag, + nonInteractive, + organizations + ); + connection = authorizedConnection; + const region = await resolveRegionAsync(regionFlag, nonInteractive); + project = await this.provisionAndPollPrimaryProjectAsync(graphqlClient, projectId, region); + } + + const publishableKey = await resolvePublishableKeyAsync(graphqlClient, projectId, project); + + const envVars = createSupabaseEnvVars(project.supabaseProjectUrl, publishableKey); + + const manualSteps = await setupSdkAndConfigAsync(projectDir, exp, { + packages: SDK_PACKAGES, + plugin: CONFIG_PLUGIN, + label: SUPABASE_ENV_LABEL, + jsonFlag, + }); + + const envLocalWritten = await writeEnvLocalAsync(projectDir, envVars, { + label: SUPABASE_ENV_LABEL, + nonInteractive, + overwrite, + }); + const easWritten = await upsertEnvVarsSequentiallyAsync(envVars, envVar => + upsertEnvVarAsync( + graphqlClient, + projectId, + envVar, + EAS_SUPABASE_ENVIRONMENTS, + nonInteractive, + overwrite, + { mode: 'replaceOtherEnvironments' } + ) + ); + + if (jsonFlag) { + printJsonOnlyOutput({ + organizationConnection: { + id: connection.id, + organizationSlug: connection.supabaseOrganizationSlug, + }, + project: { + id: project.id, + ref: project.supabaseProjectRef, + url: project.supabaseProjectUrl, + region: project.supabaseRegion, + }, + dashboardUrl: getSupabaseProjectDashboardUrl(project), + envLocalWritten, + environmentVariables: envVars.map((v, i) => ({ name: v.name, easWritten: easWritten[i] })), + manualSteps, + }); + return; + } + + this.printNextSteps(project, manualSteps); + } + + private async resolveProjectAfterReauthAsync({ + graphqlClient, + projectId, + accountId, + connection, + organizationFlag, + regionFlag, + nonInteractive, + organizations, + }: { + graphqlClient: ExpoGraphqlClient; + projectId: string; + accountId: string; + connection: SupabaseConnectionData; + organizationFlag: string | undefined; + regionFlag: string | undefined; + nonInteractive: boolean; + organizations: SupabaseOrganizationData[] | null; + }): Promise<{ project: SupabaseProjectData; connection: SupabaseConnectionData }> { + // --reauth already required interactive mode when a connection existed. + const choice = await selectAsync<'link' | 'provision'>( + 'The previous EAS project link was removed. What next?', + [ + { title: 'Link an existing Supabase project (recommended)', value: 'link' }, + { title: 'Provision a new Supabase project', value: 'provision' }, + ], + { initial: 'link' } + ); + + if (choice === 'link') { + const { linkValue } = await promptAsync({ + type: 'text', + name: 'linkValue', + message: 'Supabase project ref or URL', + validate: (value: string) => { + try { + parseSupabaseProjectRef(value); + return true; + } catch (error) { + return error instanceof Error ? error.message : 'Invalid project ref or URL'; + } + }, + }); + const project = await SupabaseMutation.linkSupabaseProjectAsync(graphqlClient, { + appId: projectId, + supabaseProjectRef: parseSupabaseProjectRef(linkValue), + }); + Log.withTick(`Linked Supabase project ${chalk.bold(formatSupabaseProjectLabel(project))}`); + return { project, connection }; + } + + const nextConnection = await resolveOrganizationAsync( + graphqlClient, + accountId, + connection, + organizationFlag, + nonInteractive, + organizations + ); + const region = await resolveRegionAsync(regionFlag, nonInteractive); + const project = await this.provisionAndPollPrimaryProjectAsync( + graphqlClient, + projectId, + region + ); + return { project, connection: nextConnection }; + } + + private async provisionAndPollPrimaryProjectAsync( + graphqlClient: ExpoGraphqlClient, + projectId: string, + region: string + ): Promise { + const receipt = await SupabaseMutation.provisionSupabaseProjectAsync(graphqlClient, { + appId: projectId, + region, + }); + const finalized = await pollProvisionReceiptAsync(graphqlClient, receipt, { + startMessage: 'Provisioning Supabase project (this can take a minute)…', + waitingMessage: 'Waiting for Expo to finish Supabase setup…', + failureMessage: 'Failed to provision the Supabase project', + failureHint: primaryProvisionFailureHint(), + }); + const parsedResult = PrimaryProvisionResultSchema.safeParse(finalized.resultData); + const resultData = parsedResult.success ? parsedResult.data : undefined; + const project = await SupabaseQuery.getSupabaseProjectByAppIdAsync(graphqlClient, projectId, { + useCache: false, + }); + if (!project) { + throw new Error( + resultData?.supabaseProjectRef + ? `Provision succeeded for project ${resultData.supabaseProjectRef}, but the Expo project link was not found. Try again, or link with --link ${resultData.supabaseProjectRef}.` + : 'Provision succeeded but the Expo project link was not found. Try again, or link an existing project with --link.' + ); + } + Log.withTick(`Provisioned Supabase project ${chalk.bold(formatSupabaseProjectLabel(project))}`); + return project; + } + + private async runAdditionalEnvironmentSetupAsync({ + graphqlClient, + projectId, + targetEnvironments, + regionFlag, + nonInteractive, + overwrite, + jsonFlag, + }: { + graphqlClient: ExpoGraphqlClient; + projectId: string; + targetEnvironments: string[]; + regionFlag: string | undefined; + nonInteractive: boolean; + overwrite: boolean; + jsonFlag: boolean; + }): Promise { + const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId); + let connection = await SupabaseQuery.getSupabaseConnectionByAccountIdAsync( + graphqlClient, + account.id + ); + if (!connection) { + throw new Error( + 'No Supabase connection found. Run `eas integrations:supabase:connect` first, then re-run with --environment.' + ); + } + + const primaryProject = await SupabaseQuery.getSupabaseProjectByAppIdAsync( + graphqlClient, + projectId + ); + if (!primaryProject) { + throw new Error( + 'No primary Supabase project is linked yet. Run `eas integrations:supabase:connect` without --environment first.' + ); + } + + const organizations = await loadOrganizationsBestEffortAsync(graphqlClient, account.id); + Log.withTick( + `Using existing Supabase organization ${chalk.bold( + formatSupabaseOrganization(connection, organizations ?? undefined) + )}` + ); + Log.withTick( + `Primary project remains ${chalk.bold(formatSupabaseProjectLabel(primaryProject))}; provisioning an additional project for ${targetEnvironments.join(', ')}` + ); + + const region = await resolveRegionAsync(regionFlag, nonInteractive); + const projectNameSuffix = projectNameSuffixForEnvironments(targetEnvironments); + + // Fail closed before billing: refuse to provision if we already know env writes will be skipped. + const { forceOverwrite } = await confirmOverwriteForAdditionalProjectAsync( + graphqlClient, + projectId, + targetEnvironments, + nonInteractive, + overwrite + ); + const effectiveOverwrite = overwrite || forceOverwrite; + + const additional = await this.provisionAndPollAdditionalProjectAsync(graphqlClient, projectId, { + region, + projectNameSuffix, + targetEnvironments, + }); + + const envVars = createSupabaseEnvVars(additional.supabaseProjectUrl, additional.publishableKey); + + const dashboardUrl = getSupabaseProjectDashboardUrl({ + supabaseProjectRef: additional.supabaseProjectRef, + }); + + let easWritten: boolean[] = []; + try { + easWritten = await upsertEnvVarsSequentiallyAsync(envVars, envVar => + upsertEnvVarAsync( + graphqlClient, + projectId, + envVar, + targetEnvironments, + nonInteractive, + effectiveOverwrite, + { + mode: 'keepOtherEnvironments', + moveConfirmMessage: supabaseMoveConfirmMessage, + } + ) + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw this.additionalEnvWriteFailureError({ + additional, + targetEnvironments, + dashboardUrl, + headline: `Provisioned additional Supabase project ${additional.supabaseProjectRef}, but could not write EAS environment variables for ${targetEnvironments.join(', ')}: ${message}`, + }); + } + if (easWritten.some(written => !written)) { + throw this.additionalEnvWriteFailureError({ + additional, + targetEnvironments, + dashboardUrl, + headline: `Provisioned additional Supabase project ${additional.supabaseProjectRef}, but did not write all EAS environment variables for ${targetEnvironments.join(', ')}.`, + }); + } + + if (jsonFlag) { + printJsonOnlyOutput({ + additionalProject: { + ref: additional.supabaseProjectRef, + url: additional.supabaseProjectUrl, + region: additional.supabaseRegion, + }, + environments: targetEnvironments, + dashboardUrl, + environmentVariables: envVars.map((v, i) => ({ name: v.name, easWritten: easWritten[i] })), + }); + return; + } + + Log.addNewLineIfNone(); + Log.log(chalk.green('Additional Supabase project is ready!')); + Log.newLine(); + Log.log(`${chalk.bold('Dashboard')}: ${link(dashboardUrl, { dim: false })}`); + Log.log( + `EAS environment variables updated for: ${targetEnvironments.map(env => chalk.bold(env)).join(', ')}` + ); + Log.log( + `Primary linked project is unchanged (${chalk.bold(primaryProject.supabaseProjectRef)}).` + ); + } + + private async provisionAndPollAdditionalProjectAsync( + graphqlClient: ExpoGraphqlClient, + projectId: string, + { + region, + projectNameSuffix, + targetEnvironments, + }: { region: string; projectNameSuffix: string; targetEnvironments: string[] } + ): Promise { + const receipt = await SupabaseMutation.provisionAdditionalSupabaseProjectAsync(graphqlClient, { + appId: projectId, + region, + projectNameSuffix, + }); + const finalized = await pollProvisionReceiptAsync(graphqlClient, receipt, { + startMessage: `Provisioning additional Supabase project for ${targetEnvironments.join(', ')}…`, + waitingMessage: 'Waiting for Expo to finish additional Supabase setup…', + failureMessage: 'Failed to provision the additional Supabase project', + failureHint: additionalProvisionFailureHint(targetEnvironments), + }); + const parsedResult = AdditionalProvisionResultSchema.safeParse(finalized.resultData); + if (!parsedResult.success) { + throw new Error( + 'Additional provision succeeded but did not return project credentials. Open your Supabase dashboard, copy the project URL and publishable key, and set EXPO_PUBLIC_SUPABASE_URL / EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY on the target EAS environments. Do not re-run connect --environment — that would create another project.' + ); + } + const resultData = parsedResult.data; + const additional: AdditionalProvisionResult = { + supabaseProjectRef: resultData.supabaseProjectRef, + supabaseProjectName: resultData.supabaseProjectName ?? resultData.supabaseProjectRef, + supabaseProjectUrl: resultData.supabaseProjectUrl, + supabaseRegion: resultData.supabaseRegion ?? region, + publishableKey: resultData.publishableKey, + }; + Log.withTick( + `Provisioned additional Supabase project ${chalk.bold(formatSupabaseProjectLabel(additional))}` + ); + return additional; + } + + private additionalEnvWriteFailureError({ + additional, + targetEnvironments, + dashboardUrl, + headline, + }: { + additional: Pick; + targetEnvironments: string[]; + dashboardUrl: string; + headline: string; + }): Error { + return new Error( + [ + headline, + `Do not re-run connect --environment — the Supabase project already exists. Set these on ${targetEnvironments.join(', ')}:`, + ` EXPO_PUBLIC_SUPABASE_URL=${additional.supabaseProjectUrl}`, + ` EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY=${additional.publishableKey}`, + `Dashboard: ${dashboardUrl}`, + ].join('\n') + ); + } + + private printNextSteps(project: SupabaseProjectData, manualSteps: string[]): void { + Log.addNewLineIfNone(); + Log.log(chalk.green('Supabase is connected!')); + Log.newLine(); + Log.log( + `${chalk.bold('Dashboard')}: ${link(getSupabaseProjectDashboardUrl(project), { dim: false })}` + ); + Log.newLine(); + Log.log('Next steps:'); + Log.log( + ` 1. Create a Supabase client (e.g. ${chalk.bold('lib/supabase.ts')}) following our guide (${chalk.cyan('https://docs.expo.dev/guides/using-supabase')}). It reads ${chalk.bold(EAS_SUPABASE_URL_ENV_VAR_NAME)} and ${chalk.bold(EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME)} — no extra config plugin or dev build needed.` + ); + Log.log( + ` 2. For local development, run ${chalk.cyan('supabase start')} and point ${chalk.bold(EAS_SUPABASE_URL_ENV_VAR_NAME)} at the local stack (see the guide).` + ); + Log.log( + ` 3. For a separate hosted project for Preview (or other EAS environments), re-run ${chalk.cyan('eas integrations:supabase:connect --environment preview')}.` + ); + + if (manualSteps.length > 0) { + Log.newLine(); + Log.warn('Finish setup manually:'); + for (const step of manualSteps) { + if (step.includes('\n')) { + for (const line of step.split('\n')) { + Log.warn(` ${line}`); + } + } else { + Log.warn(` • ${step}`); + } + } + } + } +}