diff --git a/CHANGELOG.md b/CHANGELOG.md index 12d930e7cb..fe4a707301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features +- [eas-cli] Add `eas integrations:supabase:connect` command. + - [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)) ### 🐛 Bug fixes diff --git a/packages/eas-cli/src/commandUtils/supabase.ts b/packages/eas-cli/src/commandUtils/supabase.ts new file mode 100644 index 0000000000..f01b90a357 --- /dev/null +++ b/packages/eas-cli/src/commandUtils/supabase.ts @@ -0,0 +1,57 @@ +import chalk from 'chalk'; + +import { + SupabaseConnectionData, + SupabaseOrganizationData, + SupabaseProjectData, +} from '../graphql/types/SupabaseConnection'; +import Log, { link } from '../log'; + +export function getSupabaseProjectDashboardUrl( + project: Pick +): string { + return `https://supabase.com/dashboard/project/${encodeURIComponent(project.supabaseProjectRef)}`; +} + +/** Prefer human-readable org name; Supabase slugs are often opaque ids like `ycjmzsygzfkryaazoitp`. */ +export function formatSupabaseOrganization( + connection: Pick< + SupabaseConnectionData, + 'supabaseOrganizationSlug' | 'supabaseOrganizationName' + >, + organizations: readonly SupabaseOrganizationData[] = [] +): string { + const slug = connection.supabaseOrganizationSlug; + const storedName = connection.supabaseOrganizationName; + const liveName = organizations.find(organization => organization.slug === slug)?.name; + const name = storedName || liveName; + if (name && name !== slug) { + return `${name} (${slug})`; + } + return slug; +} + +/** Prefer human-readable project name over the opaque project ref. */ +export function formatSupabaseProjectLabel( + project: Pick +): string { + const { supabaseProjectRef: ref, supabaseProjectName: name } = project; + if (name && name !== ref) { + return `${name} (${ref})`; + } + return ref; +} + +export function formatSupabaseProject(project: SupabaseProjectData): string { + return [ + `${chalk.bold('Name')}: ${project.supabaseProjectName}`, + `${chalk.bold('Ref')}: ${project.supabaseProjectRef}`, + `${chalk.bold('URL')}: ${project.supabaseProjectUrl}`, + `${chalk.bold('Region')}: ${project.supabaseRegion}`, + `${chalk.bold('Dashboard')}: ${link(getSupabaseProjectDashboardUrl(project), { dim: false })}`, + ].join('\n'); +} + +export function logNoSupabaseProject(projectName: string): void { + Log.warn(`No Supabase project is linked to Expo app ${chalk.bold(projectName)} on EAS.`); +} 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..2e77cfa834 --- /dev/null +++ b/packages/eas-cli/src/commands/integrations/supabase/__tests__/connect.test.ts @@ -0,0 +1,640 @@ +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 { 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, 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(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('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 and re-authorizes in the browser', async () => { + jest + .mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync) + .mockResolvedValueOnce(mockConnection) + .mockResolvedValueOnce(null) + .mockResolvedValue(mockConnection); + jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(mockProject); + jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({ + state: 'state', + url: 'https://api.supabase.com/v1/oauth/authorize', + }); + jest.mocked(SupabaseMutation.disconnectSupabaseAsync).mockResolvedValue(mockConnection.id); + + await createCommand(['--reauth']).runAsync(); + + expect(SupabaseMutation.disconnectSupabaseAsync).toHaveBeenCalledWith( + graphqlClient, + mockConnection.id + ); + expect(SupabaseMutation.beginSupabaseOAuthAsync).toHaveBeenCalled(); + expect(openBrowserAsync).toHaveBeenCalled(); + }); + + 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({ + state: 'state', + 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")); + }); +}); 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..1a368cb11b --- /dev/null +++ b/packages/eas-cli/src/commands/integrations/supabase/connect.ts @@ -0,0 +1,512 @@ +import { Flags } from '@oclif/core'; +import chalk from 'chalk'; + +import EasCommand from '../../../commandUtils/EasCommand'; +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { + EasNonInteractiveAndJsonFlags, + resolveNonInteractiveAndJsonFlags, +} from '../../../commandUtils/flags'; +import { + formatSupabaseOrganization, + formatSupabaseProjectLabel, + getSupabaseProjectDashboardUrl, +} from '../../../commandUtils/supabase'; +import { SupabaseMutation } from '../../../graphql/mutations/SupabaseMutation'; +import { SupabaseQuery } from '../../../graphql/queries/SupabaseQuery'; +import { + SupabaseOrganizationData, + SupabaseProjectData, +} from '../../../graphql/types/SupabaseConnection'; +import Log, { link } from '../../../log'; +import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; + +import { + EAS_SUPABASE_ENVIRONMENTS, + parseEnvironmentFlag, + projectNameSuffixForEnvironments, + resolveTargetEnvironmentsAsync, +} from '../../../integrations/supabase/environments'; +import { + EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME, + EAS_SUPABASE_URL_ENV_VAR_NAME, + createSupabaseEnvVars, + ensureAdditionalEnvWritesAllowedAsync, + upsertEasEnvVarAsync, + upsertEasEnvVarForEnvironmentsAsync, + writeEnvLocalAsync, + writeEnvVarsAsync, +} from '../../../integrations/supabase/env'; +import { + SUPABASE_REGION_CHOICES, + additionalProvisionFailureHint, + authorizeViaBrowserAsync, + loadOrganizationsBestEffortAsync, + pollProvisionReceiptAsync, + primaryProvisionFailureHint, + resolveOrganizationAsync, + resolvePublishableKeyAsync, + resolveRegionAsync, +} from '../../../integrations/supabase/provision'; +import { setupSdkAndConfigAsync } from '../../../integrations/supabase/sdk'; + +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.', + options: SUPABASE_REGION_CHOICES.map(r => r.value), + }), + organization: Flags.string({ + description: + 'Supabase org slug for a new primary project. Ignored with --link and --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 themselves are kept. 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); + 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.' + ); + } + const targetEnvironments = await resolveTargetEnvironmentsAsync( + graphqlClient, + projectId, + requestedEnvironments, + nonInteractive + ); + await this.runAdditionalEnvironmentSetupAsync({ + graphqlClient, + projectId, + targetEnvironments, + regionFlag, + organizationFlag, + nonInteractive, + overwrite, + jsonFlag, + }); + return; + } + + const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId); + + let connection = await SupabaseQuery.getSupabaseConnectionByAccountIdAsync( + graphqlClient, + account.id + ); + 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 can re-link with --link or provision a new project.' + ); + await SupabaseMutation.disconnectSupabaseAsync(graphqlClient, connection.id); + Log.withTick('Reset the existing Supabase connection'); + connection = null; + } + } + + let organizations: SupabaseOrganizationData[] | null = null; + if (connection) { + organizations = await loadOrganizationsBestEffortAsync(graphqlClient, account.id); + Log.withTick( + `Using existing Supabase organization ${chalk.bold( + formatSupabaseOrganization(connection, organizations ?? undefined) + )}` + ); + } else { + connection = await authorizeViaBrowserAsync(graphqlClient, account, nonInteractive); + } + + let project = await SupabaseQuery.getSupabaseProjectByAppIdAsync(graphqlClient, projectId); + if (project) { + Log.withTick( + `Using existing Supabase project ${chalk.bold(formatSupabaseProjectLabel(project))}` + ); + if (linkRefFlag && linkRefFlag !== project.supabaseProjectRef) { + Log.warn( + `Ignoring --link ${chalk.bold(linkRefFlag)}: 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 (linkRefFlag) { + // 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: linkRefFlag, + }); + Log.withTick(`Linked Supabase project ${chalk.bold(formatSupabaseProjectLabel(project))}`); + } else { + connection = await resolveOrganizationAsync( + graphqlClient, + account.id, + connection, + organizationFlag, + nonInteractive, + organizations + ); + 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, jsonFlag); + + const envLocalWritten = await writeEnvLocalAsync(projectDir, envVars, nonInteractive, overwrite); + const easWritten = await writeEnvVarsAsync(envVars, envVar => + upsertEasEnvVarAsync( + graphqlClient, + projectId, + envVar, + EAS_SUPABASE_ENVIRONMENTS, + nonInteractive, + overwrite + ) + ); + + 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 provisionAndPollPrimaryProjectAsync( + graphqlClient: ExpoGraphqlClient, + projectId: string, + region: string + ): Promise { + const receipt = await SupabaseMutation.provisionSupabaseProjectAsync(graphqlClient, { + appId: projectId, + region, + }); + const { finalized, spinner } = 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 resultData = finalized.resultData as + | { supabaseProjectId?: string; supabaseProjectRef?: string } + | null + | 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.' + ); + } + spinner.succeed( + `Provisioned Supabase project ${chalk.bold(formatSupabaseProjectLabel(project))}` + ); + return project; + } + + private async runAdditionalEnvironmentSetupAsync({ + graphqlClient, + projectId, + targetEnvironments, + regionFlag, + organizationFlag, + nonInteractive, + overwrite, + jsonFlag, + }: { + graphqlClient: ExpoGraphqlClient; + projectId: string; + targetEnvironments: string[]; + regionFlag: string | undefined; + organizationFlag: 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(', ')}` + ); + + if (organizationFlag) { + throw new Error( + 'Cannot use --organization with --environment. The additional project uses the organization from the existing connection.' + ); + } + + 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 confirmedOverwrite = await ensureAdditionalEnvWritesAllowedAsync( + graphqlClient, + projectId, + targetEnvironments, + nonInteractive, + overwrite + ); + const effectiveOverwrite = overwrite || confirmedOverwrite; + + const receipt = await SupabaseMutation.provisionAdditionalSupabaseProjectAsync(graphqlClient, { + appId: projectId, + region, + projectNameSuffix, + }); + const { finalized, spinner } = 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 resultData = finalized.resultData as + | { + supabaseProjectRef?: string; + supabaseProjectName?: string; + supabaseProjectUrl?: string; + supabaseRegion?: string; + publishableKey?: string; + } + | null + | undefined; + if ( + !resultData?.supabaseProjectRef || + !resultData.supabaseProjectUrl || + !resultData.publishableKey + ) { + throw new Error( + 'Additional provision succeeded but did not return project credentials. Check your Supabase dashboard and try again.' + ); + } + const additional = { + supabaseProjectRef: resultData.supabaseProjectRef, + supabaseProjectName: resultData.supabaseProjectName ?? resultData.supabaseProjectRef, + supabaseProjectUrl: resultData.supabaseProjectUrl, + supabaseRegion: resultData.supabaseRegion ?? region, + publishableKey: resultData.publishableKey, + }; + spinner.succeed( + `Provisioned additional Supabase project ${chalk.bold(formatSupabaseProjectLabel(additional))}` + ); + + const envVars = createSupabaseEnvVars(additional.supabaseProjectUrl, additional.publishableKey); + + const dashboardUrl = getSupabaseProjectDashboardUrl({ + supabaseProjectRef: additional.supabaseProjectRef, + }); + + let easWritten: boolean[] = []; + try { + easWritten = await writeEnvVarsAsync(envVars, envVar => + upsertEasEnvVarForEnvironmentsAsync( + graphqlClient, + projectId, + envVar, + targetEnvironments, + nonInteractive, + effectiveOverwrite + ) + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + [ + `Provisioned additional Supabase project ${additional.supabaseProjectRef}, but could not write EAS environment variables for ${targetEnvironments.join(', ')}: ${message}`, + `Dashboard: ${dashboardUrl}`, + `Re-run with --overwrite, or set EXPO_PUBLIC_SUPABASE_URL=${additional.supabaseProjectUrl} and EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY on ${targetEnvironments.join(', ')}.`, + ].join('\n') + ); + } + if (easWritten.some(written => !written)) { + throw new Error( + [ + `Provisioned additional Supabase project ${additional.supabaseProjectRef}, but did not write all EAS environment variables for ${targetEnvironments.join(', ')}.`, + `Re-run with --overwrite, or set EXPO_PUBLIC_SUPABASE_URL=${additional.supabaseProjectUrl} and EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY on those environments.`, + `Dashboard: ${dashboardUrl}`, + ].join('\n') + ); + } + + 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 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}`); + } + } + } + } +} diff --git a/packages/eas-cli/src/graphql/mutations/SupabaseMutation.ts b/packages/eas-cli/src/graphql/mutations/SupabaseMutation.ts new file mode 100644 index 0000000000..f82099214c --- /dev/null +++ b/packages/eas-cli/src/graphql/mutations/SupabaseMutation.ts @@ -0,0 +1,269 @@ +import { print } from 'graphql'; +import gql from 'graphql-tag'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { withErrorHandlingAsync } from '../client'; +import { BackgroundJobReceiptDataFragment } from '../generated'; +import { BackgroundJobReceiptNode } from '../types/BackgroundJobReceipt'; +import { + BeginSupabaseOAuthInput, + LinkSupabaseProjectInput, + ProvisionAdditionalSupabaseProjectInput, + ProvisionSupabaseProjectInput, + SetSupabaseConnectionOrganizationInput, + SupabaseConnectionData, + SupabaseConnectionFragmentNode, + SupabaseOAuthStartData, + SupabaseOrganizationData, + SupabaseProjectData, + SupabaseProjectFragmentNode, +} from '../types/SupabaseConnection'; + +export const SupabaseMutation = { + async beginSupabaseOAuthAsync( + graphqlClient: ExpoGraphqlClient, + input: BeginSupabaseOAuthInput + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation< + { supabaseConnection: { beginSupabaseOAuth: SupabaseOAuthStartData } }, + { input: BeginSupabaseOAuthInput } + >( + gql` + mutation BeginSupabaseOAuth($input: BeginSupabaseOAuthInput!) { + supabaseConnection { + beginSupabaseOAuth(input: $input) { + state + url + } + } + } + `, + { input } + ) + .toPromise() + ); + return data.supabaseConnection.beginSupabaseOAuth; + }, + + async setSupabaseConnectionOrganizationAsync( + graphqlClient: ExpoGraphqlClient, + input: SetSupabaseConnectionOrganizationInput + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation< + { + supabaseConnection: { setSupabaseConnectionOrganization: SupabaseConnectionData }; + }, + { input: SetSupabaseConnectionOrganizationInput } + >( + gql` + mutation SetSupabaseConnectionOrganization( + $input: SetSupabaseConnectionOrganizationInput! + ) { + supabaseConnection { + setSupabaseConnectionOrganization(input: $input) { + id + ...SupabaseConnectionFragment + } + } + } + ${print(SupabaseConnectionFragmentNode)} + `, + { input }, + { additionalTypenames: ['SupabaseConnection'] } + ) + .toPromise() + ); + return data.supabaseConnection.setSupabaseConnectionOrganization; + }, + + async disconnectSupabaseAsync(graphqlClient: ExpoGraphqlClient, id: string): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation<{ supabaseConnection: { disconnectSupabase: string } }, { id: string }>( + gql` + mutation DisconnectSupabase($id: ID!) { + supabaseConnection { + disconnectSupabase(id: $id) + } + } + `, + { id }, + { additionalTypenames: ['Account', 'SupabaseConnection', 'SupabaseProject'] } + ) + .toPromise() + ); + return data.supabaseConnection.disconnectSupabase; + }, + + async provisionSupabaseProjectAsync( + graphqlClient: ExpoGraphqlClient, + input: ProvisionSupabaseProjectInput + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation< + { supabaseProject: { provisionSupabaseProject: BackgroundJobReceiptDataFragment } }, + { input: ProvisionSupabaseProjectInput } + >( + gql` + mutation ProvisionSupabaseProject($input: ProvisionSupabaseProjectInput!) { + supabaseProject { + provisionSupabaseProject(input: $input) { + id + ...BackgroundJobReceiptData + } + } + } + ${print(BackgroundJobReceiptNode)} + `, + { input }, + { additionalTypenames: ['App', 'SupabaseProject', 'BackgroundJobReceipt'] } + ) + .toPromise() + ); + return data.supabaseProject.provisionSupabaseProject; + }, + + async provisionAdditionalSupabaseProjectAsync( + graphqlClient: ExpoGraphqlClient, + input: ProvisionAdditionalSupabaseProjectInput + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation< + { + supabaseProject: { + provisionAdditionalSupabaseProject: BackgroundJobReceiptDataFragment; + }; + }, + { input: ProvisionAdditionalSupabaseProjectInput } + >( + gql` + mutation ProvisionAdditionalSupabaseProject( + $input: ProvisionAdditionalSupabaseProjectInput! + ) { + supabaseProject { + provisionAdditionalSupabaseProject(input: $input) { + id + ...BackgroundJobReceiptData + } + } + } + ${print(BackgroundJobReceiptNode)} + `, + { input }, + { additionalTypenames: ['App', 'SupabaseProject', 'BackgroundJobReceipt'] } + ) + .toPromise() + ); + return data.supabaseProject.provisionAdditionalSupabaseProject; + }, + + async linkSupabaseProjectAsync( + graphqlClient: ExpoGraphqlClient, + input: LinkSupabaseProjectInput + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation< + { supabaseProject: { linkSupabaseProject: SupabaseProjectData } }, + { input: LinkSupabaseProjectInput } + >( + gql` + mutation LinkSupabaseProject($input: LinkSupabaseProjectInput!) { + supabaseProject { + linkSupabaseProject(input: $input) { + id + ...SupabaseProjectFragment + } + } + } + ${print(SupabaseProjectFragmentNode)} + `, + { input }, + { additionalTypenames: ['App', 'SupabaseProject'] } + ) + .toPromise() + ); + return data.supabaseProject.linkSupabaseProject; + }, + + async deleteSupabaseProjectAsync(graphqlClient: ExpoGraphqlClient, id: string): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation<{ supabaseProject: { deleteSupabaseProject: string } }, { id: string }>( + gql` + mutation DeleteSupabaseProject($id: ID!) { + supabaseProject { + deleteSupabaseProject(id: $id) + } + } + `, + { id }, + { additionalTypenames: ['App', 'SupabaseProject'] } + ) + .toPromise() + ); + return data.supabaseProject.deleteSupabaseProject; + }, + + async listSupabaseOrganizationsAsync( + graphqlClient: ExpoGraphqlClient, + accountId: string + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation< + { + supabaseConnection: { + listSupabaseOrganizations: SupabaseOrganizationData[]; + }; + }, + { accountId: string } + >( + gql` + mutation ListSupabaseOrganizations($accountId: ID!) { + supabaseConnection { + listSupabaseOrganizations(accountId: $accountId) { + id + slug + name + } + } + } + `, + { accountId } + ) + .toPromise() + ); + return data.supabaseConnection.listSupabaseOrganizations; + }, + + async fetchSupabasePublishableKeyAsync( + graphqlClient: ExpoGraphqlClient, + appId: string + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation< + { supabaseProject: { fetchSupabasePublishableKey: string | null } }, + { appId: string } + >( + gql` + mutation FetchSupabasePublishableKey($appId: ID!) { + supabaseProject { + fetchSupabasePublishableKey(appId: $appId) + } + } + `, + { appId } + ) + .toPromise() + ); + return data.supabaseProject.fetchSupabasePublishableKey; + }, +}; diff --git a/packages/eas-cli/src/graphql/queries/SupabaseQuery.ts b/packages/eas-cli/src/graphql/queries/SupabaseQuery.ts new file mode 100644 index 0000000000..823a1c2283 --- /dev/null +++ b/packages/eas-cli/src/graphql/queries/SupabaseQuery.ts @@ -0,0 +1,97 @@ +import { print } from 'graphql'; +import gql from 'graphql-tag'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { withErrorHandlingAsync } from '../client'; +import { + SupabaseConnectionData, + SupabaseConnectionFragmentNode, + SupabaseProjectData, + SupabaseProjectFragmentNode, +} from '../types/SupabaseConnection'; + +type SupabaseConnectionByAccountIdQuery = { + account: { + byId: { + id: string; + supabaseConnection?: SupabaseConnectionData | null; + }; + }; +}; + +type SupabaseProjectByAppIdQuery = { + app: { + byId: { + id: string; + supabaseProject?: SupabaseProjectData | null; + }; + }; +}; + +export const SupabaseQuery = { + async getSupabaseConnectionByAccountIdAsync( + graphqlClient: ExpoGraphqlClient, + accountId: string, + { useCache = true }: { useCache?: boolean } = {} + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .query( + gql` + query SupabaseConnectionByAccountId($accountId: String!) { + account { + byId(accountId: $accountId) { + id + supabaseConnection { + id + ...SupabaseConnectionFragment + } + } + } + } + ${print(SupabaseConnectionFragmentNode)} + `, + { accountId }, + { + additionalTypenames: ['SupabaseConnection'], + requestPolicy: useCache ? 'cache-first' : 'network-only', + } + ) + .toPromise() + ); + return data.account.byId.supabaseConnection ?? null; + }, + + async getSupabaseProjectByAppIdAsync( + graphqlClient: ExpoGraphqlClient, + appId: string, + { useCache = true }: { useCache?: boolean } = {} + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .query( + gql` + query SupabaseProjectByAppId($appId: String!) { + app { + byId(appId: $appId) { + id + supabaseProject { + id + ...SupabaseProjectFragment + } + } + } + } + ${print(SupabaseProjectFragmentNode)} + `, + { appId }, + { + additionalTypenames: ['App', 'SupabaseProject'], + requestPolicy: useCache ? 'cache-first' : 'network-only', + } + ) + .toPromise() + ); + return data.app.byId.supabaseProject ?? null; + }, +}; diff --git a/packages/eas-cli/src/graphql/types/SupabaseConnection.ts b/packages/eas-cli/src/graphql/types/SupabaseConnection.ts new file mode 100644 index 0000000000..4591eae00a --- /dev/null +++ b/packages/eas-cli/src/graphql/types/SupabaseConnection.ts @@ -0,0 +1,79 @@ +import gql from 'graphql-tag'; + +// Hand-defined until the Supabase schema lands in graphql/generated.ts (codegen from the deployed +// schema). Once it does, these should be replaced with Pick<> over the generated types. +export type SupabaseOrganizationData = { + id: string; + slug: string; + name: string; +}; + +export type SupabaseConnectionData = { + id: string; + supabaseOrganizationSlug: string; + supabaseOrganizationName: string; + createdAt: string; + updatedAt: string; +}; + +export type SetSupabaseConnectionOrganizationInput = { + supabaseConnectionId: string; + organizationSlug: string; +}; + +export type SupabaseProjectData = { + id: string; + supabaseProjectRef: string; + supabaseProjectName: string; + supabaseProjectUrl: string; + supabaseRegion: string; + createdAt: string; + updatedAt: string; +}; + +export type SupabaseOAuthStartData = { + state: string; + url: string; +}; + +export type BeginSupabaseOAuthInput = { + accountId: string; +}; + +export type ProvisionSupabaseProjectInput = { + appId: string; + region: string; +}; + +export type ProvisionAdditionalSupabaseProjectInput = { + appId: string; + region: string; + projectNameSuffix: string; +}; + +export type LinkSupabaseProjectInput = { + appId: string; + supabaseProjectRef: string; +}; + +export const SupabaseConnectionFragmentNode = gql` + fragment SupabaseConnectionFragment on SupabaseConnection { + id + supabaseOrganizationSlug + supabaseOrganizationName + createdAt + updatedAt + } +`; + +export const SupabaseProjectFragmentNode = gql` + fragment SupabaseProjectFragment on SupabaseProject { + id + supabaseProjectRef + supabaseProjectName + supabaseProjectUrl + supabaseRegion + createdAt + updatedAt + } +`; diff --git a/packages/eas-cli/src/integrations/supabase/env.ts b/packages/eas-cli/src/integrations/supabase/env.ts new file mode 100644 index 0000000000..707cb16799 --- /dev/null +++ b/packages/eas-cli/src/integrations/supabase/env.ts @@ -0,0 +1,331 @@ +import chalk from 'chalk'; +import dotenv from 'dotenv'; +import * as fs from 'fs-extra'; +import path from 'path'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { + EnvironmentSecretType, + EnvironmentVariableScope, + EnvironmentVariableVisibility, +} from '../../graphql/generated'; +import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation'; +import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery'; +import Log from '../../log'; +import { confirmAsync } from '../../prompts'; + +export const EAS_SUPABASE_URL_ENV_VAR_NAME = 'EXPO_PUBLIC_SUPABASE_URL'; +export const EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME = 'EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY'; + +export type EnvVar = { name: string; value: string; visibility: EnvironmentVariableVisibility }; + +export function createSupabaseEnvVars(url: string, publishableKey: string): EnvVar[] { + return [ + { + name: EAS_SUPABASE_URL_ENV_VAR_NAME, + value: url, + visibility: EnvironmentVariableVisibility.Public, + }, + { + name: EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME, + value: publishableKey, + visibility: EnvironmentVariableVisibility.Public, + }, + ]; +} + +export async function writeEnvLocalAsync( + projectDir: string, + envVars: EnvVar[], + nonInteractive: boolean, + overwrite: boolean +): Promise { + const envPath = path.join(projectDir, '.env.local'); + let rawContent = ''; + if (await fs.pathExists(envPath)) { + rawContent = await fs.readFile(envPath, 'utf8'); + const existing = dotenv.parse(rawContent); + const conflicts = envVars.filter(v => existing[v.name] !== undefined); + if (conflicts.length > 0 && !overwrite) { + if (nonInteractive) { + Log.warn( + `.env.local already defines ${conflicts.map(v => v.name).join(', ')}; skipped (pass --overwrite to replace).` + ); + return false; + } + const confirmed = await confirmAsync({ + message: `.env.local already defines ${conflicts + .map(v => v.name) + .join(', ')}. Overwrite with the Supabase values?`, + }); + if (!confirmed) { + Log.warn(`Skipped updating ${chalk.bold('.env.local')}.`); + return false; + } + } + } + + const updatedContent = mergeEnvContent( + rawContent, + Object.fromEntries(envVars.map(v => [v.name, v.value])) + ); + await fs.writeFile(envPath, updatedContent); + Log.withTick(`Wrote Supabase config to ${chalk.bold('.env.local')}`); + return true; +} + +export async function writeEnvVarsAsync( + envVars: EnvVar[], + upsert: (envVar: EnvVar) => Promise +): Promise { + const easWritten: boolean[] = []; + for (const envVar of envVars) { + easWritten.push(await upsert(envVar)); + } + return easWritten; +} + +export function mergeEnvContent(rawContent: string, newVars: Record): string { + let content = rawContent; + const keysToAdd: Record = { ...newVars }; + for (const [key, value] of Object.entries(newVars)) { + const regex = new RegExp(`^${key}=.*$`, 'm'); + if (regex.test(content)) { + content = content.replace(regex, () => `${key}=${value}`); + delete keysToAdd[key]; + } + } + const remaining = Object.entries(keysToAdd); + if (remaining.length > 0) { + if (content.length > 0 && !content.endsWith('\n')) { + content += '\n'; + } + for (const [key, value] of remaining) { + content += `${key}=${value}\n`; + } + } + return content; +} + +export async function upsertEasEnvVarAsync( + graphqlClient: ExpoGraphqlClient, + projectId: string, + envVar: EnvVar, + environments: string[], + nonInteractive: boolean, + overwrite: boolean +): Promise { + const existingProjectVariables = ( + await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, { + appId: projectId, + filterNames: [envVar.name], + }) + ).filter(variable => variable.scope === EnvironmentVariableScope.Project); + + if (existingProjectVariables.length === 0) { + await EnvironmentVariableMutation.createForAppAsync( + graphqlClient, + { + name: envVar.name, + value: envVar.value, + environments, + visibility: envVar.visibility, + type: EnvironmentSecretType.String, + }, + projectId + ); + Log.withTick( + `Created EAS environment variable ${chalk.bold(envVar.name)} for ${environments.join(', ')}` + ); + return true; + } + + const [keeper, ...extras] = existingProjectVariables; + const extraEnvironments = [ + ...new Set(extras.flatMap(variable => variable.environments ?? [])), + ]; + const shouldOverwrite = + overwrite || + (!nonInteractive && + (await confirmAsync({ + message: + extras.length > 0 + ? `EAS has multiple ${envVar.name} variables for this project (including ${extraEnvironments.join(', ') || 'other environments'}). Replace them with one value for ${environments.join(', ')}?` + : `EAS already has an ${envVar.name} environment variable for this project. Overwrite it?`, + }))); + if (!shouldOverwrite) { + Log.warn( + `Skipped updating EAS environment variable ${chalk.bold(envVar.name)}${ + nonInteractive ? ' (pass --overwrite to replace it)' : '' + }.` + ); + return false; + } + + for (const extra of extras) { + await EnvironmentVariableMutation.deleteAsync(graphqlClient, extra.id); + } + await EnvironmentVariableMutation.updateAsync(graphqlClient, { + id: keeper!.id, + name: envVar.name, + value: envVar.value, + environments, + visibility: envVar.visibility, + type: EnvironmentSecretType.String, + }); + Log.withTick( + `Updated EAS environment variable ${chalk.bold(envVar.name)} for ${environments.join(', ')}` + ); + return true; +} + +export async function ensureAdditionalEnvWritesAllowedAsync( + graphqlClient: ExpoGraphqlClient, + projectId: string, + environments: string[], + nonInteractive: boolean, + overwrite: boolean +): Promise { + if (overwrite) { + return true; + } + const names = [EAS_SUPABASE_URL_ENV_VAR_NAME, EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME]; + const targetSet = new Set(environments); + for (const name of names) { + const existingVariables = ( + await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, { + appId: projectId, + filterNames: [name], + }) + ).filter(variable => variable.scope === EnvironmentVariableScope.Project); + const hasOverlap = existingVariables.some(variable => + (variable.environments ?? []).some(environment => targetSet.has(environment)) + ); + if (!hasOverlap) { + continue; + } + if (nonInteractive) { + throw new Error( + `EAS already has ${name} for ${environments.join(', ')}. Re-run with --overwrite to replace it before provisioning an additional project.` + ); + } + const proceed = await confirmAsync({ + message: `EAS already has ${name} covering ${environments.join(', ')}. Continue and move those values to the additional Supabase project?`, + }); + if (!proceed) { + throw new Error( + `Canceled. No additional Supabase project was provisioned. Pass --overwrite to replace ${name} for ${environments.join(', ')}, or leave those environments on the primary project.` + ); + } + // One confirm covers both vars; stop asking. Force overwrite so the per-var upsert + // doesn't prompt again and can't return false after we already billed the project. + return true; + } + return false; +} + +export async function upsertEasEnvVarForEnvironmentsAsync( + graphqlClient: ExpoGraphqlClient, + projectId: string, + envVar: EnvVar, + environments: string[], + nonInteractive: boolean, + overwrite: boolean +): Promise { + const existingVariables = ( + await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, { + appId: projectId, + filterNames: [envVar.name], + }) + ).filter(variable => variable.scope === EnvironmentVariableScope.Project); + + const targetSet = new Set(environments); + const exactMatch = existingVariables.find(variable => { + const current = variable.environments ?? []; + return ( + current.length === targetSet.size && current.every(environment => targetSet.has(environment)) + ); + }); + if (exactMatch) { + const shouldOverwrite = + overwrite || + exactMatch.value === envVar.value || + (!nonInteractive && + (await confirmAsync({ + message: `EAS already has ${envVar.name} for ${environments.join(', ')}. Overwrite it?`, + }))); + if (!shouldOverwrite) { + Log.warn(`Skipped updating EAS environment variable ${chalk.bold(envVar.name)}.`); + return false; + } + await EnvironmentVariableMutation.updateAsync(graphqlClient, { + id: exactMatch.id, + name: envVar.name, + value: envVar.value, + environments, + visibility: envVar.visibility, + type: EnvironmentSecretType.String, + }); + Log.withTick( + `Updated EAS environment variable ${chalk.bold(envVar.name)} for ${environments.join(', ')}` + ); + return true; + } + + const toDelete: { id: string; overlap: string[] }[] = []; + const toShrink: { id: string; overlap: string[]; remaining: string[] }[] = []; + for (const variable of existingVariables) { + const current = variable.environments ?? []; + const overlap = current.filter(environment => targetSet.has(environment)); + if (overlap.length === 0) { + continue; + } + const remaining = current.filter(environment => !targetSet.has(environment)); + if (remaining.length === 0) { + toDelete.push({ id: variable.id, overlap }); + } else { + toShrink.push({ id: variable.id, overlap, remaining }); + } + } + + if ((toDelete.length > 0 || toShrink.length > 0) && !overwrite) { + const overlapLabel = [ + ...new Set([...toDelete, ...toShrink].flatMap(item => item.overlap)), + ].join(', '); + const shouldOverwrite = + !nonInteractive && + (await confirmAsync({ + message: `Move ${envVar.name} for ${overlapLabel} to the additional Supabase project?`, + })); + if (!shouldOverwrite) { + Log.warn(`Skipped updating EAS environment variable ${chalk.bold(envVar.name)}.`); + return false; + } + } + + for (const item of toDelete) { + await EnvironmentVariableMutation.deleteAsync(graphqlClient, item.id); + } + for (const item of toShrink) { + await EnvironmentVariableMutation.updateAsync(graphqlClient, { + id: item.id, + environments: item.remaining, + }); + } + + await EnvironmentVariableMutation.createForAppAsync( + graphqlClient, + { + name: envVar.name, + value: envVar.value, + environments, + visibility: envVar.visibility, + type: EnvironmentSecretType.String, + }, + projectId + ); + Log.withTick( + `Created EAS environment variable ${chalk.bold(envVar.name)} for ${environments.join(', ')}` + ); + return true; +} diff --git a/packages/eas-cli/src/integrations/supabase/environments.ts b/packages/eas-cli/src/integrations/supabase/environments.ts new file mode 100644 index 0000000000..afbf02f706 --- /dev/null +++ b/packages/eas-cli/src/integrations/supabase/environments.ts @@ -0,0 +1,105 @@ +import { DefaultEnvironment } from '../../build/utils/environment'; +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery'; +import { confirmAsync } from '../../prompts'; + +export const EAS_SUPABASE_ENVIRONMENTS = [ + DefaultEnvironment.Production, + DefaultEnvironment.Preview, + DefaultEnvironment.Development, +]; + +export function parseEnvironmentFlag(value: string | undefined): string[] | null { + if (value === undefined) { + return null; + } + if (!value.trim()) { + throw new Error( + 'Pass at least one EAS environment to --environment (e.g. --environment preview).' + ); + } + const environments = [ + ...new Set( + value + .split(',') + .map(part => part.trim().toLowerCase()) + .filter(Boolean) + ), + ]; + if (environments.length === 0) { + throw new Error( + 'Pass at least one EAS environment to --environment (e.g. --environment preview).' + ); + } + for (const environment of environments) { + if (environment.length < 3 || environment.length > 100 || !/^[a-z0-9_-]+$/.test(environment)) { + throw new Error( + `Invalid EAS environment "${environment}". Use 3–100 lowercase letters, numbers, dashes, or underscores.` + ); + } + } + return environments; +} + +export async function resolveTargetEnvironmentsAsync( + graphqlClient: ExpoGraphqlClient, + projectId: string, + requested: string[], + nonInteractive: boolean +): Promise { + let known: string[]; + try { + known = await EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync( + graphqlClient, + projectId + ); + } catch (error) { + // If the query itself fails, surface that rather than guessing the environment list. + throw new Error( + `Could not load EAS environments for this project: ${error instanceof Error ? error.message : String(error)}` + ); + } + if (known.length === 0) { + known = [...EAS_SUPABASE_ENVIRONMENTS]; + } + const knownSet = new Set(known); + const unknown = requested.filter(environment => !knownSet.has(environment)); + if (unknown.length === 0) { + return requested; + } + const defaultEnvironmentSet = new Set(EAS_SUPABASE_ENVIRONMENTS); + const hasCustom = unknown.some(environment => !defaultEnvironmentSet.has(environment)); + if (nonInteractive) { + const hint = hasCustom + ? 'Custom environments require an Enterprise plan; pass only default environments, or upgrade.' + : 'Re-run interactively to create them, or pass only existing environments.'; + throw new Error( + `EAS environment(s) not found on this project: ${unknown.join(', ')}. Known: ${known.join(', ')}. ${hint}` + ); + } + const listed = unknown.map(environment => `"${environment}"`).join(', '); + const isPlural = unknown.length > 1; + const noun = isPlural ? 'environments' : 'environment'; + const verb = isPlural ? 'are' : 'is'; + const customHint = hasCustom + ? ' Custom environments require an Enterprise plan; values will be written if your account supports them.' + : ''; + const create = await confirmAsync({ + message: `EAS ${noun} ${listed} ${verb} not used on this project yet.${customHint} Continue provisioning?`, + }); + if (!create) { + throw new Error( + `Canceled. No additional Supabase project was provisioned. Create the environment(s) first, or pass only existing ones (known: ${known.join(', ')}).` + ); + } + // Custom environments are created lazily when env vars are written (createForAppAsync). + return requested; +} + +export function projectNameSuffixForEnvironments(environments: string[]): string { + return [...environments] + .sort() + .join('-') + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .slice(0, 32); +} diff --git a/packages/eas-cli/src/integrations/supabase/provision.ts b/packages/eas-cli/src/integrations/supabase/provision.ts new file mode 100644 index 0000000000..c7e1447420 --- /dev/null +++ b/packages/eas-cli/src/integrations/supabase/provision.ts @@ -0,0 +1,306 @@ +import openBrowserAsync from 'better-opn'; +import chalk from 'chalk'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { + formatSupabaseOrganization, + getSupabaseProjectDashboardUrl, +} from '../../commandUtils/supabase'; +import { BackgroundJobReceiptDataFragment } from '../../graphql/generated'; +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 { Ora, ora } from '../../ora'; +import { selectAsync } from '../../prompts'; +import { + BackgroundJobReceiptPollError, + BackgroundJobReceiptPollErrorType, + pollForBackgroundJobReceiptAsync, +} from '../../utils/pollForBackgroundJobReceiptAsync'; + +// The server holds the pending OAuth row for 15 minutes; match that so we don't time out on an +// approval the user completes within the window. +const CONNECTION_POLL_INTERVAL_MS = 2_000; +const CONNECTION_POLL_TIMEOUT_MS = 15 * 60 * 1_000; + +// A freshly provisioned project takes a minute or two to become healthy; the publishable key only +// resolves once it is. +const READINESS_POLL_INTERVAL_MS = 3_000; +const READINESS_POLL_TIMEOUT_MS = 5 * 60 * 1_000; +const MAX_CONSECUTIVE_READINESS_ERRORS = 3; + +// Background job create + publishable-key polling can take 5+ minutes; allow 7 min at 1s interval. +export const PROVISION_RECEIPT_MAX_CHECKS = 420; +export const PROVISION_RECEIPT_MAX_CONSECUTIVE_FETCH_ERRORS = 3; + +export const SUPABASE_REGION_CHOICES = [ + { title: 'Americas (US)', value: 'americas' }, + { title: 'Europe / Middle East / Africa', value: 'emea' }, + { title: 'Asia Pacific', value: 'apac' }, +]; + +export function toProvisionPollError(error: unknown, { hint }: { hint: string }): Error { + if (!(error instanceof BackgroundJobReceiptPollError)) { + return error instanceof Error ? error : new Error(String(error)); + } + const trimmedHint = hint.trim(); + const join = (message: string): string => + trimmedHint ? `${message.trimEnd()}\n\n${trimmedHint}` : message; + if (error.errorData.errorType === BackgroundJobReceiptPollErrorType.JOB_FAILED_NO_WILL_RETRY) { + return new Error(join(error.errorData.receiptErrorMessage ?? error.message)); + } + if ( + error.errorData.errorType === BackgroundJobReceiptPollErrorType.TIMEOUT || + error.errorData.errorType === BackgroundJobReceiptPollErrorType.NULL_RECEIPT + ) { + return new Error( + join('Timed out or lost contact while waiting for Supabase project provision.') + ); + } + return error; +} + +/** Fast-forward guidance when additional (--environment) provision fails permanently. */ +export function additionalProvisionFailureHint(environments: string[]): string { + return [ + 'If a project already exists in your Supabase dashboard, point those environments at it with --link:', + ` eas integrations:supabase:connect --environment ${environments.join(',')} --link `, + 'Or free a slot on your Supabase plan (delete, pause, or upgrade a project), then re-run without --link.', + ].join('\n'); +} + +/** Where to find a project for --link (primary connect path). */ +export function primaryProvisionFailureHint(): string { + return [ + 'If a project already exists in your Supabase dashboard, link it instead of provisioning:', + ' eas integrations:supabase:connect --link ', + ].join('\n'); +} + +export async function pollProvisionReceiptAsync( + graphqlClient: ExpoGraphqlClient, + receipt: BackgroundJobReceiptDataFragment, + { + startMessage, + waitingMessage, + failureMessage, + failureHint, + }: { + startMessage: string; + waitingMessage: string; + failureMessage: string; + failureHint: string; + } +): Promise<{ finalized: BackgroundJobReceiptDataFragment; spinner: Ora }> { + const spinner = ora(startMessage).start(); + try { + spinner.text = waitingMessage; + const finalized = await pollForBackgroundJobReceiptAsync(graphqlClient, receipt, { + maxChecks: PROVISION_RECEIPT_MAX_CHECKS, + maxConsecutiveFetchErrors: PROVISION_RECEIPT_MAX_CONSECUTIVE_FETCH_ERRORS, + }); + if (!finalized) { + throw new Error('Supabase project provision finished without a receipt.'); + } + return { finalized, spinner }; + } catch (error) { + spinner.fail(failureMessage); + throw toProvisionPollError(error, { hint: failureHint }); + } +} + +export async function authorizeViaBrowserAsync( + graphqlClient: ExpoGraphqlClient, + account: { id: string; name: string }, + nonInteractive: boolean +): Promise { + if (nonInteractive) { + throw new Error( + `Connecting Supabase requires approving access in a browser, which isn't possible in non-interactive mode. Re-run \`eas integrations:supabase:connect\` interactively.` + ); + } + + const { url } = await SupabaseMutation.beginSupabaseOAuthAsync(graphqlClient, { + accountId: account.id, + }); + Log.addNewLineIfNone(); + Log.log( + `Authorize Expo to access your Supabase account in the browser. You'll need an existing Supabase account.` + ); + const opened = await openBrowserAsync(url).catch(() => false); + Log.log(opened ? `Opened ${link(url)}` : `Open this URL to authorize: ${link(url)}`); + + const spinner = ora( + 'Waiting for you to authorize in Supabase (up to 15 minutes; press Ctrl-C to cancel)' + ).start(); + try { + const connection = await pollForConnectionAsync(graphqlClient, account.id); + const organizations = await loadOrganizationsBestEffortAsync(graphqlClient, account.id); + spinner.succeed( + `Connected Supabase organization ${chalk.bold( + formatSupabaseOrganization(connection, organizations ?? undefined) + )}` + ); + return connection; + } catch (error) { + spinner.fail("Couldn't confirm the Supabase connection"); + throw error; + } +} + +export async function loadOrganizationsBestEffortAsync( + graphqlClient: ExpoGraphqlClient, + accountId: string +): Promise { + try { + return await SupabaseMutation.listSupabaseOrganizationsAsync(graphqlClient, accountId); + } catch { + return null; + } +} + +export async function pollForConnectionAsync( + graphqlClient: ExpoGraphqlClient, + accountId: string +): Promise { + const deadline = Date.now() + CONNECTION_POLL_TIMEOUT_MS; + for (;;) { + let connection: SupabaseConnectionData | null = null; + try { + connection = await SupabaseQuery.getSupabaseConnectionByAccountIdAsync( + graphqlClient, + accountId, + { useCache: false } + ); + } catch (error) { + Log.debug(`Polling for the Supabase connection failed, will retry: ${error}`); + } + if (connection) { + return connection; + } + if (Date.now() >= deadline) { + throw new Error( + 'Timed out waiting for the Supabase connection. If you authorized it in your browser, re-run `eas integrations:supabase:connect` — it will pick up the connection.' + ); + } + await new Promise(resolve => setTimeout(resolve, CONNECTION_POLL_INTERVAL_MS)); + } +} + +export async function resolvePublishableKeyAsync( + graphqlClient: ExpoGraphqlClient, + appId: string, + project: SupabaseProjectData +): Promise { + const spinner = ora('Waiting for the Supabase project to finish provisioning').start(); + const deadline = Date.now() + READINESS_POLL_TIMEOUT_MS; + // The server returns a null key while the project is still provisioning but throws for a real + // problem (revoked authorization, etc.). Tolerate a transient blip, but stop retrying for the + // full timeout once the errors are persistent — that isn't a provisioning delay. + let consecutiveErrors = 0; + for (;;) { + let key: string | null = null; + try { + key = await SupabaseMutation.fetchSupabasePublishableKeyAsync(graphqlClient, appId); + consecutiveErrors = 0; + } catch (error) { + consecutiveErrors += 1; + Log.debug(`Polling for the Supabase project readiness failed, will retry: ${error}`); + if (consecutiveErrors >= MAX_CONSECUTIVE_READINESS_ERRORS) { + spinner.fail("Couldn't reach the Supabase project"); + throw error; + } + } + if (key) { + spinner.succeed('Supabase project is ready'); + return key; + } + if (Date.now() >= deadline) { + spinner.fail('Supabase project did not finish provisioning in time'); + throw new Error( + `The Supabase project is still provisioning. Once it's healthy (check ${getSupabaseProjectDashboardUrl( + project + )}), re-run \`eas integrations:supabase:connect\` to finish writing the environment variables.` + ); + } + await new Promise(resolve => setTimeout(resolve, READINESS_POLL_INTERVAL_MS)); + } +} + +export async function resolveRegionAsync( + flagValue: string | undefined, + nonInteractive: boolean +): Promise { + if (flagValue) { + return flagValue; + } + if (nonInteractive) { + throw new Error( + 'A Supabase region is required in non-interactive mode. Pass --region (americas, emea, or apac). The region is permanent once the project is created.' + ); + } + return await selectAsync( + 'Select a Supabase region (permanent once the project is created)', + SUPABASE_REGION_CHOICES + ); +} + +export async function resolveOrganizationAsync( + graphqlClient: ExpoGraphqlClient, + accountId: string, + connection: SupabaseConnectionData, + organizationFlag: string | undefined, + nonInteractive: boolean, + preloadedOrganizations: SupabaseOrganizationData[] | null +): Promise { + if (organizationFlag) { + if (organizationFlag === connection.supabaseOrganizationSlug) { + return connection; + } + const organizations = + preloadedOrganizations ?? + (await SupabaseMutation.listSupabaseOrganizationsAsync(graphqlClient, accountId)); + if (!organizations.some(organization => organization.slug === organizationFlag)) { + throw new Error( + `Supabase organization ${chalk.bold( + organizationFlag + )} isn't one of your connected organizations (${organizations + .map(organization => organization.slug) + .join(', ')}).` + ); + } + return await SupabaseMutation.setSupabaseConnectionOrganizationAsync(graphqlClient, { + supabaseConnectionId: connection.id, + organizationSlug: organizationFlag, + }); + } + if (nonInteractive) { + return connection; + } + const organizations = + preloadedOrganizations ?? + (await SupabaseMutation.listSupabaseOrganizationsAsync(graphqlClient, accountId)); + if (organizations.length <= 1) { + return connection; + } + const chosen = await selectAsync( + 'Select the Supabase organization to use', + organizations.map(organization => ({ + title: `${organization.name} (${organization.slug})`, + value: organization.slug, + })) + ); + if (chosen === connection.supabaseOrganizationSlug) { + return connection; + } + return await SupabaseMutation.setSupabaseConnectionOrganizationAsync(graphqlClient, { + supabaseConnectionId: connection.id, + organizationSlug: chosen, + }); +} + diff --git a/packages/eas-cli/src/integrations/supabase/sdk.ts b/packages/eas-cli/src/integrations/supabase/sdk.ts new file mode 100644 index 0000000000..2f47934843 --- /dev/null +++ b/packages/eas-cli/src/integrations/supabase/sdk.ts @@ -0,0 +1,113 @@ +import spawnAsync from '@expo/spawn-async'; +import { ExpoConfig } from '@expo/config'; +import chalk from 'chalk'; + +import Log from '../../log'; +import { ora } from '../../ora'; +import { createOrModifyExpoConfigAsync } from '../../project/expoConfig'; + +export const SDK_PACKAGES = ['@supabase/supabase-js', 'react-native-url-polyfill', 'expo-sqlite']; +export const CONFIG_PLUGIN = 'expo-sqlite'; + +export type SdkInstallResult = + | { status: 'installed' } + | { status: 'failed' } + | { status: 'installed'; dynamicConfigGuidance: string }; + +export function getSpawnErrorOutput(error: unknown): string { + const { stdout, stderr } = (error ?? {}) as { stdout?: string; stderr?: string }; + return `${stdout ?? ''}${stderr ?? ''}`; +} + +export function extractDynamicConfigGuidance(output: string): string | null { + const marker = 'Cannot automatically write to dynamic config'; + const index = output.indexOf(marker); + if (index === -1) { + return null; + } + return output.slice(index).trim(); +} + +export function envForExpoInstall(): NodeJS.ProcessEnv { + const env = { ...process.env }; + delete env.EXPO_LOCAL; + delete env.EXPO_STAGING; + delete env.EXPO_UNIVERSE_DIR; + return env; +} + +export async function installSdkPackagesAsync( + projectDir: string, + jsonFlag: boolean +): Promise { + const spinner = jsonFlag ? null : ora('Installing the Supabase SDK packages').start(); + try { + await spawnAsync('npx', ['expo', 'install', ...SDK_PACKAGES], { + cwd: projectDir, + env: envForExpoInstall(), + }); + spinner?.succeed('Installed the Supabase SDK packages'); + return { status: 'installed' }; + } catch (error) { + const output = getSpawnErrorOutput(error); + Log.debug(output || error); + const dynamicConfigGuidance = extractDynamicConfigGuidance(output); + if (dynamicConfigGuidance) { + spinner?.warn( + 'Installed the Supabase SDK packages — add the config plugin to your app config' + ); + return { status: 'installed', dynamicConfigGuidance }; + } + spinner?.warn('Could not install the Supabase SDK packages'); + return { status: 'failed' }; + } +} + +export async function setupSdkAndConfigAsync( + projectDir: string, + exp: ExpoConfig, + jsonFlag: boolean +): Promise { + const installResult = await installSdkPackagesAsync(projectDir, jsonFlag); + const manualSteps: string[] = []; + if (installResult.status === 'failed') { + manualSteps.push( + `The Supabase SDK packages didn't install. Run npx expo install ${SDK_PACKAGES.join(' ')} from your project directory.` + ); + } + if (installResult.status === 'installed' && 'dynamicConfigGuidance' in installResult) { + manualSteps.push(installResult.dynamicConfigGuidance); + } else { + const pluginManualStep = await addConfigPluginAsync(projectDir, exp); + if (pluginManualStep) { + manualSteps.push(pluginManualStep); + } + } + return manualSteps; +} + +export async function addConfigPluginAsync( + projectDir: string, + exp: ExpoConfig +): Promise { + const plugins = exp.plugins ?? []; + const alreadyAdded = plugins.some(p => (Array.isArray(p) ? p[0] : p) === CONFIG_PLUGIN); + if (alreadyAdded) { + Log.withTick(`Config plugin ${chalk.bold(CONFIG_PLUGIN)} is already configured`); + return null; + } + + const modification = await createOrModifyExpoConfigAsync( + projectDir, + { plugins: [...plugins, CONFIG_PLUGIN] }, + { skipSDKVersionRequirement: true } + ); + if (modification.type === 'success') { + Log.withTick(`Added the ${chalk.bold(CONFIG_PLUGIN)} config plugin`); + return null; + } + if (modification.type === 'warn') { + return `${modification.message} Add ${JSON.stringify(CONFIG_PLUGIN)} to the "plugins" array in your app config.`; + } + return `Add ${JSON.stringify(CONFIG_PLUGIN)} to the "plugins" array in your app config.`; +} diff --git a/packages/eas-cli/src/utils/__tests__/pollForBackgroundJobReceiptAsync-test.ts b/packages/eas-cli/src/utils/__tests__/pollForBackgroundJobReceiptAsync-test.ts index 4db64112d1..1c0f1f2315 100644 --- a/packages/eas-cli/src/utils/__tests__/pollForBackgroundJobReceiptAsync-test.ts +++ b/packages/eas-cli/src/utils/__tests__/pollForBackgroundJobReceiptAsync-test.ts @@ -161,4 +161,39 @@ describe(pollForBackgroundJobReceiptAsync, () => { }) ).rejects.toThrow('Background job timed out.'); }); + + it('tolerates consecutive CombinedError fetches before NULL_RECEIPT', async () => { + const { CombinedError } = jest.requireActual('@urql/core') as typeof import('@urql/core'); + const graphqlClient = instance(mock()); + + const receiptId = '123'; + const backgroundJobReceiptInProgress: BackgroundJobReceiptDataFragment = { + id: receiptId, + state: BackgroundJobState.InProgress, + willRetry: false, + tries: 0, + resultType: BackgroundJobResultType.Void, + } as any; + const backgroundJobReceiptSuccess: BackgroundJobReceiptDataFragment = { + id: receiptId, + state: BackgroundJobState.Success, + willRetry: false, + tries: 0, + resultType: BackgroundJobResultType.Void, + } as any; + + jest + .mocked(BackgroundJobReceiptQuery.byIdAsync) + .mockRejectedValueOnce(new CombinedError({ networkError: new Error('blip') })) + .mockRejectedValueOnce(new CombinedError({ networkError: new Error('blip') })) + .mockResolvedValueOnce(backgroundJobReceiptSuccess); + + const result = await pollForBackgroundJobReceiptAsync( + graphqlClient, + backgroundJobReceiptInProgress, + { pollInterval: 50, maxConsecutiveFetchErrors: 3 } + ); + + expect(result).toEqual(backgroundJobReceiptSuccess); + }); }); diff --git a/packages/eas-cli/src/utils/pollForBackgroundJobReceiptAsync.ts b/packages/eas-cli/src/utils/pollForBackgroundJobReceiptAsync.ts index 43b187d541..c238405a35 100644 --- a/packages/eas-cli/src/utils/pollForBackgroundJobReceiptAsync.ts +++ b/packages/eas-cli/src/utils/pollForBackgroundJobReceiptAsync.ts @@ -71,6 +71,8 @@ export function pollForBackgroundJobReceiptAsync( options?: { onBackgroundJobReceiptPollError?: BackgroundJobPollErrorCondition; pollInterval?: number; + maxChecks?: number; + maxConsecutiveFetchErrors?: number; } ): Promise; export async function pollForBackgroundJobReceiptAsync( @@ -79,10 +81,15 @@ export async function pollForBackgroundJobReceiptAsync( options?: { onBackgroundJobReceiptPollError?: BackgroundJobPollErrorCondition; pollInterval?: number; + maxChecks?: number; + maxConsecutiveFetchErrors?: number; } ): Promise { + const maxChecks = options?.maxChecks ?? 90; + const maxConsecutiveFetchErrors = options?.maxConsecutiveFetchErrors ?? 0; return await new Promise((resolve, reject) => { let numChecks = 0; + let consecutiveFetchErrors = 0; const intervalHandle = setIntervalAsync(async function pollForDeletionFinishedAsync() { function failBackgroundDeletion(error: BackgroundJobReceiptPollError): void { void clearIntervalAsync(intervalHandle); @@ -101,6 +108,11 @@ export async function pollForBackgroundJobReceiptAsync( resolve(null); return; } + consecutiveFetchErrors += 1; + if (consecutiveFetchErrors <= maxConsecutiveFetchErrors) { + numChecks++; + return; + } } failBackgroundDeletion( new BackgroundJobReceiptPollError({ @@ -110,6 +122,8 @@ export async function pollForBackgroundJobReceiptAsync( return; } + consecutiveFetchErrors = 0; + // job failed and will not retry if (receipt.state === BackgroundJobState.Failure && !receipt.willRetry) { failBackgroundDeletion( @@ -121,10 +135,10 @@ export async function pollForBackgroundJobReceiptAsync( return; } - // all else fails, stop polling after 90 checks. This should only happen if there's an + // all else fails, stop polling after maxChecks. This should only happen if there's an // issue with receipts not setting `willRetry` to false when they fail within a reasonable // amount of time. - if (numChecks > 90) { + if (numChecks > maxChecks) { failBackgroundDeletion( new BackgroundJobReceiptPollError({ errorType: BackgroundJobReceiptPollErrorType.TIMEOUT,