diff --git a/CHANGELOG.md b/CHANGELOG.md index 38c07a7ba8..c909c1bb3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ This is the log of notable changes to EAS CLI and related packages. - [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-cli] Add `eas integrations:supabase:dashboard` command. ([#4107](https://github.com/expo/eas-cli/pull/4107) by [@gwdp](https://github.com/gwdp)) +- [eas-cli] Add `eas integrations:supabase:disconnect` command. ([#4108](https://github.com/expo/eas-cli/pull/4108) 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__/disconnect.test.ts b/packages/eas-cli/src/commands/integrations/supabase/__tests__/disconnect.test.ts new file mode 100644 index 0000000000..2e65e2c28d --- /dev/null +++ b/packages/eas-cli/src/commands/integrations/supabase/__tests__/disconnect.test.ts @@ -0,0 +1,135 @@ +import { getMockOclifConfig } from '../../../../__tests__/commands/utils'; +import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants'; +import { SupabaseMutation } from '../../../../graphql/mutations/SupabaseMutation'; +import { SupabaseQuery } from '../../../../graphql/queries/SupabaseQuery'; +import { SupabaseProjectData } from '../../../../graphql/types/SupabaseConnection'; +import Log from '../../../../log'; +import { confirmAsync } from '../../../../prompts'; +import { printJsonOnlyOutput } from '../../../../utils/json'; +import IntegrationsSupabaseDisconnect from '../disconnect'; + +jest.mock('../../../../graphql/queries/SupabaseQuery'); +jest.mock('../../../../graphql/mutations/SupabaseMutation'); +jest.mock('../../../../prompts'); +jest.mock('../../../../log'); +jest.mock('../../../../utils/json'); +jest.mock('../../../../ora', () => ({ + ora: jest.fn(), +})); + +import { ora } from '../../../../ora'; + +function mockOraSpinner(): { + start: jest.Mock; + succeed: jest.Mock; + fail: jest.Mock; +} { + const spinner = { + start: jest.fn().mockReturnThis(), + succeed: jest.fn().mockReturnThis(), + fail: jest.fn().mockReturnThis(), + }; + jest.mocked(ora).mockReturnValue(spinner as never); + return spinner; +} + +describe(IntegrationsSupabaseDisconnect, () => { + const graphqlClient = {} as ExpoGraphqlClient; + const mockConfig = getMockOclifConfig(); + + 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[]): IntegrationsSupabaseDisconnect { + const command = new IntegrationsSupabaseDisconnect(argv, mockConfig); + jest.spyOn(command as any, 'getContextAsync').mockReturnValue({ + privateProjectConfig: { + projectId: testProjectId, + exp: { slug: 'testapp' }, + }, + loggedIn: { graphqlClient }, + } as never); + return command; + } + + beforeEach(() => { + jest.resetAllMocks(); + mockOraSpinner(); + jest.spyOn(Log, 'log').mockImplementation(() => {}); + jest.spyOn(Log, 'warn').mockImplementation(() => {}); + jest.spyOn(Log, 'error').mockImplementation(() => {}); + jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {}); + jest.spyOn(Log, 'newLine').mockImplementation(() => {}); + jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(mockProject); + jest.mocked(SupabaseMutation.deleteSupabaseProjectAsync).mockResolvedValue(mockProject.id); + jest.mocked(confirmAsync).mockResolvedValue(true); + }); + + it('removes the project link after confirmation', async () => { + await createCommand([]).runAsync(); + + expect(SupabaseMutation.deleteSupabaseProjectAsync).toHaveBeenCalledWith( + graphqlClient, + mockProject.id + ); + }); + + it('skips confirmation with --yes', async () => { + await createCommand(['--yes']).runAsync(); + + expect(confirmAsync).not.toHaveBeenCalled(); + expect(SupabaseMutation.deleteSupabaseProjectAsync).toHaveBeenCalled(); + }); + + it('cancels when confirmation is declined', async () => { + jest.mocked(confirmAsync).mockResolvedValue(false); + + await createCommand([]).runAsync(); + + expect(SupabaseMutation.deleteSupabaseProjectAsync).not.toHaveBeenCalled(); + }); + + it('prints json when no project is linked', async () => { + jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null); + + await createCommand(['--json']).runAsync(); + + expect(printJsonOnlyOutput).toHaveBeenCalledWith({ id: null }); + }); + + it('warns when no project is linked', async () => { + jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null); + + await createCommand([]).runAsync(); + + expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('No Supabase project')); + expect(SupabaseMutation.deleteSupabaseProjectAsync).not.toHaveBeenCalled(); + }); + + it('prints json with id and ref on success', async () => { + await createCommand(['--json', '--yes']).runAsync(); + + expect(printJsonOnlyOutput).toHaveBeenCalledWith({ + id: mockProject.id, + ref: mockProject.supabaseProjectRef, + }); + }); + + it('fails the spinner and rethrows when delete fails', async () => { + jest + .mocked(SupabaseMutation.deleteSupabaseProjectAsync) + .mockRejectedValue(new Error('delete failed')); + const spinner = mockOraSpinner(); + + await expect(createCommand(['--yes']).runAsync()).rejects.toThrow('delete failed'); + expect(spinner.fail).toHaveBeenCalledWith('Failed to remove Supabase project link'); + }); +}); diff --git a/packages/eas-cli/src/commands/integrations/supabase/disconnect.ts b/packages/eas-cli/src/commands/integrations/supabase/disconnect.ts new file mode 100644 index 0000000000..5f5f75fbf2 --- /dev/null +++ b/packages/eas-cli/src/commands/integrations/supabase/disconnect.ts @@ -0,0 +1,113 @@ +import { Flags } from '@oclif/core'; +import chalk from 'chalk'; + +import EasCommand from '../../../commandUtils/EasCommand'; +import { + EasNonInteractiveAndJsonFlags, + resolveNonInteractiveAndJsonFlags, +} from '../../../commandUtils/flags'; +import { + formatSupabaseProject, + formatSupabaseProjectLabel, + logNoSupabaseProject, +} from '../../../commandUtils/supabase'; +import { SupabaseMutation } from '../../../graphql/mutations/SupabaseMutation'; +import { SupabaseQuery } from '../../../graphql/queries/SupabaseQuery'; +import Log from '../../../log'; +import { ora } from '../../../ora'; +import { confirmAsync } from '../../../prompts'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; + +export default class IntegrationsSupabaseDisconnect extends EasCommand { + static override description = + "remove this app's Supabase project link from EAS (keeps the Supabase project; does not delete EXPO_PUBLIC_SUPABASE_* env vars)"; + + static override examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --yes', + ]; + + static override flags = { + ...EasNonInteractiveAndJsonFlags, + yes: Flags.boolean({ + char: 'y', + description: 'Skip confirmation prompt', + default: false, + }), + }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectConfig, + }; + + async runAsync(): Promise { + const { flags } = await this.parse(IntegrationsSupabaseDisconnect); + const { yes } = flags; + const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags); + if (jsonFlag) { + enableJsonOutput(); + } + + const { + privateProjectConfig: { projectId, exp }, + loggedIn: { graphqlClient }, + } = await this.getContextAsync(IntegrationsSupabaseDisconnect, { + nonInteractive, + withServerSideEnvironment: null, + }); + + const project = await SupabaseQuery.getSupabaseProjectByAppIdAsync(graphqlClient, projectId); + if (!project) { + if (jsonFlag) { + printJsonOnlyOutput({ id: null }); + } else { + logNoSupabaseProject(exp.slug); + } + return; + } + + if (!jsonFlag) { + Log.addNewLineIfNone(); + Log.log(formatSupabaseProject(project)); + Log.newLine(); + } + + if (!nonInteractive && !yes) { + const confirmed = await confirmAsync({ + message: + 'Remove this Supabase project link from EAS servers? This does not delete the project on Supabase.', + }); + if (!confirmed) { + Log.warn('Canceled removal of the Supabase project link.'); + return; + } + } else if (!jsonFlag) { + Log.warn( + 'Removing the Supabase project link from EAS servers. This does not delete the project on Supabase.' + ); + } + + const spinner = jsonFlag ? null : ora('Removing Supabase project link').start(); + try { + await SupabaseMutation.deleteSupabaseProjectAsync(graphqlClient, project.id); + spinner?.succeed( + `Removed Supabase project ${chalk.bold(formatSupabaseProjectLabel(project))} from EAS servers` + ); + } catch (error) { + spinner?.fail('Failed to remove Supabase project link'); + throw error; + } + + if (jsonFlag) { + printJsonOnlyOutput({ id: project.id, ref: project.supabaseProjectRef }); + return; + } + + Log.newLine(); + Log.log( + `The ${chalk.bold('EXPO_PUBLIC_SUPABASE_URL')} and ${chalk.bold( + 'EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY' + )} values remain in ${chalk.bold('.env.local')} and in your EAS environment variables. Remove them if you no longer need them.` + ); + } +}