diff --git a/CHANGELOG.md b/CHANGELOG.md index 00ce26b7d7..b56e34ccd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features - [eas-cli] Add `eas submit:list`, `eas submit:view`, `eas submit:retry`, `eas submit:cancel`, and `eas submit:status` commands; list and view include the runtime version and fingerprint of the submitted build, and status shows the live App Store version and TestFlight build states cross-referenced with EAS submissions (Google Play app status is not available through EAS yet). ([#4134](https://github.com/expo/eas-cli/pull/4134) by [@brentvatne](https://github.com/brentvatne)) +- [eas-cli] Add Supabase integration foundation: GraphQL client, shared integration helpers, and provisioning utilities. ([#4130](https://github.com/expo/eas-cli/pull/4130) by [@gwdp](https://github.com/gwdp)) - [eas-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/commandUtils/__tests__/supabase-test.ts b/packages/eas-cli/src/commandUtils/__tests__/supabase-test.ts new file mode 100644 index 0000000000..b2880bf80a --- /dev/null +++ b/packages/eas-cli/src/commandUtils/__tests__/supabase-test.ts @@ -0,0 +1,164 @@ +import { + SupabaseConnectionData, + SupabaseOrganizationData, + SupabaseProjectData, +} from '../../graphql/types/SupabaseConnection'; +import Log from '../../log'; +import { + formatSupabaseOrganization, + formatSupabaseProject, + formatSupabaseProjectLabel, + getSupabaseProjectDashboardUrl, + logNoSupabaseProject, + parseSupabaseProjectRef, +} from '../supabase'; + +jest.mock('../../log', () => ({ + __esModule: true, + default: { + warn: jest.fn(), + log: jest.fn(), + }, + link: jest.fn((url: string) => url), +})); + +describe('commandUtils/supabase', () => { + const project: SupabaseProjectData = { + id: 'project-1', + supabaseProjectRef: 'abcdefghijklmnop', + supabaseProjectName: 'Demo App', + supabaseProjectUrl: 'https://abcdefghijklmnop.supabase.co', + supabaseRegion: 'us-east-1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + it('getSupabaseProjectDashboardUrl encodes the project ref', () => { + expect(getSupabaseProjectDashboardUrl({ supabaseProjectRef: 'abc/def' })).toBe( + 'https://supabase.com/dashboard/project/abc%2Fdef' + ); + }); + + it('formatSupabaseOrganization prefers a distinct name', () => { + const connection: Pick< + SupabaseConnectionData, + 'supabaseOrganizationSlug' | 'supabaseOrganizationName' + > = { + supabaseOrganizationSlug: 'org-slug', + supabaseOrganizationName: 'Org Name', + }; + expect(formatSupabaseOrganization(connection)).toBe('Org Name (org-slug)'); + }); + + it('formatSupabaseOrganization falls back to live org name', () => { + const connection = { + supabaseOrganizationSlug: 'org-slug', + supabaseOrganizationName: null as unknown as string, + }; + const organizations: SupabaseOrganizationData[] = [ + { id: '1', slug: 'org-slug', name: 'Live Name' }, + ]; + expect(formatSupabaseOrganization(connection, organizations)).toBe('Live Name (org-slug)'); + }); + + it('formatSupabaseOrganization returns slug when name matches or is missing', () => { + expect( + formatSupabaseOrganization({ + supabaseOrganizationSlug: 'same', + supabaseOrganizationName: 'same', + }) + ).toBe('same'); + expect( + formatSupabaseOrganization({ + supabaseOrganizationSlug: 'only-slug', + supabaseOrganizationName: '', + }) + ).toBe('only-slug'); + }); + + it('formatSupabaseProjectLabel prefers a distinct name', () => { + expect(formatSupabaseProjectLabel(project)).toBe('Demo App (abcdefghijklmnop)'); + }); + + it('formatSupabaseProjectLabel returns ref when name matches or is missing', () => { + expect( + formatSupabaseProjectLabel({ + supabaseProjectRef: 'ref', + supabaseProjectName: 'ref', + }) + ).toBe('ref'); + expect( + formatSupabaseProjectLabel({ + supabaseProjectRef: 'ref', + supabaseProjectName: '', + }) + ).toBe('ref'); + }); + + it('formatSupabaseProject includes key fields', () => { + const formatted = formatSupabaseProject(project); + expect(formatted).toContain('Demo App'); + expect(formatted).toContain('abcdefghijklmnop'); + expect(formatted).toContain('https://abcdefghijklmnop.supabase.co'); + expect(formatted).toContain('us-east-1'); + expect(formatted).toContain('https://supabase.com/dashboard/project/abcdefghijklmnop'); + }); + + it('logNoSupabaseProject warns with the project name', () => { + logNoSupabaseProject('my-app'); + expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('my-app')); + }); +}); + +describe(parseSupabaseProjectRef, () => { + it('accepts a bare reference ID', () => { + expect(parseSupabaseProjectRef('jfurmbuioogljwsqwnpd')).toBe('jfurmbuioogljwsqwnpd'); + expect(parseSupabaseProjectRef(' jfurmbuioogljwsqwnpd ')).toBe('jfurmbuioogljwsqwnpd'); + }); + + it('accepts a dashboard URL', () => { + expect( + parseSupabaseProjectRef('https://supabase.com/dashboard/project/kwdfdxdzurxigtbtwddj') + ).toBe('kwdfdxdzurxigtbtwddj'); + expect( + parseSupabaseProjectRef( + 'https://supabase.com/dashboard/project/kwdfdxdzurxigtbtwddj/settings/general' + ) + ).toBe('kwdfdxdzurxigtbtwddj'); + }); + + it('accepts a project API URL', () => { + expect(parseSupabaseProjectRef('https://kwdfdxdzurxigtbtwddj.supabase.co')).toBe( + 'kwdfdxdzurxigtbtwddj' + ); + }); + + it('lowercases the reference ID, which the server matches exactly', () => { + expect(parseSupabaseProjectRef('JFURMBUIOOGLJWSQWNPD')).toBe('jfurmbuioogljwsqwnpd'); + expect( + parseSupabaseProjectRef('https://supabase.com/dashboard/project/KWDFDXDZURXIGTBTWDDJ') + ).toBe('kwdfdxdzurxigtbtwddj'); + }); + + it('rejects a project name with guidance', () => { + expect(() => parseSupabaseProjectRef('@testuser/test-app-personal-661c52f1')).toThrow( + /not a Supabase project reference ID/ + ); + expect(() => parseSupabaseProjectRef('@testuser/test-app-personal-661c52f1')).toThrow( + /Project Settings/ + ); + }); + + it('rejects an unrelated URL and an empty value', () => { + expect(() => parseSupabaseProjectRef('https://example.com/foo')).toThrow( + /not a Supabase project reference ID/ + ); + expect(() => parseSupabaseProjectRef(' ')).toThrow(/No Supabase project given/); + }); + + it('treats a malformed URL as a non-ref and rejects it', () => { + expect(() => parseSupabaseProjectRef('https://[')).toThrow( + /not a Supabase project reference ID/ + ); + }); +}); diff --git a/packages/eas-cli/src/commandUtils/supabase.ts b/packages/eas-cli/src/commandUtils/supabase.ts new file mode 100644 index 0000000000..949c6393da --- /dev/null +++ b/packages/eas-cli/src/commandUtils/supabase.ts @@ -0,0 +1,96 @@ +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)}`; +} + +function extractProjectRefFromUrl(value: string): string | null { + if (!/^https?:\/\//i.test(value)) { + return null; + } + let url: URL; + try { + url = new URL(value); + } catch { + return null; + } + const projectHost = url.hostname.match(/^([a-z0-9]+)\.supabase\./i); + if (projectHost) { + return projectHost[1]; + } + const dashboardPath = url.pathname.match(/\/dashboard\/project\/([^/]+)/); + if (dashboardPath) { + return decodeURIComponent(dashboardPath[1]); + } + return null; +} + +/** + * Accepts whatever a user is likely to have on hand: the bare reference ID, the dashboard URL, or + * the project API URL. Supabase labels the ref "Reference ID" under Project Settings → General. + */ +export function parseSupabaseProjectRef(input: string): string { + const value = input.trim(); + if (!value) { + throw new Error( + 'No Supabase project given. Pass the project reference ID or its dashboard URL, for example --link jfurmbuioogljwsqwnpd.' + ); + } + const ref = extractProjectRefFromUrl(value) ?? value; + if (!/^[a-z0-9]+$/i.test(ref)) { + throw new Error( + `"${input}" is not a Supabase project reference ID. Supabase shows it as "Reference ID" under Project Settings → General; you can also paste the dashboard URL (https://supabase.com/dashboard/project/). Note that a project name is not a reference ID.` + ); + } + // Refs are lowercase, and the server matches them exactly. + return ref.toLowerCase(); +} + +/** Prefer human-readable org name; Supabase slugs are often opaque ids like `ycjmzsygzfkryaazoitp`. */ +export function formatSupabaseOrganization( + connection: Pick, + 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/environments/__tests__/resolve-test.ts b/packages/eas-cli/src/environments/__tests__/resolve-test.ts new file mode 100644 index 0000000000..c1b6e20f97 --- /dev/null +++ b/packages/eas-cli/src/environments/__tests__/resolve-test.ts @@ -0,0 +1,154 @@ +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery'; +import { confirmAsync } from '../../prompts'; +import { DEFAULT_ENVIRONMENTS } from '../defaults'; +import { parseEnvironmentFlag, resolveTargetEnvironmentsAsync } from '../resolve'; + +jest.mock('../../graphql/queries/EnvironmentVariablesQuery'); +jest.mock('../../prompts'); + +describe('parseEnvironmentFlag', () => { + it('returns null when undefined', () => { + expect(parseEnvironmentFlag(undefined)).toBeNull(); + }); + + it('throws on empty or whitespace-only values', () => { + expect(() => parseEnvironmentFlag('')).toThrow(/Pass at least one EAS environment/); + expect(() => parseEnvironmentFlag(' ')).toThrow(/Pass at least one EAS environment/); + expect(() => parseEnvironmentFlag(',,,')).toThrow(/Pass at least one EAS environment/); + }); + + it('dedupes and normalizes values', () => { + expect(parseEnvironmentFlag(' Preview ,production, preview ')).toEqual([ + 'preview', + 'production', + ]); + }); + + it('throws on invalid environment names', () => { + expect(() => parseEnvironmentFlag('ab')).toThrow(/Invalid EAS environment/); + expect(() => parseEnvironmentFlag('Bad Env')).toThrow(/Invalid EAS environment/); + }); +}); + +describe('resolveTargetEnvironmentsAsync', () => { + const client = {} as ExpoGraphqlClient; + const options = { + defaultEnvironments: DEFAULT_ENVIRONMENTS, + cancelMessage: (knownEnvironments: string) => `Canceled. Known: ${knownEnvironments}.`, + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('wraps environments query failures in a generic error that keeps the cause', async () => { + const cause = new Error('network down'); + jest + .mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync) + .mockRejectedValue(cause); + + await expect( + resolveTargetEnvironmentsAsync(client, 'app-1', ['preview'], true, options) + ).rejects.toThrow( + expect.objectContaining({ + message: expect.stringMatching(/Failed to fetch available environments/), + cause, + }) + ); + }); + + it('keeps non-Error query failures as the cause', async () => { + jest + .mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync) + .mockRejectedValue('network down'); + + await expect( + resolveTargetEnvironmentsAsync(client, 'app-1', ['preview'], true, options) + ).rejects.toThrow( + expect.objectContaining({ + message: expect.stringMatching(/Failed to fetch available environments/), + cause: 'network down', + }) + ); + }); + + it('falls back to default environments when none are known', async () => { + jest + .mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync) + .mockResolvedValue([]); + + await expect( + resolveTargetEnvironmentsAsync(client, 'app-1', ['preview'], true, options) + ).resolves.toEqual(['preview']); + }); + + it('returns requested environments when all are known', async () => { + jest + .mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync) + .mockResolvedValue(['production', 'preview']); + + await expect( + resolveTargetEnvironmentsAsync(client, 'app-1', ['preview'], true, options) + ).resolves.toEqual(['preview']); + }); + + it('throws in non-interactive mode for unknown default environments', async () => { + jest + .mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync) + .mockResolvedValue(['production']); + + await expect( + resolveTargetEnvironmentsAsync(client, 'app-1', ['preview'], true, options) + ).rejects.toThrow(/EAS environment\(s\) not found/); + }); + + it('throws in non-interactive mode for unknown custom environments', async () => { + jest + .mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync) + .mockResolvedValue(['production']); + + await expect( + resolveTargetEnvironmentsAsync(client, 'app-1', ['staging'], true, options) + ).rejects.toThrow(/Custom environments require an Enterprise plan/); + }); + + it('confirms unknown environments interactively', async () => { + jest + .mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync) + .mockResolvedValue(['production']); + jest.mocked(confirmAsync).mockResolvedValue(true); + + await expect( + resolveTargetEnvironmentsAsync(client, 'app-1', ['preview', 'development'], false, options) + ).resolves.toEqual(['preview', 'development']); + expect(confirmAsync).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('environments') }) + ); + }); + + it('cancels when interactive confirmation is declined', async () => { + jest + .mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync) + .mockResolvedValue(['production']); + jest.mocked(confirmAsync).mockResolvedValue(false); + + await expect( + resolveTargetEnvironmentsAsync(client, 'app-1', ['preview'], false, options) + ).rejects.toThrow('Canceled. Known: production.'); + }); + + it('mentions enterprise plan for custom environments interactively', async () => { + jest + .mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync) + .mockResolvedValue(DEFAULT_ENVIRONMENTS); + jest.mocked(confirmAsync).mockResolvedValue(true); + + await resolveTargetEnvironmentsAsync(client, 'app-1', ['enterprise-env'], false, options); + expect(confirmAsync).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Custom environments require an Enterprise plan'), + }) + ); + }); +}); diff --git a/packages/eas-cli/src/environments/__tests__/variables-test.ts b/packages/eas-cli/src/environments/__tests__/variables-test.ts new file mode 100644 index 0000000000..13e377d81a --- /dev/null +++ b/packages/eas-cli/src/environments/__tests__/variables-test.ts @@ -0,0 +1,426 @@ +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { 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'; +import { EnvVar, upsertEnvVarAsync, upsertEnvVarsSequentiallyAsync } from '../variables'; + +jest.mock('../../graphql/mutations/EnvironmentVariableMutation'); +jest.mock('../../graphql/queries/EnvironmentVariablesQuery'); +jest.mock('../../prompts'); +jest.mock('../../log'); + +const client = {} as ExpoGraphqlClient; + +const envVar: EnvVar = { + name: 'EXPO_PUBLIC_EXAMPLE_URL', + value: 'https://new.example.test', + visibility: EnvironmentVariableVisibility.Public, +}; + +const REPLACE = { mode: 'replaceOtherEnvironments' } as const; +const KEEP = { + mode: 'keepOtherEnvironments', + moveConfirmMessage: (name: string, environments: string) => `Move ${name} for ${environments}?`, +} as const; + +function mockRows( + rows: { + id: string; + environments: string[] | null; + value?: string; + visibility?: EnvironmentVariableVisibility; + }[], + { includeAccountScoped = false }: { includeAccountScoped?: boolean } = {} +): void { + const projectRows = rows.map(row => ({ + id: row.id, + scope: EnvironmentVariableScope.Project, + environments: row.environments, + value: row.value ?? 'old', + visibility: row.visibility ?? EnvironmentVariableVisibility.Public, + })); + const all = includeAccountScoped + ? [ + ...projectRows, + { + id: 'account-row', + scope: EnvironmentVariableScope.Shared, + environments: ['production'], + value: 'shared', + }, + ] + : projectRows; + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue(all as never); +} + +beforeEach(() => { + jest.resetAllMocks(); + jest.mocked(EnvironmentVariableMutation.createForAppAsync).mockResolvedValue({} as never); + jest.mocked(EnvironmentVariableMutation.updateAsync).mockResolvedValue({} as never); + jest.mocked(EnvironmentVariableMutation.deleteAsync).mockResolvedValue({} as never); +}); + +describe('upsertEnvVarAsync, no existing variable', () => { + it.each([ + ['replaceOtherEnvironments', REPLACE], + ['keepOtherEnvironments', KEEP], + ])('creates without prompting in %s mode', async (_name, options) => { + mockRows([]); + + await expect( + upsertEnvVarAsync(client, 'app-1', envVar, ['production'], true, false, options) + ).resolves.toBe(true); + expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith( + client, + expect.objectContaining({ value: envVar.value, environments: ['production'] }), + 'app-1' + ); + expect(confirmAsync).not.toHaveBeenCalled(); + }); + + it('ignores account-scoped variables of the same name', async () => { + mockRows([], { includeAccountScoped: true }); + + await expect( + upsertEnvVarAsync(client, 'app-1', envVar, ['production'], true, false, REPLACE) + ).resolves.toBe(true); + expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled(); + expect(EnvironmentVariableMutation.deleteAsync).not.toHaveBeenCalled(); + }); +}); + +describe('upsertEnvVarAsync in replaceOtherEnvironments mode', () => { + it('reuses the first row and deletes the rest', async () => { + mockRows([ + { id: 'keeper', environments: ['production'] }, + { id: 'extra', environments: ['preview'] }, + ]); + + await expect( + upsertEnvVarAsync(client, 'app-1', envVar, ['production', 'preview'], true, true, REPLACE) + ).resolves.toBe(true); + expect(EnvironmentVariableMutation.deleteAsync).toHaveBeenCalledWith(client, 'extra'); + expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith( + client, + expect.objectContaining({ + id: 'keeper', + value: envVar.value, + environments: ['production', 'preview'], + }) + ); + expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled(); + }); + + it('takes over environments it was not asked to write', async () => { + mockRows([{ id: 'wide', environments: ['production', 'preview', 'development'] }]); + + await expect( + upsertEnvVarAsync(client, 'app-1', envVar, ['production'], true, true, REPLACE) + ).resolves.toBe(true); + expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith( + client, + expect.objectContaining({ id: 'wide', environments: ['production'] }) + ); + }); + + it('deletes a row whose environment list is null', async () => { + mockRows([ + { id: 'keeper', environments: ['production'] }, + { id: 'no-envs', environments: null }, + ]); + + await expect( + upsertEnvVarAsync(client, 'app-1', envVar, ['production'], true, true, REPLACE) + ).resolves.toBe(true); + expect(EnvironmentVariableMutation.deleteAsync).toHaveBeenCalledWith(client, 'no-envs'); + }); + + it('prompts with the multiple-variables message when extra rows exist', async () => { + mockRows([ + { id: 'keeper', environments: ['production'] }, + { id: 'extra', environments: ['preview'] }, + ]); + jest.mocked(confirmAsync).mockResolvedValue(true); + + await expect( + upsertEnvVarAsync(client, 'app-1', envVar, ['production'], false, false, REPLACE) + ).resolves.toBe(true); + expect(confirmAsync).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('multiple') }) + ); + }); + + it('falls back to other-environments wording when extras have no environments', async () => { + mockRows([ + { id: 'keeper', environments: null }, + { id: 'extra', environments: null }, + ]); + jest.mocked(confirmAsync).mockResolvedValue(true); + + await expect( + upsertEnvVarAsync(client, 'app-1', envVar, ['production'], false, false, REPLACE) + ).resolves.toBe(true); + expect(confirmAsync).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('other environments') }) + ); + }); + + it('prompts with the single-variable message when only one row exists', async () => { + mockRows([{ id: 'only', environments: ['production'] }]); + jest.mocked(confirmAsync).mockResolvedValue(true); + + await expect( + upsertEnvVarAsync(client, 'app-1', envVar, ['production'], false, false, REPLACE) + ).resolves.toBe(true); + expect(confirmAsync).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('Overwrite it?') }) + ); + }); +}); + +describe('upsertEnvVarAsync in keepOtherEnvironments mode', () => { + it('shrinks an overlapping row and creates a new one', async () => { + mockRows([{ id: 'shared', environments: ['production', 'preview'] }]); + + await expect( + upsertEnvVarAsync(client, 'app-1', envVar, ['preview'], true, true, KEEP) + ).resolves.toBe(true); + expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(client, { + id: 'shared', + environments: ['production'], + }); + expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith( + client, + expect.objectContaining({ value: envVar.value, environments: ['preview'] }), + 'app-1' + ); + }); + + it('reuses a row the target environments fully cover', async () => { + mockRows([{ id: 'preview-only', environments: ['preview'] }]); + + await expect( + upsertEnvVarAsync(client, 'app-1', envVar, ['preview', 'development'], true, true, KEEP) + ).resolves.toBe(true); + expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith( + client, + expect.objectContaining({ id: 'preview-only', environments: ['preview', 'development'] }) + ); + expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled(); + }); + + it('leaves non-overlapping rows completely alone', async () => { + mockRows([ + { id: 'production-only', environments: ['production'] }, + { id: 'no-envs', environments: null }, + ]); + + await expect( + upsertEnvVarAsync(client, 'app-1', envVar, ['preview'], true, false, KEEP) + ).resolves.toBe(true); + expect(EnvironmentVariableMutation.deleteAsync).not.toHaveBeenCalled(); + expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled(); + expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled(); + expect(confirmAsync).not.toHaveBeenCalled(); + }); + + it('uses the caller move message when a row loses only some environments', async () => { + mockRows([{ id: 'shared', environments: ['production', 'preview'] }]); + jest.mocked(confirmAsync).mockResolvedValue(true); + + await expect( + upsertEnvVarAsync(client, 'app-1', envVar, ['preview'], false, false, KEEP) + ).resolves.toBe(true); + expect(confirmAsync).toHaveBeenCalledWith( + expect.objectContaining({ message: `Move ${envVar.name} for preview?` }) + ); + }); + + it('uses the generic message when no row loses environments', async () => { + mockRows([{ id: 'exact', environments: ['preview'] }]); + jest.mocked(confirmAsync).mockResolvedValue(true); + + await expect( + upsertEnvVarAsync(client, 'app-1', envVar, ['preview'], false, false, KEEP) + ).resolves.toBe(true); + expect(confirmAsync).toHaveBeenCalledWith( + expect.objectContaining({ + message: `EAS already has ${envVar.name} for preview. Overwrite it?`, + }) + ); + }); + + it('falls back to the generic message when no move message is supplied', async () => { + mockRows([{ id: 'shared', environments: ['production', 'preview'] }]); + jest.mocked(confirmAsync).mockResolvedValue(true); + + await expect( + upsertEnvVarAsync(client, 'app-1', envVar, ['preview'], false, false, { + mode: 'keepOtherEnvironments', + }) + ).resolves.toBe(true); + expect(confirmAsync).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('Overwrite it?') }) + ); + }); +}); + +describe('upsertEnvVarAsync overwrite gate', () => { + it.each([ + ['replaceOtherEnvironments', REPLACE], + ['keepOtherEnvironments', KEEP], + ])('skips and names --overwrite in non-interactive %s mode', async (_name, options) => { + mockRows([{ id: 'existing', environments: ['production'] }]); + + await expect( + upsertEnvVarAsync(client, 'app-1', envVar, ['production'], true, false, options) + ).resolves.toBe(false); + expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('pass --overwrite')); + expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled(); + expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled(); + expect(EnvironmentVariableMutation.deleteAsync).not.toHaveBeenCalled(); + }); + + it.each([ + ['replaceOtherEnvironments', REPLACE], + ['keepOtherEnvironments', KEEP], + ])( + 'skips without naming --overwrite when declined interactively in %s mode', + async (_name, options) => { + mockRows([{ id: 'existing', environments: ['production'] }]); + jest.mocked(confirmAsync).mockResolvedValue(false); + + await expect( + upsertEnvVarAsync(client, 'app-1', envVar, ['production'], false, false, options) + ).resolves.toBe(false); + expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('Skipped updating')); + expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining('--overwrite')); + expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled(); + } + ); + + it('still requires consent when only the visibility differs', async () => { + mockRows([ + { + id: 'sensitive', + environments: ['production'], + value: envVar.value, + visibility: EnvironmentVariableVisibility.Sensitive, + }, + ]); + + await expect( + upsertEnvVarAsync(client, 'app-1', envVar, ['production'], true, false, REPLACE) + ).resolves.toBe(false); + expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled(); + }); + + it('names the environments in the prompt even when the row has none', async () => { + mockRows([{ id: 'no-envs', environments: null }]); + jest.mocked(confirmAsync).mockResolvedValue(true); + + await expect( + upsertEnvVarAsync(client, 'app-1', envVar, ['production'], false, false, REPLACE) + ).resolves.toBe(true); + expect(confirmAsync).toHaveBeenCalledWith( + expect.objectContaining({ + message: `EAS already has ${envVar.name} for other environments. Overwrite it?`, + }) + ); + }); + + it('does not prompt when the value and environments are already correct', async () => { + mockRows([{ id: 'exact', environments: ['production'], value: envVar.value }]); + + await expect( + upsertEnvVarAsync(client, 'app-1', envVar, ['production'], true, false, KEEP) + ).resolves.toBe(true); + expect(confirmAsync).not.toHaveBeenCalled(); + }); + + it('still requires consent when the value matches but the environments differ', async () => { + mockRows([{ id: 'exact', environments: ['production'], value: envVar.value }]); + + await expect( + upsertEnvVarAsync(client, 'app-1', envVar, ['production', 'preview'], true, false, REPLACE) + ).resolves.toBe(false); + expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('pass --overwrite')); + expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled(); + }); + + it('still requires consent when the existing row has no readable value', async () => { + mockRows([{ id: 'secret', environments: ['production'] }]); + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ + { + id: 'secret', + scope: EnvironmentVariableScope.Project, + environments: ['production'], + value: null, + }, + ] as never); + + await expect( + upsertEnvVarAsync(client, 'app-1', envVar, ['production'], true, false, KEEP) + ).resolves.toBe(false); + expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled(); + }); + + it('still requires consent when the environments match but the value differs', async () => { + mockRows([{ id: 'exact', environments: ['production'], value: 'stale' }]); + + await expect( + upsertEnvVarAsync(client, 'app-1', envVar, ['production'], true, false, KEEP) + ).resolves.toBe(false); + expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled(); + }); + + it('writes without prompting when --overwrite is passed', async () => { + mockRows([{ id: 'existing', environments: ['production'] }]); + + await expect( + upsertEnvVarAsync(client, 'app-1', envVar, ['production'], false, true, REPLACE) + ).resolves.toBe(true); + expect(confirmAsync).not.toHaveBeenCalled(); + expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalled(); + }); +}); + +describe('upsertEnvVarsSequentiallyAsync', () => { + it('runs upsert for each var and collects the results in order', async () => { + const upsert = jest.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false); + + await expect( + upsertEnvVarsSequentiallyAsync( + [ + { name: 'A', value: '1', visibility: EnvironmentVariableVisibility.Public }, + { name: 'B', value: '2', visibility: EnvironmentVariableVisibility.Public }, + ], + upsert + ) + ).resolves.toEqual([true, false]); + }); + + it('does not start the next upsert before the previous one settles', async () => { + const active: string[] = []; + const maxConcurrent = { value: 0 }; + const upsert = jest.fn(async (variable: EnvVar) => { + active.push(variable.name); + maxConcurrent.value = Math.max(maxConcurrent.value, active.length); + await Promise.resolve(); + active.pop(); + return true; + }); + + await upsertEnvVarsSequentiallyAsync( + [ + { name: 'A', value: '1', visibility: EnvironmentVariableVisibility.Public }, + { name: 'B', value: '2', visibility: EnvironmentVariableVisibility.Public }, + ], + upsert + ); + + expect(maxConcurrent.value).toBe(1); + }); +}); diff --git a/packages/eas-cli/src/environments/defaults.ts b/packages/eas-cli/src/environments/defaults.ts new file mode 100644 index 0000000000..bd2d2ab42f --- /dev/null +++ b/packages/eas-cli/src/environments/defaults.ts @@ -0,0 +1,3 @@ +import { DefaultEnvironment } from '../build/utils/environment'; + +export const DEFAULT_ENVIRONMENTS = Object.values(DefaultEnvironment); diff --git a/packages/eas-cli/src/environments/resolve.ts b/packages/eas-cli/src/environments/resolve.ts new file mode 100644 index 0000000000..f98943d41a --- /dev/null +++ b/packages/eas-cli/src/environments/resolve.ts @@ -0,0 +1,89 @@ +import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; +import { confirmAsync } from '../prompts'; + +import { getProjectEnvironmentVariableEnvironmentsAsync } from './variables'; + +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; +} + +/** + * Resolve requested EAS environment names against what the project already uses. + * Shared across integrations; pass integration-specific defaults and messages at each callsite. + */ +export async function resolveTargetEnvironmentsAsync( + graphqlClient: ExpoGraphqlClient, + projectId: string, + requested: string[], + nonInteractive: boolean, + { + defaultEnvironments, + cancelMessage, + }: { + defaultEnvironments: string[]; + cancelMessage: (knownEnvironments: string) => string; + } +): Promise { + let known = await getProjectEnvironmentVariableEnvironmentsAsync(graphqlClient, projectId); + if (known.length === 0) { + known = [...defaultEnvironments]; + } + const knownSet = new Set(known); + const unknown = requested.filter(environment => !knownSet.has(environment)); + if (unknown.length === 0) { + return requested; + } + const defaultEnvironmentSet = new Set(defaultEnvironments); + 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(cancelMessage(known.join(', '))); + } + // Custom environments are created lazily when env vars are written (createForAppAsync). + return requested; +} diff --git a/packages/eas-cli/src/environments/variables.ts b/packages/eas-cli/src/environments/variables.ts new file mode 100644 index 0000000000..e7eb7a2a02 --- /dev/null +++ b/packages/eas-cli/src/environments/variables.ts @@ -0,0 +1,186 @@ +import chalk from 'chalk'; + +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 type EnvVar = { name: string; value: string; visibility: EnvironmentVariableVisibility }; + +type ProjectScopedEnvVar = Awaited< + ReturnType +>[number]; + +// What happens to environments that already hold a value for this name but are not being written: +// drop them so one value covers everything, or leave them on their existing value. +export type EnvVarWriteMode = 'replaceOtherEnvironments' | 'keepOtherEnvironments'; + +function coversExactly(environments: string[], target: Set): boolean { + return environments.length === target.size && environments.every(e => target.has(e)); +} + +export async function getProjectEnvironmentVariableEnvironmentsAsync( + graphqlClient: ExpoGraphqlClient, + projectId: string +): Promise { + try { + const environments = await EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync( + graphqlClient, + projectId + ); + return environments; + } catch (error) { + throw new Error('Failed to fetch available environments', { cause: error }); + } +} + +export async function loadProjectScopedEnvVarsAsync( + graphqlClient: ExpoGraphqlClient, + projectId: string, + name: string +): Promise { + return ( + await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, { + appId: projectId, + filterNames: [name], + }) + ).filter(variable => variable.scope === EnvironmentVariableScope.Project); +} + +export async function upsertEnvVarAsync( + graphqlClient: ExpoGraphqlClient, + projectId: string, + envVar: EnvVar, + environments: string[], + nonInteractive: boolean, + overwrite: boolean, + { + mode, + moveConfirmMessage, + }: { + mode: EnvVarWriteMode; + // Only reachable in keepOtherEnvironments mode, where a row keeps some environments and loses others. + moveConfirmMessage?: (variableName: string, overlappingEnvironments: string) => string; + } +): Promise { + const target = new Set(environments); + const replacingAll = mode === 'replaceOtherEnvironments'; + const existingRows = await loadProjectScopedEnvVarsAsync(graphqlClient, projectId, envVar.name); + + // 1. Work out which rows this write touches. Replacing takes over every row; keeping only touches + // rows that share an environment with the target. + type CoveredRow = { + id: string; + value: string; + environments: string[]; + visibility: EnvironmentVariableVisibility | null; + }; + const coveredRows: CoveredRow[] = []; + const rowsToShrink: { id: string; keptEnvironments: string[] }[] = []; + const takenOverEnvironments = new Set(); + for (const row of existingRows) { + const rowEnvironments = row.environments ?? []; + const handedOver = replacingAll + ? rowEnvironments + : rowEnvironments.filter(environment => target.has(environment)); + const kept = replacingAll + ? [] + : rowEnvironments.filter(environment => !target.has(environment)); + if (!replacingAll && handedOver.length === 0) { + continue; + } + handedOver.forEach(environment => takenOverEnvironments.add(environment)); + if (kept.length === 0) { + coveredRows.push({ + id: row.id, + value: row.value ?? '', + environments: rowEnvironments, + visibility: row.visibility ?? null, + }); + } else { + rowsToShrink.push({ id: row.id, keptEnvironments: kept }); + } + } + // Reuse the first fully covered row so the variable keeps its id; the rest are redundant. Typed + // explicitly because tsconfig has no noUncheckedIndexedAccess, so index 0 looks always-present. + const rowToReuse: CoveredRow | undefined = coveredRows[0]; + const redundantRows = coveredRows.slice(1); + const takenOver = [...takenOverEnvironments].join(', ') || 'other environments'; + + // 2. Get consent. Overwriting is always explicit — --overwrite, or a yes — and only a write that + // changes nothing at all is exempt. Visibility counts: writing the same value as Public over a + // Sensitive row would expose it, so that is a change like any other. + const reusedRowIsIdentical = + rowToReuse?.value === envVar.value && + rowToReuse.visibility === envVar.visibility && + coversExactly(rowToReuse.environments, target); + const touchesExistingRows = coveredRows.length > 0 || rowsToShrink.length > 0; + const changesNothing = + reusedRowIsIdentical && redundantRows.length === 0 && rowsToShrink.length === 0; + + if (touchesExistingRows && !overwrite && !changesNothing) { + let message: string; + if (rowsToShrink.length > 0 && moveConfirmMessage) { + message = moveConfirmMessage(envVar.name, takenOver); + } else if (redundantRows.length > 0) { + message = `EAS has multiple ${envVar.name} variables for this project (including ${takenOver}). Replace them with one value for ${environments.join(', ')}?`; + } else { + message = `EAS already has ${envVar.name} for ${takenOver}. Overwrite it?`; + } + if (nonInteractive || !(await confirmAsync({ message }))) { + Log.warn( + `Skipped updating EAS environment variable ${chalk.bold(envVar.name)}${ + nonInteractive ? ' (pass --overwrite to replace it)' : '' + }.` + ); + return false; + } + } + + // 3. Apply: hand environments over, drop redundant rows, then land the value. + for (const { id, keptEnvironments } of rowsToShrink) { + await EnvironmentVariableMutation.updateAsync(graphqlClient, { + id, + environments: keptEnvironments, + }); + } + for (const { id } of redundantRows) { + await EnvironmentVariableMutation.deleteAsync(graphqlClient, id); + } + + const fields = { + name: envVar.name, + value: envVar.value, + environments, + visibility: envVar.visibility, + type: EnvironmentSecretType.String, + }; + if (rowToReuse) { + await EnvironmentVariableMutation.updateAsync(graphqlClient, { id: rowToReuse.id, ...fields }); + } else { + await EnvironmentVariableMutation.createForAppAsync(graphqlClient, fields, projectId); + } + Log.withTick( + `${rowToReuse ? 'Updated' : 'Created'} EAS environment variable ${chalk.bold( + envVar.name + )} for ${environments.join(', ')}` + ); + return true; +} + +export async function upsertEnvVarsSequentiallyAsync( + envVars: EnvVar[], + upsert: (envVar: EnvVar) => Promise +): Promise { + const written: boolean[] = []; + for (const envVar of envVars) { + written.push(await upsert(envVar)); + } + return written; +} diff --git a/packages/eas-cli/src/graphql/__tests__/client-test.ts b/packages/eas-cli/src/graphql/__tests__/client-test.ts index 35ae50cf0a..57820bc7ed 100644 --- a/packages/eas-cli/src/graphql/__tests__/client-test.ts +++ b/packages/eas-cli/src/graphql/__tests__/client-test.ts @@ -1,8 +1,10 @@ import { CombinedError } from '@urql/core'; +import { GraphQLError } from 'graphql'; import Log from '../../log'; import { EAS_CLI_UPGRADE_REQUIRED_ERROR_CODE, + isPermanentGraphqlError, withErrorHandlingAsync, withUpgradeRequiredErrorHandlingAsync, } from '../client'; @@ -41,6 +43,46 @@ describe(withErrorHandlingAsync, () => { }); }); +describe(isPermanentGraphqlError, () => { + it('is true for user errors', () => { + expect(isPermanentGraphqlError(makeError('Not authorized', { errorType: 'USER' }))).toBe(true); + }); + + it('is false for network errors', () => { + expect(isPermanentGraphqlError(new CombinedError({ networkError: new Error('offline') }))).toBe( + false + ); + }); + + it('is false for server faults', () => { + expect(isPermanentGraphqlError(makeError('Unexpected', { errorType: 'SYSTEM' }))).toBe(false); + }); + + it('is false for user errors the server marks transient', () => { + expect( + isPermanentGraphqlError(makeError('Locked', { errorType: 'USER', isTransient: true })) + ).toBe(false); + }); + + it('is false when any error in the response is not a user error', () => { + const mixed = new CombinedError({ + graphQLErrors: [ + new GraphQLError('Not authorized', { extensions: { errorType: 'USER' } }), + new GraphQLError('Unexpected', { extensions: { errorType: 'SYSTEM' } }), + ], + }); + expect(isPermanentGraphqlError(mixed)).toBe(false); + }); + + it('is false for unclassified GraphQL errors', () => { + expect(isPermanentGraphqlError(makeError('Something'))).toBe(false); + }); + + it('is false for errors that are not GraphQL errors', () => { + expect(isPermanentGraphqlError(new Error('boom'))).toBe(false); + }); +}); + describe(withUpgradeRequiredErrorHandlingAsync, () => { it('returns data when the promise resolves successfully', async () => { const result = await withUpgradeRequiredErrorHandlingAsync( diff --git a/packages/eas-cli/src/graphql/client.ts b/packages/eas-cli/src/graphql/client.ts index d7e9f776d4..5a907ee0fb 100644 --- a/packages/eas-cli/src/graphql/client.ts +++ b/packages/eas-cli/src/graphql/client.ts @@ -60,6 +60,25 @@ export async function withUpgradeRequiredErrorHandlingAsync( } } +/** + * Whether a failed request will keep failing the same way. True only when the server attributes + * every error to the caller (`errorType: USER`): a rejected input, a missing permission, a + * resource this account can't see. Server faults, network errors, and anything we can't classify + * stay retriable, since giving up on those turns a blip into a failed command. Polling loops use + * this to stop early instead of waiting out their deadline. + */ +export function isPermanentGraphqlError(error: unknown): boolean { + if (!(error instanceof GraphqlError) || error.networkError) { + return false; + } + return ( + error.graphQLErrors.length > 0 && + error.graphQLErrors.every( + e => e?.extensions?.errorType === 'USER' && !e?.extensions?.isTransient + ) + ); +} + function isUpgradeRequiredError(error: unknown): boolean { if (!(error instanceof GraphqlError)) { return false; diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index a87dd8a12a..91fed12a8e 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -14340,6 +14340,69 @@ export type RetrySubmissionMutationVariables = Exact<{ export type RetrySubmissionMutation = { __typename?: 'RootMutation', submission: { __typename?: 'SubmissionMutation', retrySubmission: { __typename?: 'CreateSubmissionResult', submission: { __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logFiles: Array, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: string, releaseStatus?: SubmissionAndroidReleaseStatus | null, rollout?: number | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null } } } }; +export type BeginSupabaseOAuthMutationVariables = Exact<{ + input: BeginSupabaseOAuthInput; +}>; + + +export type BeginSupabaseOAuthMutation = { __typename?: 'RootMutation', supabaseConnection: { __typename?: 'SupabaseConnectionMutation', beginSupabaseOAuth: { __typename?: 'SupabaseOAuthStart', url: string } } }; + +export type SetSupabaseConnectionOrganizationMutationVariables = Exact<{ + input: SetSupabaseConnectionOrganizationInput; +}>; + + +export type SetSupabaseConnectionOrganizationMutation = { __typename?: 'RootMutation', supabaseConnection: { __typename?: 'SupabaseConnectionMutation', setSupabaseConnectionOrganization: { __typename?: 'SupabaseConnection', id: string, supabaseOrganizationSlug: string, supabaseOrganizationName: string, createdAt: any, updatedAt: any } } }; + +export type DisconnectSupabaseMutationVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type DisconnectSupabaseMutation = { __typename?: 'RootMutation', supabaseConnection: { __typename?: 'SupabaseConnectionMutation', disconnectSupabase: string } }; + +export type ProvisionSupabaseProjectMutationVariables = Exact<{ + input: ProvisionSupabaseProjectInput; +}>; + + +export type ProvisionSupabaseProjectMutation = { __typename?: 'RootMutation', supabaseProject: { __typename?: 'SupabaseProjectMutation', provisionSupabaseProject: { __typename?: 'BackgroundJobReceipt', id: string, state: BackgroundJobState, tries: number, willRetry: boolean, resultId?: string | null, resultType: BackgroundJobResultType, resultData?: any | null, errorCode?: string | null, errorMessage?: string | null, createdAt: any, updatedAt: any } } }; + +export type ProvisionAdditionalSupabaseProjectMutationVariables = Exact<{ + input: ProvisionAdditionalSupabaseProjectInput; +}>; + + +export type ProvisionAdditionalSupabaseProjectMutation = { __typename?: 'RootMutation', supabaseProject: { __typename?: 'SupabaseProjectMutation', provisionAdditionalSupabaseProject: { __typename?: 'BackgroundJobReceipt', id: string, state: BackgroundJobState, tries: number, willRetry: boolean, resultId?: string | null, resultType: BackgroundJobResultType, resultData?: any | null, errorCode?: string | null, errorMessage?: string | null, createdAt: any, updatedAt: any } } }; + +export type LinkSupabaseProjectMutationVariables = Exact<{ + input: LinkSupabaseProjectInput; +}>; + + +export type LinkSupabaseProjectMutation = { __typename?: 'RootMutation', supabaseProject: { __typename?: 'SupabaseProjectMutation', linkSupabaseProject: { __typename?: 'SupabaseProject', id: string, supabaseProjectRef: string, supabaseProjectName: string, supabaseProjectUrl: string, supabaseRegion: string, createdAt: any, updatedAt: any } } }; + +export type DeleteSupabaseProjectMutationVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type DeleteSupabaseProjectMutation = { __typename?: 'RootMutation', supabaseProject: { __typename?: 'SupabaseProjectMutation', deleteSupabaseProject: string } }; + +export type ListSupabaseOrganizationsMutationVariables = Exact<{ + accountId: Scalars['ID']['input']; +}>; + + +export type ListSupabaseOrganizationsMutation = { __typename?: 'RootMutation', supabaseConnection: { __typename?: 'SupabaseConnectionMutation', listSupabaseOrganizations: Array<{ __typename?: 'SupabaseOrganization', id: string, slug: string, name: string }> } }; + +export type FetchSupabasePublishableKeyMutationVariables = Exact<{ + appId: Scalars['ID']['input']; +}>; + + +export type FetchSupabasePublishableKeyMutation = { __typename?: 'RootMutation', supabaseProject: { __typename?: 'SupabaseProjectMutation', fetchSupabasePublishableKey?: string | null } }; + export type CreateUploadSessionMutationVariables = Exact<{ type: UploadSessionType; filename?: InputMaybe; @@ -15069,6 +15132,20 @@ export type GetAllSubmissionsForAppQuery = { __typename?: 'RootQuery', app: { __ | { __typename: 'Snack', id: string, name: string, slug: string } , metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } | null, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: string, releaseStatus?: SubmissionAndroidReleaseStatus | null, rollout?: number | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null }> } } }; +export type SupabaseConnectionByAccountIdQueryVariables = Exact<{ + accountId: Scalars['String']['input']; +}>; + + +export type SupabaseConnectionByAccountIdQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, supabaseConnection?: { __typename?: 'SupabaseConnection', id: string, supabaseOrganizationSlug: string, supabaseOrganizationName: string, createdAt: any, updatedAt: any } | null } } }; + +export type SupabaseProjectByAppIdQueryVariables = Exact<{ + appId: Scalars['String']['input']; +}>; + + +export type SupabaseProjectByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, supabaseProject?: { __typename?: 'SupabaseProject', id: string, supabaseProjectRef: string, supabaseProjectName: string, supabaseProjectUrl: string, supabaseRegion: string, createdAt: any, updatedAt: any } | null } } }; + export type ViewUpdateGroupInsightsQueryVariables = Exact<{ groupId: Scalars['ID']['input']; timespan: InsightsTimespan; @@ -15415,6 +15492,10 @@ export type SubmissionWithSubmittedBuildFragment = { __typename?: 'Submission', | { __typename: 'Snack', id: string, name: string, slug: string } , metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } | null, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: string, releaseStatus?: SubmissionAndroidReleaseStatus | null, rollout?: number | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null }; +export type SupabaseConnectionFragment = { __typename?: 'SupabaseConnection', id: string, supabaseOrganizationSlug: string, supabaseOrganizationName: string, createdAt: any, updatedAt: any }; + +export type SupabaseProjectFragment = { __typename?: 'SupabaseProject', id: string, supabaseProjectRef: string, supabaseProjectName: string, supabaseProjectUrl: string, supabaseRegion: string, createdAt: any, updatedAt: any }; + export type UpdateFragment = { __typename?: 'Update', id: string, group: string, message?: string | null, createdAt: any, runtimeVersion: string, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, isGitWorkingTreeDirty: boolean, environment?: any | null, rolloutPercentage?: number | null, manifestHostOverride?: string | null, assetHostOverride?: string | null, actor?: | { __typename: 'PartnerActor', username: string, id: string } | { __typename: 'Robot', firstName?: string | null, id: string } 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..9b7c9e6cbc --- /dev/null +++ b/packages/eas-cli/src/graphql/mutations/SupabaseMutation.ts @@ -0,0 +1,268 @@ +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) { + 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/mutations/__tests__/SupabaseMutation-test.ts b/packages/eas-cli/src/graphql/mutations/__tests__/SupabaseMutation-test.ts new file mode 100644 index 0000000000..5a571ede35 --- /dev/null +++ b/packages/eas-cli/src/graphql/mutations/__tests__/SupabaseMutation-test.ts @@ -0,0 +1,141 @@ +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { SupabaseMutation } from '../SupabaseMutation'; + +function makeMutationClient(data: unknown): ExpoGraphqlClient { + return { + mutation: jest.fn().mockReturnValue({ + toPromise: jest.fn().mockResolvedValue({ data }), + }), + } as unknown as ExpoGraphqlClient; +} + +describe('SupabaseMutation', () => { + it('beginSupabaseOAuthAsync returns oauth start data', async () => { + const oauth = { state: 'state-1', url: 'https://supabase.example/oauth' }; + const client = makeMutationClient({ + supabaseConnection: { beginSupabaseOAuth: oauth }, + }); + + await expect( + SupabaseMutation.beginSupabaseOAuthAsync(client, { accountId: 'acct-1' }) + ).resolves.toEqual(oauth); + }); + + it('setSupabaseConnectionOrganizationAsync returns connection', async () => { + const connection = { + id: 'conn-1', + supabaseOrganizationSlug: 'org', + supabaseOrganizationName: 'Org', + }; + const client = makeMutationClient({ + supabaseConnection: { setSupabaseConnectionOrganization: connection }, + }); + + await expect( + SupabaseMutation.setSupabaseConnectionOrganizationAsync(client, { + supabaseConnectionId: 'conn-1', + organizationSlug: 'org', + }) + ).resolves.toEqual(connection); + }); + + it('disconnectSupabaseAsync returns id', async () => { + const client = makeMutationClient({ + supabaseConnection: { disconnectSupabase: 'conn-1' }, + }); + + await expect(SupabaseMutation.disconnectSupabaseAsync(client, 'conn-1')).resolves.toBe( + 'conn-1' + ); + }); + + it('provisionSupabaseProjectAsync returns receipt', async () => { + const receipt = { id: 'receipt-1' }; + const client = makeMutationClient({ + supabaseProject: { provisionSupabaseProject: receipt }, + }); + + await expect( + SupabaseMutation.provisionSupabaseProjectAsync(client, { + appId: 'app-1', + region: 'americas', + }) + ).resolves.toEqual(receipt); + }); + + it('provisionAdditionalSupabaseProjectAsync returns receipt', async () => { + const receipt = { id: 'receipt-2' }; + const client = makeMutationClient({ + supabaseProject: { provisionAdditionalSupabaseProject: receipt }, + }); + + await expect( + SupabaseMutation.provisionAdditionalSupabaseProjectAsync(client, { + appId: 'app-1', + region: 'americas', + projectNameSuffix: 'preview', + }) + ).resolves.toEqual(receipt); + expect(client.mutation).toHaveBeenCalledWith( + expect.anything(), + { input: { appId: 'app-1', region: 'americas', projectNameSuffix: 'preview' } }, + expect.anything() + ); + }); + + it('linkSupabaseProjectAsync returns project', async () => { + const project = { id: 'project-1', supabaseProjectRef: 'ref' }; + const client = makeMutationClient({ + supabaseProject: { linkSupabaseProject: project }, + }); + + await expect( + SupabaseMutation.linkSupabaseProjectAsync(client, { + appId: 'app-1', + supabaseProjectRef: 'ref', + }) + ).resolves.toEqual(project); + expect(client.mutation).toHaveBeenCalledWith( + expect.anything(), + { input: { appId: 'app-1', supabaseProjectRef: 'ref' } }, + expect.anything() + ); + }); + + it('deleteSupabaseProjectAsync returns id', async () => { + const client = makeMutationClient({ + supabaseProject: { deleteSupabaseProject: 'project-1' }, + }); + + await expect(SupabaseMutation.deleteSupabaseProjectAsync(client, 'project-1')).resolves.toBe( + 'project-1' + ); + }); + + it('listSupabaseOrganizationsAsync returns organizations', async () => { + const organizations = [{ id: '1', slug: 'org', name: 'Org' }]; + const client = makeMutationClient({ + supabaseConnection: { listSupabaseOrganizations: organizations }, + }); + + await expect( + SupabaseMutation.listSupabaseOrganizationsAsync(client, 'acct-1') + ).resolves.toEqual(organizations); + }); + + it('fetchSupabasePublishableKeyAsync returns key or null', async () => { + const withKey = makeMutationClient({ + supabaseProject: { fetchSupabasePublishableKey: 'pk_test' }, + }); + await expect(SupabaseMutation.fetchSupabasePublishableKeyAsync(withKey, 'app-1')).resolves.toBe( + 'pk_test' + ); + + const withoutKey = makeMutationClient({ + supabaseProject: { fetchSupabasePublishableKey: null }, + }); + await expect( + SupabaseMutation.fetchSupabasePublishableKeyAsync(withoutKey, 'app-1') + ).resolves.toBeNull(); + }); +}); 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/queries/__tests__/SupabaseQuery-test.ts b/packages/eas-cli/src/graphql/queries/__tests__/SupabaseQuery-test.ts new file mode 100644 index 0000000000..5064ed6ecb --- /dev/null +++ b/packages/eas-cli/src/graphql/queries/__tests__/SupabaseQuery-test.ts @@ -0,0 +1,101 @@ +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { SupabaseQuery } from '../SupabaseQuery'; + +function makeQueryClient(data: unknown): ExpoGraphqlClient { + return { + query: jest.fn().mockReturnValue({ + toPromise: jest.fn().mockResolvedValue({ data }), + }), + } as unknown as ExpoGraphqlClient; +} + +describe('SupabaseQuery', () => { + const connection = { + id: 'conn-1', + supabaseOrganizationSlug: 'org', + supabaseOrganizationName: 'Org', + }; + const project = { + id: 'project-1', + supabaseProjectRef: 'abcdefghijklmnop', + supabaseProjectName: 'Demo', + supabaseProjectUrl: 'https://abcdefghijklmnop.supabase.co', + supabaseRegion: 'us-east-1', + }; + + it('getSupabaseConnectionByAccountIdAsync returns connection', async () => { + const client = makeQueryClient({ + account: { byId: { id: 'acct-1', supabaseConnection: connection } }, + }); + + await expect( + SupabaseQuery.getSupabaseConnectionByAccountIdAsync(client, 'acct-1') + ).resolves.toEqual(connection); + expect(client.query).toHaveBeenCalledWith( + expect.anything(), + { accountId: 'acct-1' }, + expect.objectContaining({ requestPolicy: 'cache-first' }) + ); + }); + + it('getSupabaseConnectionByAccountIdAsync returns null when missing', async () => { + const client = makeQueryClient({ + account: { byId: { id: 'acct-1' } }, + }); + + await expect( + SupabaseQuery.getSupabaseConnectionByAccountIdAsync(client, 'acct-1') + ).resolves.toBeNull(); + }); + + it('getSupabaseConnectionByAccountIdAsync uses network-only when useCache is false', async () => { + const client = makeQueryClient({ + account: { byId: { id: 'acct-1', supabaseConnection: connection } }, + }); + + await SupabaseQuery.getSupabaseConnectionByAccountIdAsync(client, 'acct-1', { + useCache: false, + }); + expect(client.query).toHaveBeenCalledWith( + expect.anything(), + { accountId: 'acct-1' }, + expect.objectContaining({ requestPolicy: 'network-only' }) + ); + }); + + it('getSupabaseProjectByAppIdAsync returns project', async () => { + const client = makeQueryClient({ + app: { byId: { id: 'app-1', supabaseProject: project } }, + }); + + await expect(SupabaseQuery.getSupabaseProjectByAppIdAsync(client, 'app-1')).resolves.toEqual( + project + ); + expect(client.query).toHaveBeenCalledWith( + expect.anything(), + { appId: 'app-1' }, + expect.objectContaining({ requestPolicy: 'cache-first' }) + ); + }); + + it('getSupabaseProjectByAppIdAsync returns null when missing', async () => { + const client = makeQueryClient({ + app: { byId: { id: 'app-1', supabaseProject: null } }, + }); + + await expect(SupabaseQuery.getSupabaseProjectByAppIdAsync(client, 'app-1')).resolves.toBeNull(); + }); + + it('getSupabaseProjectByAppIdAsync uses network-only when useCache is false', async () => { + const client = makeQueryClient({ + app: { byId: { id: 'app-1', supabaseProject: project } }, + }); + + await SupabaseQuery.getSupabaseProjectByAppIdAsync(client, 'app-1', { useCache: false }); + expect(client.query).toHaveBeenCalledWith( + expect.anything(), + { appId: 'app-1' }, + expect.objectContaining({ requestPolicy: 'network-only' }) + ); + }); +}); 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..fedc3446e2 --- /dev/null +++ b/packages/eas-cli/src/graphql/types/SupabaseConnection.ts @@ -0,0 +1,58 @@ +import gql from 'graphql-tag'; + +import { + SupabaseConnection, + SupabaseOAuthStart, + SupabaseOrganization, + SupabaseProject, +} from '../generated'; + +export { + BeginSupabaseOAuthInput, + LinkSupabaseProjectInput, + ProvisionAdditionalSupabaseProjectInput, + ProvisionSupabaseProjectInput, + SetSupabaseConnectionOrganizationInput, +} from '../generated'; + +export type SupabaseOrganizationData = Pick; + +export type SupabaseConnectionData = Pick< + SupabaseConnection, + 'id' | 'supabaseOrganizationSlug' | 'supabaseOrganizationName' | 'createdAt' | 'updatedAt' +>; + +export type SupabaseProjectData = Pick< + SupabaseProject, + | 'id' + | 'supabaseProjectRef' + | 'supabaseProjectName' + | 'supabaseProjectUrl' + | 'supabaseRegion' + | 'createdAt' + | 'updatedAt' +>; + +export type SupabaseOAuthStartData = Pick; + +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/shared/__tests__/envFile-test.ts b/packages/eas-cli/src/integrations/shared/__tests__/envFile-test.ts new file mode 100644 index 0000000000..befedf4eb9 --- /dev/null +++ b/packages/eas-cli/src/integrations/shared/__tests__/envFile-test.ts @@ -0,0 +1,198 @@ +import dotenv from 'dotenv'; +import * as fs from 'fs-extra'; + +import { EnvironmentVariableVisibility } from '../../../graphql/generated'; +import Log from '../../../log'; +import { confirmAsync } from '../../../prompts'; +import { mergeEnvContent, writeEnvLocalAsync } from '../envFile'; + +jest.mock('fs-extra'); +jest.mock('../../../prompts'); +jest.mock('../../../log'); + +const LABEL = 'Example'; +const VAR_NAME = 'EXPO_PUBLIC_EXAMPLE_URL'; + +const envVars = [ + { + name: VAR_NAME, + value: 'https://new.example.test', + visibility: EnvironmentVariableVisibility.Public, + }, +]; + +describe('mergeEnvContent', () => { + it('updates existing keys and appends new ones', () => { + expect(mergeEnvContent('FOO=1\n', { FOO: '2', BAR: '3' })).toBe('FOO=2\nBAR=3\n'); + expect(mergeEnvContent('FOO=1', { BAR: '3' })).toBe('FOO=1\nBAR=3\n'); + }); + + it('replaces rather than duplicates keys written with spaces around the equals sign', () => { + expect(mergeEnvContent('FOO = 1\n', { FOO: '2' })).toBe('FOO=2\n'); + }); + + it('replaces rather than duplicates keys written with an export prefix, keeping the prefix', () => { + expect(mergeEnvContent('export FOO=1\n', { FOO: '2' })).toBe('export FOO=2\n'); + }); + + it('replaces indented keys, keeping the existing indentation', () => { + expect(mergeEnvContent(' FOO=1\n', { FOO: '2' })).toBe(' FOO=2\n'); + }); + + it('writes values containing regex replacement patterns verbatim', () => { + expect(mergeEnvContent('FOO=1\n', { FOO: 'a$&b$1c' })).toBe('FOO=a$&b$1c\n'); + }); + + it('does not treat a commented-out key as present', () => { + expect(mergeEnvContent('#FOO=1\n', { FOO: '2' })).toBe('#FOO=1\nFOO=2\n'); + }); + + it('replaces keys written in the colon form dotenv accepts', () => { + expect(mergeEnvContent('FOO: 1\n', { FOO: '2' })).toBe('FOO=2\n'); + }); + + it('collapses duplicate definitions so a later stale one cannot win', () => { + expect(mergeEnvContent('export FOO=1\nFOO=stale\n', { FOO: '2' })).toBe('export FOO=2\n'); + expect(mergeEnvContent('FOO=a\nFOO=b\nFOO=c\n', { FOO: '2' })).toBe('FOO=2\n'); + expect(mergeEnvContent('A=1\nFOO=old\nB=2\nFOO=stale\nC=3\n', { FOO: '2' })).toBe( + 'A=1\nFOO=2\nB=2\nC=3\n' + ); + }); + + it('keeps comments that follow, or trail, a replaced definition', () => { + expect(mergeEnvContent('FOO=1\n# keep\n', { FOO: '2' })).toBe('FOO=2\n# keep\n'); + expect(mergeEnvContent('FOO=1\n\n\n# keep\nBAR=2\n', { FOO: '2' })).toBe( + 'FOO=2\n\n\n# keep\nBAR=2\n' + ); + expect(mergeEnvContent('FOO=1 # keep\n', { FOO: '2' })).toBe('FOO=2 # keep\n'); + }); + + it('keeps CRLF line endings intact when collapsing duplicates', () => { + expect(mergeEnvContent('FOO=1\r\nBAR=2\r\nFOO=3\r\nBAZ=4\r\n', { FOO: 'new' })).toBe( + 'FOO=new\r\nBAR=2\r\nBAZ=4\r\n' + ); + expect(mergeEnvContent('FOO=1\r\nFOO=2\r\nFOO=3\r\n', { FOO: 'new' })).toBe('FOO=new\r\n'); + }); + + it('quotes values that would not otherwise read back unchanged', () => { + expect(mergeEnvContent('', { FOO: 'secret#123' })).toBe('FOO="secret#123"\n'); + expect(mergeEnvContent('', { FOO: ' padded' })).toBe('FOO=" padded"\n'); + expect(mergeEnvContent('', { FOO: "'sq'" })).toBe('FOO="\'sq\'"\n'); + expect(mergeEnvContent('', { FOO: 'line\nbreak' })).toBe('FOO="line\\nbreak"\n'); + }); + + it('leaves plain values unquoted', () => { + expect(mergeEnvContent('', { FOO: 'https://x.supabase.co' })).toBe( + 'FOO=https://x.supabase.co\n' + ); + }); + + it('cannot be used to inject another variable through a value', () => { + const merged = mergeEnvContent('BAR=keep\n', { FOO: 'x\nBAR=hijacked' }); + expect(dotenv.parse(merged).BAR).toBe('keep'); + expect(dotenv.parse(merged).FOO).toBe('x\nBAR=hijacked'); + }); + + it('does not treat a key inside another quoted value as a definition', () => { + const raw = 'PRIVATE_KEY="-----BEGIN-----\nFOO=notavar\n-----END-----"\nOTHER=1\n'; + expect(mergeEnvContent(raw, { FOO: '2' })).toBe(`${raw}FOO=2\n`); + }); + + it('replaces a multi-line quoted value without orphaning its remaining lines', () => { + expect(mergeEnvContent('FOO="header\nBAR=leaked"\nOTHER=1\n', { FOO: '2' })).toBe( + 'FOO=2\nOTHER=1\n' + ); + }); +}); + +describe('writeEnvLocalAsync', () => { + beforeEach(() => { + jest.resetAllMocks(); + jest.mocked(fs.pathExists).mockResolvedValue(false as never); + jest.mocked(fs.writeFile).mockResolvedValue(undefined as never); + }); + + it('writes a new .env.local file', async () => { + await expect( + writeEnvLocalAsync('/project', envVars, { + label: LABEL, + nonInteractive: true, + overwrite: false, + }) + ).resolves.toBe(true); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('.env.local'), + expect.stringContaining(VAR_NAME) + ); + expect(Log.withTick).toHaveBeenCalled(); + }); + + it('skips conflicts in non-interactive mode without overwrite', async () => { + jest.mocked(fs.pathExists).mockResolvedValue(true as never); + jest.mocked(fs.readFile).mockResolvedValue(`${VAR_NAME}=old\n` as never); + + await expect( + writeEnvLocalAsync('/project', envVars, { + label: LABEL, + nonInteractive: true, + overwrite: false, + }) + ).resolves.toBe(false); + expect(fs.writeFile).not.toHaveBeenCalled(); + expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('skipped')); + }); + + it('prompts on conflicts interactively and skips when declined', async () => { + jest.mocked(fs.pathExists).mockResolvedValue(true as never); + jest.mocked(fs.readFile).mockResolvedValue(`${VAR_NAME}=old\n` as never); + jest.mocked(confirmAsync).mockResolvedValue(false); + + await expect( + writeEnvLocalAsync('/project', envVars, { + label: LABEL, + nonInteractive: false, + overwrite: false, + }) + ).resolves.toBe(false); + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + + it('overwrites conflicts when confirmed or --overwrite', async () => { + jest.mocked(fs.pathExists).mockResolvedValue(true as never); + jest.mocked(fs.readFile).mockResolvedValue(`${VAR_NAME}=old\n` as never); + jest.mocked(confirmAsync).mockResolvedValue(true); + + await expect( + writeEnvLocalAsync('/project', envVars, { + label: LABEL, + nonInteractive: false, + overwrite: false, + }) + ).resolves.toBe(true); + await expect( + writeEnvLocalAsync('/project', envVars, { + label: LABEL, + nonInteractive: true, + overwrite: true, + }) + ).resolves.toBe(true); + expect(fs.writeFile).toHaveBeenCalled(); + }); + + it('replaces a conflicting export-prefixed key instead of leaving a stale line', async () => { + jest.mocked(fs.pathExists).mockResolvedValue(true as never); + jest.mocked(fs.readFile).mockResolvedValue(`export ${VAR_NAME}=old\n` as never); + + await expect( + writeEnvLocalAsync('/project', envVars, { + label: LABEL, + nonInteractive: true, + overwrite: true, + }) + ).resolves.toBe(true); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('.env.local'), + `export ${VAR_NAME}=https://new.example.test\n` + ); + }); +}); diff --git a/packages/eas-cli/src/integrations/shared/__tests__/sdk-test.ts b/packages/eas-cli/src/integrations/shared/__tests__/sdk-test.ts new file mode 100644 index 0000000000..40ed02730e --- /dev/null +++ b/packages/eas-cli/src/integrations/shared/__tests__/sdk-test.ts @@ -0,0 +1,221 @@ +import spawnAsync from '@expo/spawn-async'; +import { ExpoConfig } from '@expo/config'; + +import Log from '../../../log'; +import { createOrModifyExpoConfigAsync } from '../../../project/expoConfig'; +import { + addConfigPluginAsync, + envForExpoInstall, + extractDynamicConfigGuidance, + getSpawnErrorOutput, + installSdkPackagesAsync, + setupSdkAndConfigAsync, +} from '../sdk'; + +jest.mock('@expo/spawn-async'); +jest.mock('../../../project/expoConfig'); +jest.mock('../../../log'); +jest.mock('../../../ora', () => ({ + ora: jest.fn(() => ({ + start: jest.fn().mockReturnThis(), + succeed: jest.fn().mockReturnThis(), + warn: jest.fn().mockReturnThis(), + fail: jest.fn().mockReturnThis(), + })), +})); + +const packages = ['example-sdk', 'example-plugin-pkg']; +const plugin = 'example-plugin-pkg'; +const label = 'Example'; + +describe('shared sdk helpers', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('getSpawnErrorOutput concatenates stdout and stderr', () => { + expect(getSpawnErrorOutput({ stdout: 'out', stderr: 'err' })).toBe('outerr'); + expect(getSpawnErrorOutput(null)).toBe(''); + }); + + it('extractDynamicConfigGuidance returns guidance after the marker', () => { + expect(extractDynamicConfigGuidance('no marker here')).toBeNull(); + expect( + extractDynamicConfigGuidance('prefix Cannot automatically write to dynamic config: do this') + ).toBe('Cannot automatically write to dynamic config: do this'); + }); + + it('envForExpoInstall strips Expo local env vars', () => { + const original = process.env; + process.env = { + ...original, + EXPO_LOCAL: '1', + EXPO_STAGING: '1', + EXPO_UNIVERSE_DIR: '/tmp', + KEEP: 'yes', + }; + try { + const env = envForExpoInstall(); + expect(env.KEEP).toBe('yes'); + expect(env.EXPO_LOCAL).toBeUndefined(); + expect(env.EXPO_STAGING).toBeUndefined(); + expect(env.EXPO_UNIVERSE_DIR).toBeUndefined(); + } finally { + process.env = original; + } + }); +}); + +describe('installSdkPackagesAsync', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns installed on success', async () => { + jest.mocked(spawnAsync).mockResolvedValue({} as never); + await expect( + installSdkPackagesAsync('/project', { packages, label, jsonFlag: false }) + ).resolves.toEqual({ + status: 'installed', + }); + expect(spawnAsync).toHaveBeenCalledWith( + 'npx', + ['expo', 'install', ...packages], + expect.objectContaining({ cwd: '/project' }) + ); + }); + + it('returns dynamic config guidance when install output includes it', async () => { + jest.mocked(spawnAsync).mockRejectedValue({ + stdout: '', + stderr: 'Cannot automatically write to dynamic config\nAdd plugin manually', + }); + + await expect( + installSdkPackagesAsync('/project', { packages, label, jsonFlag: true }) + ).resolves.toEqual({ + status: 'installed', + dynamicConfigGuidance: expect.stringContaining( + 'Cannot automatically write to dynamic config' + ), + }); + }); + + it('returns failed when install fails without guidance', async () => { + jest.mocked(spawnAsync).mockRejectedValue(new Error('boom')); + await expect( + installSdkPackagesAsync('/project', { packages, label, jsonFlag: true }) + ).resolves.toEqual({ + status: 'failed', + }); + }); +}); + +describe('addConfigPluginAsync', () => { + const exp = { name: 'app', slug: 'app' } as ExpoConfig; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('skips when plugin is already present as a string or tuple', async () => { + await expect( + addConfigPluginAsync( + '/project', + { + ...exp, + plugins: [[plugin, {}], 'other'], + }, + { plugin } + ) + ).resolves.toBeNull(); + await expect( + addConfigPluginAsync( + '/project', + { + ...exp, + plugins: [plugin], + }, + { plugin } + ) + ).resolves.toBeNull(); + expect(createOrModifyExpoConfigAsync).not.toHaveBeenCalled(); + expect(Log.withTick).toHaveBeenCalled(); + }); + + it('returns null on successful modification', async () => { + jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({ type: 'success' } as never); + await expect(addConfigPluginAsync('/project', exp, { plugin })).resolves.toBeNull(); + }); + + it('returns warn message when modification warns', async () => { + jest + .mocked(createOrModifyExpoConfigAsync) + .mockResolvedValue({ type: 'warn', message: 'dynamic config' } as never); + await expect(addConfigPluginAsync('/project', exp, { plugin })).resolves.toContain( + 'dynamic config' + ); + }); + + it('returns fallback message for other modification results', async () => { + jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({ type: 'fail' } as never); + await expect(addConfigPluginAsync('/project', exp, { plugin })).resolves.toContain( + JSON.stringify(plugin) + ); + }); +}); + +describe('setupSdkAndConfigAsync', () => { + const exp = { name: 'app', slug: 'app' } as ExpoConfig; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('adds install failure guidance and does not add the plugin', async () => { + jest.mocked(spawnAsync).mockRejectedValue(new Error('nope')); + jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({ type: 'success' } as never); + + const steps = await setupSdkAndConfigAsync('/project', exp, { + packages, + plugin, + label, + jsonFlag: true, + }); + expect(steps).toEqual([expect.stringContaining('npx expo install')]); + expect(steps[0]).toContain(plugin); + expect(createOrModifyExpoConfigAsync).not.toHaveBeenCalled(); + }); + + it('prefers dynamic config guidance over adding the plugin', async () => { + jest.mocked(spawnAsync).mockRejectedValue({ + stderr: 'Cannot automatically write to dynamic config\nmanual', + }); + + const steps = await setupSdkAndConfigAsync('/project', exp, { + packages, + plugin, + label, + jsonFlag: true, + }); + expect(steps).toEqual([ + expect.stringContaining('Cannot automatically write to dynamic config'), + ]); + expect(createOrModifyExpoConfigAsync).not.toHaveBeenCalled(); + }); + + it('includes plugin manual steps when needed', async () => { + jest.mocked(spawnAsync).mockResolvedValue({} as never); + jest + .mocked(createOrModifyExpoConfigAsync) + .mockResolvedValue({ type: 'warn', message: 'edit app.config.js' } as never); + + const steps = await setupSdkAndConfigAsync('/project', exp, { + packages, + plugin, + label, + jsonFlag: true, + }); + expect(steps[0]).toContain('edit app.config.js'); + }); +}); diff --git a/packages/eas-cli/src/integrations/shared/envFile.ts b/packages/eas-cli/src/integrations/shared/envFile.ts new file mode 100644 index 0000000000..f1898669f9 --- /dev/null +++ b/packages/eas-cli/src/integrations/shared/envFile.ts @@ -0,0 +1,113 @@ +import chalk from 'chalk'; +import dotenv from 'dotenv'; +import * as fs from 'fs-extra'; +import path from 'path'; + +import { EnvVar } from '../../environments/variables'; +import Log from '../../log'; +import { confirmAsync } from '../../prompts'; + +export async function writeEnvLocalAsync( + projectDir: string, + envVars: EnvVar[], + { + label, + nonInteractive, + overwrite, + }: { label: string; 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 ${label} 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 ${label} config to ${chalk.bold('.env.local')}`); + return true; +} + +// Copied from dotenv (lib/main.js) so this finds exactly the keys dotenv.parse reports above. +const DOTENV_LINE = + /^\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?$/gm; + +// dotenv trims unquoted values, cuts them at `#`, and strips one layer of quotes. Inside double +// quotes it unescapes only `\n` and `\r`, so `"` and `\` stay literal. +function formatEnvValue(value: string): string { + if (value === value.trim() && !/[#\r\n'"`]/.test(value.charAt(0) + value)) { + return value; + } + return `"${value.replace(/\n/g, '\\n').replace(/\r/g, '\\r')}"`; +} + +export function mergeEnvContent(rawContent: string, newVars: Record): string { + const edits: { start: number; end: number; text: string }[] = []; + const rewritten = new Set(); + const regex = new RegExp(DOTENV_LINE.source, `${DOTENV_LINE.flags}d`); + let match: RegExpExecArray | null; + while ((match = regex.exec(rawContent)) !== null) { + const key = match[1]; + if (!Object.hasOwn(newVars, key) || !match.indices) { + continue; + } + // match[0] runs past the value onto a following comment line, so spans come from the groups. + const [keyStart, keyEnd] = match.indices[1]!; + const valueRange = match.indices[2]; + const definitionEnd = valueRange + ? valueRange[1] - (/\s+$/.exec(match[2]!)?.[0].length ?? 0) + : keyEnd + (/^[ \t]*(?:=[ \t]*|:[ \t]+)/.exec(rawContent.slice(keyEnd))?.[0].length ?? 0); + + if (!rewritten.has(key)) { + rewritten.add(key); + edits.push({ + start: keyStart, + end: definitionEnd, + text: `${key}=${formatEnvValue(newVars[key])}`, + }); + continue; + } + // dotenv lets a later definition win, so an extra one left behind would keep the stale value. + const nextNewline = rawContent.indexOf('\n', definitionEnd); + edits.push({ + start: rawContent.lastIndexOf('\n', keyStart - 1) + 1, + end: nextNewline === -1 ? rawContent.length : nextNewline + 1, + text: '', + }); + } + + let content = rawContent; + for (const { start, end, text } of edits.reverse()) { + content = content.slice(0, start) + text + content.slice(end); + } + + for (const [key, value] of Object.entries(newVars).filter(([key]) => !rewritten.has(key))) { + if (content.length > 0 && !content.endsWith('\n')) { + content += '\n'; + } + content += `${key}=${formatEnvValue(value)}\n`; + } + return content; +} diff --git a/packages/eas-cli/src/integrations/shared/sdk.ts b/packages/eas-cli/src/integrations/shared/sdk.ts new file mode 100644 index 0000000000..a62ac4ae2a --- /dev/null +++ b/packages/eas-cli/src/integrations/shared/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'; + +const DYNAMIC_CONFIG_MARKER = 'Cannot automatically write to dynamic config'; + +export type SdkInstallResult = + | { status: 'installed'; dynamicConfigGuidance?: string } + | { status: 'failed' }; + +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 index = output.indexOf(DYNAMIC_CONFIG_MARKER); + if (index === -1) { + return null; + } + return output.slice(index).trim(); +} + +// These point eas-cli at a local or staging server; SDK packages always come from production. +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, + { packages, label, jsonFlag }: { packages: string[]; label: string; jsonFlag: boolean } +): Promise { + const spinner = jsonFlag ? null : ora(`Installing the ${label} SDK packages`).start(); + try { + await spawnAsync('npx', ['expo', 'install', ...packages], { + cwd: projectDir, + env: envForExpoInstall(), + }); + spinner?.succeed(`Installed the ${label} 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 ${label} SDK packages — add the config plugin to your app config` + ); + return { status: 'installed', dynamicConfigGuidance }; + } + spinner?.warn(`Could not install the ${label} SDK packages`); + return { status: 'failed' }; + } +} + +export async function addConfigPluginAsync( + projectDir: string, + exp: ExpoConfig, + { plugin }: { plugin: string } +): Promise { + const plugins = exp.plugins ?? []; + const alreadyAdded = plugins.some(p => (Array.isArray(p) ? p[0] : p) === plugin); + if (alreadyAdded) { + Log.withTick(`Config plugin ${chalk.bold(plugin)} is already configured`); + return null; + } + + const modification = await createOrModifyExpoConfigAsync( + projectDir, + { plugins: [...plugins, plugin] }, + { skipSDKVersionRequirement: true } + ); + if (modification.type === 'success') { + Log.withTick(`Added the ${chalk.bold(plugin)} config plugin`); + return null; + } + if (modification.type === 'warn') { + return `${modification.message} Add ${JSON.stringify(plugin)} to the "plugins" array in your app config.`; + } + return `Add ${JSON.stringify(plugin)} to the "plugins" array in your app config.`; +} + +export async function setupSdkAndConfigAsync( + projectDir: string, + exp: ExpoConfig, + { + packages, + plugin, + label, + jsonFlag, + }: { packages: string[]; plugin: string; label: string; jsonFlag: boolean } +): Promise { + const installResult = await installSdkPackagesAsync(projectDir, { packages, label, jsonFlag }); + // Adding the plugin for a package that isn't installed leaves the app config unresolvable. + if (installResult.status === 'failed') { + return [ + `The ${label} SDK packages didn't install, so the ${plugin} config plugin was not added. Run npx expo install ${packages.join(' ')} from your project directory, then re-run this command.`, + ]; + } + if (installResult.dynamicConfigGuidance) { + return [installResult.dynamicConfigGuidance]; + } + const pluginManualStep = await addConfigPluginAsync(projectDir, exp, { plugin }); + return pluginManualStep ? [pluginManualStep] : []; +} diff --git a/packages/eas-cli/src/integrations/supabase/__tests__/env-test.ts b/packages/eas-cli/src/integrations/supabase/__tests__/env-test.ts new file mode 100644 index 0000000000..828127a54f --- /dev/null +++ b/packages/eas-cli/src/integrations/supabase/__tests__/env-test.ts @@ -0,0 +1,142 @@ +import { DefaultEnvironment } from '../../../build/utils/environment'; +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { + EnvironmentVariableScope, + EnvironmentVariableVisibility, +} from '../../../graphql/generated'; +import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery'; +import { confirmAsync } from '../../../prompts'; +import { + EAS_SUPABASE_ENVIRONMENTS, + EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME, + EAS_SUPABASE_URL_ENV_VAR_NAME, + confirmOverwriteForAdditionalProjectAsync, + createSupabaseEnvVars, + supabaseEnvironmentCancelMessage, + supabaseMoveConfirmMessage, +} from '../env'; + +jest.mock('../../../graphql/queries/EnvironmentVariablesQuery'); +jest.mock('../../../prompts'); +jest.mock('../../../log'); + +describe('createSupabaseEnvVars and constants', () => { + it('returns public URL and key vars', () => { + expect(createSupabaseEnvVars('https://example.supabase.co', 'pk')).toEqual([ + { + name: EAS_SUPABASE_URL_ENV_VAR_NAME, + value: 'https://example.supabase.co', + visibility: EnvironmentVariableVisibility.Public, + }, + { + name: EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME, + value: 'pk', + visibility: EnvironmentVariableVisibility.Public, + }, + ]); + }); + + it('lists production, preview, and development in that order', () => { + expect(EAS_SUPABASE_ENVIRONMENTS).toEqual([ + DefaultEnvironment.Production, + DefaultEnvironment.Preview, + DefaultEnvironment.Development, + ]); + }); +}); + +describe('Supabase prompt messages', () => { + it('names the additional project when moving a variable', () => { + expect(supabaseMoveConfirmMessage(EAS_SUPABASE_URL_ENV_VAR_NAME, 'preview')).toBe( + `Move ${EAS_SUPABASE_URL_ENV_VAR_NAME} for preview to the additional Supabase project?` + ); + }); + + it('lists known environments when provisioning is canceled', () => { + expect(supabaseEnvironmentCancelMessage('production, preview')).toContain( + 'No additional Supabase project was provisioned' + ); + expect(supabaseEnvironmentCancelMessage('production, preview')).toContain( + 'known: production, preview' + ); + }); +}); + +describe('confirmOverwriteForAdditionalProjectAsync', () => { + const client = {} as ExpoGraphqlClient; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('forces overwrite immediately with overwrite', async () => { + await expect( + confirmOverwriteForAdditionalProjectAsync(client, 'app-1', ['preview'], true, true) + ).resolves.toEqual({ forceOverwrite: true }); + expect(EnvironmentVariablesQuery.byAppIdAsync).not.toHaveBeenCalled(); + }); + + it('does not force overwrite when there is no overlap', async () => { + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([]); + await expect( + confirmOverwriteForAdditionalProjectAsync(client, 'app-1', ['preview'], true, false) + ).resolves.toEqual({ forceOverwrite: false }); + }); + + it('treats null environments as no overlap', async () => { + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ + { + id: 'v1', + scope: EnvironmentVariableScope.Project, + environments: null, + } as never, + ]); + await expect( + confirmOverwriteForAdditionalProjectAsync(client, 'app-1', ['preview'], true, false) + ).resolves.toEqual({ forceOverwrite: false }); + }); + + it('throws in non-interactive mode when overlap exists', async () => { + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ + { + id: 'v1', + scope: EnvironmentVariableScope.Project, + environments: ['preview'], + } as never, + ]); + + await expect( + confirmOverwriteForAdditionalProjectAsync(client, 'app-1', ['preview'], true, false) + ).rejects.toThrow(/Re-run with --overwrite/); + }); + + it('forces overwrite after interactive confirmation', async () => { + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ + { + id: 'v1', + scope: EnvironmentVariableScope.Project, + environments: ['preview'], + } as never, + ]); + jest.mocked(confirmAsync).mockResolvedValue(true); + + await expect( + confirmOverwriteForAdditionalProjectAsync(client, 'app-1', ['preview'], false, false) + ).resolves.toEqual({ forceOverwrite: true }); + }); + + it('throws when interactive confirmation is declined', async () => { + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ + { + id: 'v1', + scope: EnvironmentVariableScope.Project, + environments: ['preview'], + } as never, + ]); + jest.mocked(confirmAsync).mockResolvedValue(false); + + await expect( + confirmOverwriteForAdditionalProjectAsync(client, 'app-1', ['preview'], false, false) + ).rejects.toThrow(/Canceled/); + }); +}); diff --git a/packages/eas-cli/src/integrations/supabase/__tests__/provision-test.ts b/packages/eas-cli/src/integrations/supabase/__tests__/provision-test.ts new file mode 100644 index 0000000000..557de7423d --- /dev/null +++ b/packages/eas-cli/src/integrations/supabase/__tests__/provision-test.ts @@ -0,0 +1,550 @@ +import { CombinedError } from '@urql/core'; +import openBrowserAsync from 'better-opn'; +import { GraphQLError } from 'graphql'; + +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { SupabaseMutation } from '../../../graphql/mutations/SupabaseMutation'; +import { SupabaseQuery } from '../../../graphql/queries/SupabaseQuery'; +import { + SupabaseConnectionData, + SupabaseOrganizationData, + SupabaseProjectData, +} from '../../../graphql/types/SupabaseConnection'; +import Log from '../../../log'; +import { selectAsync } from '../../../prompts'; +import { + BackgroundJobReceiptPollError, + BackgroundJobReceiptPollErrorType, + pollForBackgroundJobReceiptAsync, +} from '../../../utils/pollForBackgroundJobReceiptAsync'; +import { + additionalProvisionFailureHint, + authorizeViaBrowserAsync, + loadOrganizationsBestEffortAsync, + pollForConnectionAsync, + pollProvisionReceiptAsync, + primaryProvisionFailureHint, + projectNameSuffixForEnvironments, + resolveOrganizationAsync, + resolvePublishableKeyAsync, + resolveRegionAsync, + toProvisionPollError, +} from '../provision'; + +jest.mock('better-opn'); +jest.mock('../../../graphql/mutations/SupabaseMutation'); +jest.mock('../../../graphql/queries/SupabaseQuery'); +jest.mock('../../../prompts'); +jest.mock('../../../log'); +jest.mock('../../../utils/pollForBackgroundJobReceiptAsync', () => ({ + ...jest.requireActual('../../../utils/pollForBackgroundJobReceiptAsync'), + pollForBackgroundJobReceiptAsync: jest.fn(), +})); +jest.mock('../../../ora', () => ({ + ora: jest.fn(), +})); + +import { ora } from '../../../ora'; + +function mockOraSpinner(): { + start: jest.Mock; + succeed: jest.Mock; + fail: jest.Mock; + stop: jest.Mock; + text: string; +} { + const spinner = { + start: jest.fn().mockReturnThis(), + succeed: jest.fn().mockReturnThis(), + fail: jest.fn().mockReturnThis(), + stop: jest.fn().mockReturnThis(), + text: '', + }; + jest.mocked(ora).mockReturnValue(spinner as never); + return spinner; +} + +function userError(message: string): CombinedError { + return new CombinedError({ + graphQLErrors: [new GraphQLError(message, { extensions: { errorType: 'USER' } })], + }); +} + +function systemError(message: string): CombinedError { + return new CombinedError({ + graphQLErrors: [new GraphQLError(message, { extensions: { errorType: 'SYSTEM' } })], + }); +} + +const connection: SupabaseConnectionData = { + id: 'conn-1', + supabaseOrganizationSlug: 'org-slug', + supabaseOrganizationName: 'Org', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', +}; + +const project: SupabaseProjectData = { + id: 'project-1', + supabaseProjectRef: 'abcdefghijklmnop', + supabaseProjectName: 'Demo', + supabaseProjectUrl: 'https://abcdefghijklmnop.supabase.co', + supabaseRegion: 'us-east-1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', +}; + +describe('provision hints and poll errors', () => { + it('builds failure hints', () => { + expect(primaryProvisionFailureHint()).toContain('--link'); + const additional = additionalProvisionFailureHint(['preview']); + expect(additional).toContain('--environment preview'); + expect(additional).not.toContain('--link'); + expect(additional).toContain('EXPO_PUBLIC_SUPABASE_URL'); + }); + + it('toProvisionPollError preserves non-poll errors', () => { + const err = new Error('boom'); + expect(toProvisionPollError(err, { hint: 'hint' })).toBe(err); + expect(toProvisionPollError('string-err', { hint: '' }).message).toBe('string-err'); + }); + + it('toProvisionPollError wraps failed job messages with hints', () => { + const pollError = new BackgroundJobReceiptPollError({ + errorType: BackgroundJobReceiptPollErrorType.JOB_FAILED_NO_WILL_RETRY, + receiptErrorMessage: 'quota exceeded', + }); + expect(toProvisionPollError(pollError, { hint: 'try link' }).message).toContain( + 'quota exceeded' + ); + expect(toProvisionPollError(pollError, { hint: 'try link' }).message).toContain('try link'); + + const noMessage = new BackgroundJobReceiptPollError({ + errorType: BackgroundJobReceiptPollErrorType.JOB_FAILED_NO_WILL_RETRY, + receiptErrorMessage: null, + }); + expect(toProvisionPollError(noMessage, { hint: '' }).message).toContain( + 'Background job failed' + ); + }); + + it('toProvisionPollError wraps timeout/null receipt', () => { + const timeout = new BackgroundJobReceiptPollError({ + errorType: BackgroundJobReceiptPollErrorType.TIMEOUT, + }); + expect(toProvisionPollError(timeout, { hint: '' }).message).toContain('Timed out'); + + const nullReceipt = new BackgroundJobReceiptPollError({ + errorType: BackgroundJobReceiptPollErrorType.NULL_RECEIPT, + }); + expect(toProvisionPollError(nullReceipt, { hint: 'hint' }).message).toContain('hint'); + }); + + it('toProvisionPollError returns unrecognized poll errors unchanged', () => { + const pollError = new BackgroundJobReceiptPollError({ + errorType: BackgroundJobReceiptPollErrorType.TIMEOUT, + }); + (pollError as { errorData: { errorType: number } }).errorData = { errorType: 999 }; + expect(toProvisionPollError(pollError, { hint: 'hint' })).toBe(pollError); + }); +}); + +describe('pollProvisionReceiptAsync', () => { + const client = {} as ExpoGraphqlClient; + let spinner: ReturnType; + + beforeEach(() => { + jest.resetAllMocks(); + spinner = mockOraSpinner(); + }); + + it('returns the finalized receipt and stops its own spinner', async () => { + const receipt = { id: 'r1' } as never; + jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue(receipt); + + const result = await pollProvisionReceiptAsync(client, receipt, { + startMessage: 'start', + waitingMessage: 'wait', + failureMessage: 'fail', + failureHint: 'hint', + }); + expect(result).toBe(receipt); + expect(spinner.stop).toHaveBeenCalled(); + expect(spinner.succeed).not.toHaveBeenCalled(); + }); + + it('fails spinner when poll returns null', async () => { + jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue(null); + await expect( + pollProvisionReceiptAsync(client, { id: 'r1' } as never, { + startMessage: 'start', + waitingMessage: 'wait', + failureMessage: 'fail', + failureHint: 'hint', + }) + ).rejects.toThrow(/without a receipt/); + }); +}); + +describe('authorizeViaBrowserAsync / loadOrganizationsBestEffortAsync / pollForConnectionAsync', () => { + const client = {} as ExpoGraphqlClient; + const account = { id: 'acct-1', name: 'acct' }; + + beforeEach(() => { + jest.resetAllMocks(); + mockOraSpinner(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('authorizeViaBrowserAsync rejects non-interactive mode', async () => { + await expect(authorizeViaBrowserAsync(client, account, true)).rejects.toThrow( + /non-interactive/ + ); + }); + + it('authorizeViaBrowserAsync polls until connected', async () => { + jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({ + url: 'https://oauth.example', + }); + jest.mocked(openBrowserAsync).mockResolvedValue(true as never); + jest + .mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(connection); + jest + .mocked(SupabaseMutation.listSupabaseOrganizationsAsync) + .mockResolvedValue([{ id: '1', slug: 'org-slug', name: 'Org' }]); + + const promise = authorizeViaBrowserAsync(client, account, false); + await jest.advanceTimersByTimeAsync(2_000); + await expect(promise).resolves.toEqual(connection); + }); + + it('authorizeViaBrowserAsync shows URL when browser open fails', async () => { + jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({ + url: 'https://oauth.example', + }); + jest.mocked(openBrowserAsync).mockRejectedValue(new Error('no browser')); + jest.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync).mockResolvedValue(connection); + jest.mocked(SupabaseMutation.listSupabaseOrganizationsAsync).mockResolvedValue([]); + + await expect(authorizeViaBrowserAsync(client, account, false)).resolves.toEqual(connection); + expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Open this URL')); + }); + + it('authorizeViaBrowserAsync succeeds when organization listing fails', async () => { + jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({ + url: 'https://oauth.example', + }); + jest.mocked(openBrowserAsync).mockResolvedValue(true as never); + jest.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync).mockResolvedValue(connection); + jest + .mocked(SupabaseMutation.listSupabaseOrganizationsAsync) + .mockRejectedValue(new Error('org list unavailable')); + + const spinner = mockOraSpinner(); + await expect(authorizeViaBrowserAsync(client, account, false)).resolves.toEqual(connection); + expect(spinner.succeed).toHaveBeenCalledWith(expect.stringContaining('org-slug')); + }); + + it('authorizeViaBrowserAsync fails spinner on a non-retriable poll error', async () => { + jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({ + url: 'https://oauth.example', + }); + jest.mocked(openBrowserAsync).mockResolvedValue(true as never); + jest + .mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync) + .mockRejectedValue(userError('not authorized')); + + const spinner = mockOraSpinner(); + await expect(authorizeViaBrowserAsync(client, account, false)).rejects.toThrow( + /not authorized/ + ); + expect(spinner.fail).toHaveBeenCalled(); + }); + + it('authorizeViaBrowserAsync fails spinner when the authorization never arrives', async () => { + jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({ + url: 'https://oauth.example', + }); + jest.mocked(openBrowserAsync).mockResolvedValue(true as never); + jest.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync).mockResolvedValue(null); + + const spinner = mockOraSpinner(); + const promise = authorizeViaBrowserAsync(client, account, false); + promise.catch(() => undefined); + await jest.advanceTimersByTimeAsync(15 * 60 * 1_000 + 2_000); + await expect(promise).rejects.toThrow(/Timed out waiting for the Supabase connection/); + expect(spinner.fail).toHaveBeenCalled(); + }); + + it('loadOrganizationsBestEffortAsync returns null on failure', async () => { + jest + .mocked(SupabaseMutation.listSupabaseOrganizationsAsync) + .mockRejectedValue(new Error('nope')); + await expect(loadOrganizationsBestEffortAsync(client, 'acct-1')).resolves.toBeNull(); + }); + + it('pollForConnectionAsync retries after retriable query errors', async () => { + jest + .mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync) + .mockRejectedValueOnce(new CombinedError({ networkError: new Error('offline') })) + .mockResolvedValueOnce(connection); + + const promise = pollForConnectionAsync(client, 'acct-1'); + await jest.advanceTimersByTimeAsync(2_000); + await expect(promise).resolves.toEqual(connection); + expect(Log.debug).toHaveBeenCalled(); + }); + + it('pollForConnectionAsync stops on a non-retriable query error', async () => { + jest + .mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync) + .mockRejectedValue(userError('not authorized')); + + await expect(pollForConnectionAsync(client, 'acct-1')).rejects.toThrow(/not authorized/); + expect(SupabaseQuery.getSupabaseConnectionByAccountIdAsync).toHaveBeenCalledTimes(1); + }); + + it('pollForConnectionAsync keeps waiting through server-side errors', async () => { + jest + .mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync) + .mockRejectedValueOnce(systemError('boom')) + .mockResolvedValueOnce(connection); + + const promise = pollForConnectionAsync(client, 'acct-1'); + await jest.advanceTimersByTimeAsync(2_000); + await expect(promise).resolves.toEqual(connection); + }); + + it('pollForConnectionAsync surfaces an error that keeps repeating', async () => { + jest + .mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync) + .mockRejectedValue(new CombinedError({ networkError: new Error('offline') })); + + const promise = pollForConnectionAsync(client, 'acct-1'); + promise.catch(() => undefined); + await jest.advanceTimersByTimeAsync(10 * 2_000); + await expect(promise).rejects.toThrow(/offline/); + expect(SupabaseQuery.getSupabaseConnectionByAccountIdAsync).toHaveBeenCalledTimes(10); + }); + + it('pollForConnectionAsync forgives errors that stop repeating', async () => { + const query = jest.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync); + for (let i = 0; i < 9; i++) { + query.mockRejectedValueOnce(new CombinedError({ networkError: new Error('offline') })); + } + query.mockResolvedValueOnce(null); + for (let i = 0; i < 9; i++) { + query.mockRejectedValueOnce(new CombinedError({ networkError: new Error('offline') })); + } + query.mockResolvedValueOnce(connection); + + const promise = pollForConnectionAsync(client, 'acct-1'); + await jest.advanceTimersByTimeAsync(20 * 2_000); + await expect(promise).resolves.toEqual(connection); + }); +}); + +describe('resolvePublishableKeyAsync', () => { + const client = {} as ExpoGraphqlClient; + + beforeEach(() => { + jest.resetAllMocks(); + mockOraSpinner(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns key once available', async () => { + jest + .mocked(SupabaseMutation.fetchSupabasePublishableKeyAsync) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('pk_live'); + + const promise = resolvePublishableKeyAsync(client, 'app-1', project); + await jest.advanceTimersByTimeAsync(3_000); + await expect(promise).resolves.toBe('pk_live'); + }); + + it('stops on a non-retriable readiness error', async () => { + jest + .mocked(SupabaseMutation.fetchSupabasePublishableKeyAsync) + .mockRejectedValue(userError('revoked')); + + await expect(resolvePublishableKeyAsync(client, 'app-1', project)).rejects.toThrow(/revoked/); + expect(SupabaseMutation.fetchSupabasePublishableKeyAsync).toHaveBeenCalledTimes(1); + }); + + it('keeps waiting through retriable readiness errors', async () => { + jest + .mocked(SupabaseMutation.fetchSupabasePublishableKeyAsync) + .mockRejectedValueOnce(new CombinedError({ networkError: new Error('offline') })) + .mockResolvedValueOnce('pk_live'); + + const promise = resolvePublishableKeyAsync(client, 'app-1', project); + await jest.advanceTimersByTimeAsync(3_000); + await expect(promise).resolves.toBe('pk_live'); + }); + + it('gives up once a server fault keeps repeating', async () => { + jest + .mocked(SupabaseMutation.fetchSupabasePublishableKeyAsync) + .mockRejectedValue(systemError('upstream 502')); + + const promise = resolvePublishableKeyAsync(client, 'app-1', project); + promise.catch(() => undefined); + await jest.advanceTimersByTimeAsync(10 * 3_000); + await expect(promise).rejects.toThrow(/upstream 502/); + expect(SupabaseMutation.fetchSupabasePublishableKeyAsync).toHaveBeenCalledTimes(10); + }); + + it('keeps waiting through a server fault that resolves', async () => { + jest + .mocked(SupabaseMutation.fetchSupabasePublishableKeyAsync) + .mockRejectedValueOnce(systemError('upstream 502')) + .mockResolvedValueOnce('pk_live'); + + const promise = resolvePublishableKeyAsync(client, 'app-1', project); + await jest.advanceTimersByTimeAsync(3_000); + await expect(promise).resolves.toBe('pk_live'); + }); + + it('times out when key never becomes ready', async () => { + jest.mocked(SupabaseMutation.fetchSupabasePublishableKeyAsync).mockResolvedValue(null); + + const promise = resolvePublishableKeyAsync(client, 'app-1', project); + promise.catch(() => undefined); + await jest.advanceTimersByTimeAsync(5 * 60 * 1_000 + 3_000); + await expect(promise).rejects.toThrow(/still provisioning/); + }); +}); + +describe('resolveRegionAsync', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns flag value when provided', async () => { + await expect(resolveRegionAsync('emea', true)).resolves.toBe('emea'); + }); + + it('rejects a whitespace-only --region value', async () => { + await expect(resolveRegionAsync(' ', true)).rejects.toThrow(/Pass a Supabase region/); + }); + + it('throws in non-interactive mode without a flag', async () => { + await expect(resolveRegionAsync(undefined, true)).rejects.toThrow(/--region/); + }); + + it('prompts interactively when unset', async () => { + jest.mocked(selectAsync).mockResolvedValue('apac'); + await expect(resolveRegionAsync(undefined, false)).resolves.toBe('apac'); + }); +}); + +describe('resolveOrganizationAsync', () => { + const client = {} as ExpoGraphqlClient; + const organizations: SupabaseOrganizationData[] = [ + { id: '1', slug: 'org-slug', name: 'Org' }, + { id: '2', slug: 'other', name: 'Other' }, + ]; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns connection when flag matches current org', async () => { + await expect( + resolveOrganizationAsync(client, 'acct-1', connection, 'org-slug', true, organizations) + ).resolves.toEqual(connection); + }); + + it('sets organization when flag is a different connected org', async () => { + const updated = { ...connection, supabaseOrganizationSlug: 'other' }; + jest.mocked(SupabaseMutation.setSupabaseConnectionOrganizationAsync).mockResolvedValue(updated); + + await expect( + resolveOrganizationAsync(client, 'acct-1', connection, 'other', true, organizations) + ).resolves.toEqual(updated); + }); + + it('throws when flag org is unknown', async () => { + await expect( + resolveOrganizationAsync(client, 'acct-1', connection, 'missing', true, organizations) + ).rejects.toThrow(/isn't one of your connected organizations/); + }); + + it('returns connection in non-interactive mode without flag', async () => { + await expect( + resolveOrganizationAsync(client, 'acct-1', connection, undefined, true, organizations) + ).resolves.toEqual(connection); + }); + + it('returns connection when only one org exists', async () => { + await expect( + resolveOrganizationAsync(client, 'acct-1', connection, undefined, false, [organizations[0]]) + ).resolves.toEqual(connection); + }); + + it('prompts and updates when a different org is chosen', async () => { + const updated = { ...connection, supabaseOrganizationSlug: 'other' }; + jest.mocked(selectAsync).mockResolvedValue('other'); + jest.mocked(SupabaseMutation.setSupabaseConnectionOrganizationAsync).mockResolvedValue(updated); + + await expect( + resolveOrganizationAsync(client, 'acct-1', connection, undefined, false, organizations) + ).resolves.toEqual(updated); + }); + + it('prompts and keeps current org when reselected', async () => { + jest.mocked(selectAsync).mockResolvedValue('org-slug'); + await expect( + resolveOrganizationAsync(client, 'acct-1', connection, undefined, false, organizations) + ).resolves.toEqual(connection); + expect(SupabaseMutation.setSupabaseConnectionOrganizationAsync).not.toHaveBeenCalled(); + }); + + it('loads organizations when not preloaded', async () => { + jest.mocked(SupabaseMutation.listSupabaseOrganizationsAsync).mockResolvedValue(organizations); + const updated = { ...connection, supabaseOrganizationSlug: 'other' }; + jest.mocked(SupabaseMutation.setSupabaseConnectionOrganizationAsync).mockResolvedValue(updated); + + await expect( + resolveOrganizationAsync(client, 'acct-1', connection, 'other', true, null) + ).resolves.toEqual(updated); + expect(SupabaseMutation.listSupabaseOrganizationsAsync).toHaveBeenCalled(); + }); + + it('loads organizations interactively when not preloaded', async () => { + jest.mocked(SupabaseMutation.listSupabaseOrganizationsAsync).mockResolvedValue(organizations); + jest.mocked(selectAsync).mockResolvedValue('org-slug'); + + await expect( + resolveOrganizationAsync(client, 'acct-1', connection, undefined, false, null) + ).resolves.toEqual(connection); + expect(SupabaseMutation.listSupabaseOrganizationsAsync).toHaveBeenCalled(); + }); +}); + +describe('projectNameSuffixForEnvironments', () => { + it('sorts, joins, and truncates environment names', () => { + expect(projectNameSuffixForEnvironments(['preview', 'development'])).toBe( + 'development-preview' + ); + expect( + projectNameSuffixForEnvironments(['a'.repeat(40), 'b'.repeat(40)]).length + ).toBeLessThanOrEqual(32); + }); + + it('replaces characters outside [a-zA-Z0-9._-] with a dash', () => { + expect(projectNameSuffixForEnvironments(['a/b', 'c d'])).toBe('a-b-c-d'); + }); +}); 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..41d266f753 --- /dev/null +++ b/packages/eas-cli/src/integrations/supabase/env.ts @@ -0,0 +1,84 @@ +import { DefaultEnvironment } from '../../build/utils/environment'; +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { EnvVar, loadProjectScopedEnvVarsAsync } from '../../environments/variables'; +import { EnvironmentVariableVisibility } from '../../graphql/generated'; +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'; + +/** Label passed into shared env helpers for prompts and log lines. */ +export const SUPABASE_ENV_LABEL = 'Supabase'; + +// Production-first, matching the PostHog and Convex integrations. +export const EAS_SUPABASE_ENVIRONMENTS = [ + DefaultEnvironment.Production, + DefaultEnvironment.Preview, + DefaultEnvironment.Development, +]; + +export function supabaseMoveConfirmMessage( + variableName: string, + overlappingEnvironments: string +): string { + return `Move ${variableName} for ${overlappingEnvironments} to the additional Supabase project?`; +} + +export function supabaseEnvironmentCancelMessage(knownEnvironments: string): string { + return `Canceled. No additional Supabase project was provisioned. Create the environment(s) first, or pass only existing ones (known: ${knownEnvironments}).`; +} + +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 confirmOverwriteForAdditionalProjectAsync( + graphqlClient: ExpoGraphqlClient, + projectId: string, + environments: string[], + nonInteractive: boolean, + overwrite: boolean +): Promise<{ forceOverwrite: boolean }> { + if (overwrite) { + return { forceOverwrite: 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 loadProjectScopedEnvVarsAsync(graphqlClient, projectId, name); + 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 { forceOverwrite: true }; + } + return { forceOverwrite: false }; +} 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..4cc710d3d4 --- /dev/null +++ b/packages/eas-cli/src/integrations/supabase/provision.ts @@ -0,0 +1,344 @@ +import openBrowserAsync from 'better-opn'; +import chalk from 'chalk'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { + formatSupabaseOrganization, + getSupabaseProjectDashboardUrl, +} from '../../commandUtils/supabase'; +import { isPermanentGraphqlError } from '../../graphql/client'; +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 } from '../../ora'; +import { selectAsync } from '../../prompts'; +import { sleepAsync } from '../../utils/promise'; +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; +const MAX_CONSECUTIVE_CONNECTION_ERRORS = 10; + +// 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; +// A server fault is worth retrying, but not for the full timeout: once it repeats this many +// times it isn't a provisioning delay, and the user shouldn't wait five minutes to hear it. +const MAX_CONSECUTIVE_READINESS_ERRORS = 10; + +// 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 projectNameSuffixForEnvironments(environments: string[]): string { + return [...environments] + .sort() + .join('-') + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .slice(0, 32); +} + +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 [ + 'Free a slot on your Supabase plan (delete, pause, or upgrade a project), then re-run:', + ` eas integrations:supabase:connect --environment ${environments.join(',')}`, + 'Or set EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY on those EAS environments to an existing Supabase project.', + ].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 { + 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.'); + } + // Stopped, not succeeded: the caller resolves what was provisioned and owns that message. + spinner.stop(); + return finalized; + } 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; + let consecutiveErrors = 0; + for (;;) { + let connection: SupabaseConnectionData | null = null; + try { + connection = await SupabaseQuery.getSupabaseConnectionByAccountIdAsync( + graphqlClient, + accountId, + { useCache: false } + ); + consecutiveErrors = 0; + } catch (error) { + consecutiveErrors += 1; + Log.debug(`Polling for the Supabase connection failed: ${error}`); + if ( + isPermanentGraphqlError(error) || + consecutiveErrors >= MAX_CONSECUTIVE_CONNECTION_ERRORS + ) { + Log.error( + 'Gave up checking whether the Supabase authorization finished. Fix the error below, then re-run `eas integrations:supabase:connect` — an authorization you already approved is still picked up.' + ); + throw 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 sleepAsync(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, and throws for a real + // problem. A rejected request can't succeed on a retry; a server fault might, but not forever. + 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: ${error}`); + if (isPermanentGraphqlError(error) || consecutiveErrors >= MAX_CONSECUTIVE_READINESS_ERRORS) { + spinner.fail("Couldn't reach the Supabase project"); + Log.error( + `The project may exist even though EAS can't read its key. Check it at ${getSupabaseProjectDashboardUrl( + project + )} before you re-run \`eas integrations:supabase:connect\`.` + ); + 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 sleepAsync(READINESS_POLL_INTERVAL_MS); + } +} + +export async function resolveRegionAsync( + flagValue: string | undefined, + nonInteractive: boolean +): Promise { + if (flagValue !== undefined) { + // The server accepts both the smart-group values (americas | emea | apac) and raw region + // codes (e.g. us-east-1), so any non-empty value passes through. + const region = flagValue.trim(); + if (!region) { + throw new Error( + 'Pass a Supabase region to --region (americas, emea, apac, or a raw code like us-east-1).' + ); + } + return region; + } + 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/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, diff --git a/packages/eas-cli/src/utils/prompts.ts b/packages/eas-cli/src/utils/prompts.ts index 53da35f6a4..b081b129d7 100644 --- a/packages/eas-cli/src/utils/prompts.ts +++ b/packages/eas-cli/src/utils/prompts.ts @@ -1,29 +1,12 @@ import chalk from 'chalk'; -import { DefaultEnvironment } from '../build/utils/environment'; import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; +import { DEFAULT_ENVIRONMENTS } from '../environments/defaults'; +import { getProjectEnvironmentVariableEnvironmentsAsync } from '../environments/variables'; import { EnvironmentSecretType, EnvironmentVariableVisibility } from '../graphql/generated'; -import { EnvironmentVariablesQuery } from '../graphql/queries/EnvironmentVariablesQuery'; import { RequestedPlatform } from '../platform'; import { promptAsync, selectAsync } from '../prompts'; -const DEFAULT_ENVIRONMENTS = Object.values(DefaultEnvironment); - -export async function getProjectEnvironmentVariableEnvironmentsAsync( - graphqlClient: ExpoGraphqlClient, - projectId: string -): Promise { - try { - const environments = await EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync( - graphqlClient, - projectId - ); - return environments; - } catch { - throw new Error('Failed to fetch available environments'); - } -} - const CUSTOM_ENVIRONMENT_VALUE = '~~CUSTOM~~'; export async function promptVariableTypeAsync(