From 1196747390a8057b7c110e892b39073900db76d1 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Fri, 31 Jul 2026 17:46:15 -0700 Subject: [PATCH 1/9] [eas-cli] Add Supabase integration foundation (GraphQL, shared helpers, provisioning) --- CHANGELOG.md | 1 + .../commandUtils/__tests__/supabase-test.ts | 151 ++++++ packages/eas-cli/src/commandUtils/supabase.ts | 95 ++++ .../environments/__tests__/resolve-test.ts | 151 ++++++ packages/eas-cli/src/environments/defaults.ts | 3 + packages/eas-cli/src/environments/resolve.ts | 80 ++++ .../eas-cli/src/environments/variables.ts | 206 ++++++++ packages/eas-cli/src/graphql/generated.ts | 79 ++++ .../src/graphql/mutations/SupabaseMutation.ts | 269 +++++++++++ .../__tests__/SupabaseMutation-test.ts | 141 ++++++ .../src/graphql/queries/SupabaseQuery.ts | 97 ++++ .../queries/__tests__/SupabaseQuery-test.ts | 101 ++++ .../src/graphql/types/SupabaseConnection.ts | 58 +++ .../src/integrations/shared/envFile.ts | 73 +++ .../eas-cli/src/integrations/shared/sdk.ts | 117 +++++ .../supabase/__tests__/env-test.ts | 438 ++++++++++++++++++ .../supabase/__tests__/environments-test.ts | 42 ++ .../supabase/__tests__/provision-test.ts | 419 +++++++++++++++++ .../supabase/__tests__/sdk-test.ts | 186 ++++++++ .../eas-cli/src/integrations/supabase/env.ts | 119 +++++ .../src/integrations/supabase/environments.ts | 29 ++ .../src/integrations/supabase/provision.ts | 322 +++++++++++++ .../eas-cli/src/integrations/supabase/sdk.ts | 51 ++ .../pollForBackgroundJobReceiptAsync-test.ts | 35 ++ .../utils/pollForBackgroundJobReceiptAsync.ts | 18 +- packages/eas-cli/src/utils/prompts.ts | 8 +- 26 files changed, 3282 insertions(+), 7 deletions(-) create mode 100644 packages/eas-cli/src/commandUtils/__tests__/supabase-test.ts create mode 100644 packages/eas-cli/src/commandUtils/supabase.ts create mode 100644 packages/eas-cli/src/environments/__tests__/resolve-test.ts create mode 100644 packages/eas-cli/src/environments/defaults.ts create mode 100644 packages/eas-cli/src/environments/resolve.ts create mode 100644 packages/eas-cli/src/environments/variables.ts create mode 100644 packages/eas-cli/src/graphql/mutations/SupabaseMutation.ts create mode 100644 packages/eas-cli/src/graphql/mutations/__tests__/SupabaseMutation-test.ts create mode 100644 packages/eas-cli/src/graphql/queries/SupabaseQuery.ts create mode 100644 packages/eas-cli/src/graphql/queries/__tests__/SupabaseQuery-test.ts create mode 100644 packages/eas-cli/src/graphql/types/SupabaseConnection.ts create mode 100644 packages/eas-cli/src/integrations/shared/envFile.ts create mode 100644 packages/eas-cli/src/integrations/shared/sdk.ts create mode 100644 packages/eas-cli/src/integrations/supabase/__tests__/env-test.ts create mode 100644 packages/eas-cli/src/integrations/supabase/__tests__/environments-test.ts create mode 100644 packages/eas-cli/src/integrations/supabase/__tests__/provision-test.ts create mode 100644 packages/eas-cli/src/integrations/supabase/__tests__/sdk-test.ts create mode 100644 packages/eas-cli/src/integrations/supabase/env.ts create mode 100644 packages/eas-cli/src/integrations/supabase/environments.ts create mode 100644 packages/eas-cli/src/integrations/supabase/provision.ts create mode 100644 packages/eas-cli/src/integrations/supabase/sdk.ts 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..6b018e94cc --- /dev/null +++ b/packages/eas-cli/src/commandUtils/__tests__/supabase-test.ts @@ -0,0 +1,151 @@ +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('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/); + }); +}); diff --git a/packages/eas-cli/src/commandUtils/supabase.ts b/packages/eas-cli/src/commandUtils/supabase.ts new file mode 100644 index 0000000000..0c8b367cf4 --- /dev/null +++ b/packages/eas-cli/src/commandUtils/supabase.ts @@ -0,0 +1,95 @@ +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.` + ); + } + return ref; +} + +/** 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..85cb36f02c --- /dev/null +++ b/packages/eas-cli/src/environments/__tests__/resolve-test.ts @@ -0,0 +1,151 @@ +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, label: 'Example' }; + + 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\. No additional Example project was provisioned\./); + }); + + 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/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..619251951c --- /dev/null +++ b/packages/eas-cli/src/environments/resolve.ts @@ -0,0 +1,80 @@ +import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; +import { confirmAsync } from '../prompts'; +import { getProjectEnvironmentVariableEnvironmentsAsync } from '../utils/prompts'; + +export function parseEnvironmentFlag(value: string | undefined): string[] | null { + if (value === undefined) { + return null; + } + if (!value.trim()) { + throw new Error( + 'Pass at least one EAS environment to --environment (e.g. --environment preview).' + ); + } + const environments = [ + ...new Set( + value + .split(',') + .map(part => part.trim().toLowerCase()) + .filter(Boolean) + ), + ]; + if (environments.length === 0) { + throw new Error( + 'Pass at least one EAS environment to --environment (e.g. --environment preview).' + ); + } + for (const environment of environments) { + if (environment.length < 3 || environment.length > 100 || !/^[a-z0-9_-]+$/.test(environment)) { + throw new Error( + `Invalid EAS environment "${environment}". Use 3–100 lowercase letters, numbers, dashes, or underscores.` + ); + } + } + return environments; +} + +export async function resolveTargetEnvironmentsAsync( + graphqlClient: ExpoGraphqlClient, + projectId: string, + requested: string[], + nonInteractive: boolean, + { defaultEnvironments, label }: { defaultEnvironments: string[]; label: 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( + `Canceled. No additional ${label} project was provisioned. Create the environment(s) first, or pass only existing ones (known: ${known.join(', ')}).` + ); + } + // Custom environments are created lazily when env vars are written (createForAppAsync). + return requested; +} diff --git a/packages/eas-cli/src/environments/variables.ts b/packages/eas-cli/src/environments/variables.ts new file mode 100644 index 0000000000..10b353db01 --- /dev/null +++ b/packages/eas-cli/src/environments/variables.ts @@ -0,0 +1,206 @@ +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]; + +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 upsertEasEnvVarAsync( + graphqlClient: ExpoGraphqlClient, + projectId: string, + envVar: EnvVar, + environments: string[], + nonInteractive: boolean, + overwrite: boolean +): Promise { + const existingProjectVariables = await loadProjectScopedEnvVarsAsync( + graphqlClient, + projectId, + envVar.name + ); + + if (existingProjectVariables.length === 0) { + await EnvironmentVariableMutation.createForAppAsync( + graphqlClient, + { + name: envVar.name, + value: envVar.value, + environments, + visibility: envVar.visibility, + type: EnvironmentSecretType.String, + }, + projectId + ); + Log.withTick( + `Created EAS environment variable ${chalk.bold(envVar.name)} for ${environments.join(', ')}` + ); + return true; + } + + const [keeper, ...extras] = existingProjectVariables; + const extraEnvironments = [...new Set(extras.flatMap(variable => variable.environments ?? []))]; + const shouldOverwrite = + overwrite || + (!nonInteractive && + (await confirmAsync({ + message: + extras.length > 0 + ? `EAS has multiple ${envVar.name} variables for this project (including ${extraEnvironments.join(', ') || 'other environments'}). Replace them with one value for ${environments.join(', ')}?` + : `EAS already has an ${envVar.name} environment variable for this project. Overwrite it?`, + }))); + if (!shouldOverwrite) { + Log.warn( + `Skipped updating EAS environment variable ${chalk.bold(envVar.name)}${ + nonInteractive ? ' (pass --overwrite to replace it)' : '' + }.` + ); + return false; + } + + for (const extra of extras) { + await EnvironmentVariableMutation.deleteAsync(graphqlClient, extra.id); + } + await EnvironmentVariableMutation.updateAsync(graphqlClient, { + id: keeper.id, + name: envVar.name, + value: envVar.value, + environments, + visibility: envVar.visibility, + type: EnvironmentSecretType.String, + }); + Log.withTick( + `Updated EAS environment variable ${chalk.bold(envVar.name)} for ${environments.join(', ')}` + ); + return true; +} + +export async function upsertEasEnvVarForEnvironmentsAsync( + graphqlClient: ExpoGraphqlClient, + projectId: string, + envVar: EnvVar, + environments: string[], + nonInteractive: boolean, + overwrite: boolean, + { label }: { label: string } +): Promise { + const existingVariables = await loadProjectScopedEnvVarsAsync( + graphqlClient, + projectId, + envVar.name + ); + + const targetSet = new Set(environments); + const exactMatch = existingVariables.find(variable => { + const current = variable.environments ?? []; + return ( + current.length === targetSet.size && current.every(environment => targetSet.has(environment)) + ); + }); + if (exactMatch) { + const shouldOverwrite = + overwrite || + exactMatch.value === envVar.value || + (!nonInteractive && + (await confirmAsync({ + message: `EAS already has ${envVar.name} for ${environments.join(', ')}. Overwrite it?`, + }))); + if (!shouldOverwrite) { + Log.warn(`Skipped updating EAS environment variable ${chalk.bold(envVar.name)}.`); + return false; + } + await EnvironmentVariableMutation.updateAsync(graphqlClient, { + id: exactMatch.id, + name: envVar.name, + value: envVar.value, + environments, + visibility: envVar.visibility, + type: EnvironmentSecretType.String, + }); + Log.withTick( + `Updated EAS environment variable ${chalk.bold(envVar.name)} for ${environments.join(', ')}` + ); + return true; + } + + const toDelete: { id: string; overlap: string[] }[] = []; + const toShrink: { id: string; overlap: string[]; remaining: string[] }[] = []; + for (const variable of existingVariables) { + const current = variable.environments ?? []; + const overlap = current.filter(environment => targetSet.has(environment)); + if (overlap.length === 0) { + continue; + } + const remaining = current.filter(environment => !targetSet.has(environment)); + if (remaining.length === 0) { + toDelete.push({ id: variable.id, overlap }); + } else { + toShrink.push({ id: variable.id, overlap, remaining }); + } + } + + if ((toDelete.length > 0 || toShrink.length > 0) && !overwrite) { + const overlapLabel = [ + ...new Set([...toDelete, ...toShrink].flatMap(item => item.overlap)), + ].join(', '); + const shouldOverwrite = + !nonInteractive && + (await confirmAsync({ + message: `Move ${envVar.name} for ${overlapLabel} to the additional ${label} project?`, + })); + if (!shouldOverwrite) { + Log.warn(`Skipped updating EAS environment variable ${chalk.bold(envVar.name)}.`); + return false; + } + } + + for (const item of toDelete) { + await EnvironmentVariableMutation.deleteAsync(graphqlClient, item.id); + } + for (const item of toShrink) { + await EnvironmentVariableMutation.updateAsync(graphqlClient, { + id: item.id, + environments: item.remaining, + }); + } + + await EnvironmentVariableMutation.createForAppAsync( + graphqlClient, + { + name: envVar.name, + value: envVar.value, + environments, + visibility: envVar.visibility, + type: EnvironmentSecretType.String, + }, + projectId + ); + Log.withTick( + `Created EAS environment variable ${chalk.bold(envVar.name)} for ${environments.join(', ')}` + ); + return true; +} diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index a87dd8a12a..7ca688ad35 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -14339,6 +14339,68 @@ 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', state: string, 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; @@ -15069,6 +15131,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; @@ -15414,6 +15490,9 @@ export type SubmissionWithSubmittedBuildFragment = { __typename?: 'Submission', | { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __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 } diff --git a/packages/eas-cli/src/graphql/mutations/SupabaseMutation.ts b/packages/eas-cli/src/graphql/mutations/SupabaseMutation.ts new file mode 100644 index 0000000000..f82099214c --- /dev/null +++ b/packages/eas-cli/src/graphql/mutations/SupabaseMutation.ts @@ -0,0 +1,269 @@ +import { print } from 'graphql'; +import gql from 'graphql-tag'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { withErrorHandlingAsync } from '../client'; +import { BackgroundJobReceiptDataFragment } from '../generated'; +import { BackgroundJobReceiptNode } from '../types/BackgroundJobReceipt'; +import { + BeginSupabaseOAuthInput, + LinkSupabaseProjectInput, + ProvisionAdditionalSupabaseProjectInput, + ProvisionSupabaseProjectInput, + SetSupabaseConnectionOrganizationInput, + SupabaseConnectionData, + SupabaseConnectionFragmentNode, + SupabaseOAuthStartData, + SupabaseOrganizationData, + SupabaseProjectData, + SupabaseProjectFragmentNode, +} from '../types/SupabaseConnection'; + +export const SupabaseMutation = { + async beginSupabaseOAuthAsync( + graphqlClient: ExpoGraphqlClient, + input: BeginSupabaseOAuthInput + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation< + { supabaseConnection: { beginSupabaseOAuth: SupabaseOAuthStartData } }, + { input: BeginSupabaseOAuthInput } + >( + gql` + mutation BeginSupabaseOAuth($input: BeginSupabaseOAuthInput!) { + supabaseConnection { + beginSupabaseOAuth(input: $input) { + state + url + } + } + } + `, + { input } + ) + .toPromise() + ); + return data.supabaseConnection.beginSupabaseOAuth; + }, + + async setSupabaseConnectionOrganizationAsync( + graphqlClient: ExpoGraphqlClient, + input: SetSupabaseConnectionOrganizationInput + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation< + { + supabaseConnection: { setSupabaseConnectionOrganization: SupabaseConnectionData }; + }, + { input: SetSupabaseConnectionOrganizationInput } + >( + gql` + mutation SetSupabaseConnectionOrganization( + $input: SetSupabaseConnectionOrganizationInput! + ) { + supabaseConnection { + setSupabaseConnectionOrganization(input: $input) { + id + ...SupabaseConnectionFragment + } + } + } + ${print(SupabaseConnectionFragmentNode)} + `, + { input }, + { additionalTypenames: ['SupabaseConnection'] } + ) + .toPromise() + ); + return data.supabaseConnection.setSupabaseConnectionOrganization; + }, + + async disconnectSupabaseAsync(graphqlClient: ExpoGraphqlClient, id: string): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation<{ supabaseConnection: { disconnectSupabase: string } }, { id: string }>( + gql` + mutation DisconnectSupabase($id: ID!) { + supabaseConnection { + disconnectSupabase(id: $id) + } + } + `, + { id }, + { additionalTypenames: ['Account', 'SupabaseConnection', 'SupabaseProject'] } + ) + .toPromise() + ); + return data.supabaseConnection.disconnectSupabase; + }, + + async provisionSupabaseProjectAsync( + graphqlClient: ExpoGraphqlClient, + input: ProvisionSupabaseProjectInput + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation< + { supabaseProject: { provisionSupabaseProject: BackgroundJobReceiptDataFragment } }, + { input: ProvisionSupabaseProjectInput } + >( + gql` + mutation ProvisionSupabaseProject($input: ProvisionSupabaseProjectInput!) { + supabaseProject { + provisionSupabaseProject(input: $input) { + id + ...BackgroundJobReceiptData + } + } + } + ${print(BackgroundJobReceiptNode)} + `, + { input }, + { additionalTypenames: ['App', 'SupabaseProject', 'BackgroundJobReceipt'] } + ) + .toPromise() + ); + return data.supabaseProject.provisionSupabaseProject; + }, + + async provisionAdditionalSupabaseProjectAsync( + graphqlClient: ExpoGraphqlClient, + input: ProvisionAdditionalSupabaseProjectInput + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation< + { + supabaseProject: { + provisionAdditionalSupabaseProject: BackgroundJobReceiptDataFragment; + }; + }, + { input: ProvisionAdditionalSupabaseProjectInput } + >( + gql` + mutation ProvisionAdditionalSupabaseProject( + $input: ProvisionAdditionalSupabaseProjectInput! + ) { + supabaseProject { + provisionAdditionalSupabaseProject(input: $input) { + id + ...BackgroundJobReceiptData + } + } + } + ${print(BackgroundJobReceiptNode)} + `, + { input }, + { additionalTypenames: ['App', 'SupabaseProject', 'BackgroundJobReceipt'] } + ) + .toPromise() + ); + return data.supabaseProject.provisionAdditionalSupabaseProject; + }, + + async linkSupabaseProjectAsync( + graphqlClient: ExpoGraphqlClient, + input: LinkSupabaseProjectInput + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation< + { supabaseProject: { linkSupabaseProject: SupabaseProjectData } }, + { input: LinkSupabaseProjectInput } + >( + gql` + mutation LinkSupabaseProject($input: LinkSupabaseProjectInput!) { + supabaseProject { + linkSupabaseProject(input: $input) { + id + ...SupabaseProjectFragment + } + } + } + ${print(SupabaseProjectFragmentNode)} + `, + { input }, + { additionalTypenames: ['App', 'SupabaseProject'] } + ) + .toPromise() + ); + return data.supabaseProject.linkSupabaseProject; + }, + + async deleteSupabaseProjectAsync(graphqlClient: ExpoGraphqlClient, id: string): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation<{ supabaseProject: { deleteSupabaseProject: string } }, { id: string }>( + gql` + mutation DeleteSupabaseProject($id: ID!) { + supabaseProject { + deleteSupabaseProject(id: $id) + } + } + `, + { id }, + { additionalTypenames: ['App', 'SupabaseProject'] } + ) + .toPromise() + ); + return data.supabaseProject.deleteSupabaseProject; + }, + + async listSupabaseOrganizationsAsync( + graphqlClient: ExpoGraphqlClient, + accountId: string + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation< + { + supabaseConnection: { + listSupabaseOrganizations: SupabaseOrganizationData[]; + }; + }, + { accountId: string } + >( + gql` + mutation ListSupabaseOrganizations($accountId: ID!) { + supabaseConnection { + listSupabaseOrganizations(accountId: $accountId) { + id + slug + name + } + } + } + `, + { accountId } + ) + .toPromise() + ); + return data.supabaseConnection.listSupabaseOrganizations; + }, + + async fetchSupabasePublishableKeyAsync( + graphqlClient: ExpoGraphqlClient, + appId: string + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation< + { supabaseProject: { fetchSupabasePublishableKey: string | null } }, + { appId: string } + >( + gql` + mutation FetchSupabasePublishableKey($appId: ID!) { + supabaseProject { + fetchSupabasePublishableKey(appId: $appId) + } + } + `, + { appId } + ) + .toPromise() + ); + return data.supabaseProject.fetchSupabasePublishableKey; + }, +}; diff --git a/packages/eas-cli/src/graphql/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..3ee1bbda46 --- /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/envFile.ts b/packages/eas-cli/src/integrations/shared/envFile.ts new file mode 100644 index 0000000000..f4c0a6d70a --- /dev/null +++ b/packages/eas-cli/src/integrations/shared/envFile.ts @@ -0,0 +1,73 @@ +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; +} + +export function mergeEnvContent(rawContent: string, newVars: Record): string { + let content = rawContent; + const keysToAdd: Record = { ...newVars }; + for (const [key, value] of Object.entries(newVars)) { + const regex = new RegExp(`^${key}=.*$`, 'm'); + if (regex.test(content)) { + content = content.replace(regex, () => `${key}=${value}`); + delete keysToAdd[key]; + } + } + const remaining = Object.entries(keysToAdd); + if (remaining.length > 0) { + if (content.length > 0 && !content.endsWith('\n')) { + content += '\n'; + } + for (const [key, value] of remaining) { + content += `${key}=${value}\n`; + } + } + return content; +} 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..a1d684bd6f --- /dev/null +++ b/packages/eas-cli/src/integrations/shared/sdk.ts @@ -0,0 +1,117 @@ +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' } + | { status: 'failed' } + | { status: 'installed'; dynamicConfigGuidance: string }; + +export function getSpawnErrorOutput(error: unknown): string { + const { stdout, stderr } = (error ?? {}) as { stdout?: string; stderr?: string }; + return `${stdout ?? ''}${stderr ?? ''}`; +} + +export function extractDynamicConfigGuidance(output: string): string | null { + const index = output.indexOf(DYNAMIC_CONFIG_MARKER); + if (index === -1) { + return null; + } + return output.slice(index).trim(); +} + +export function envForExpoInstall(): NodeJS.ProcessEnv { + const env = { ...process.env }; + delete env.EXPO_LOCAL; + delete env.EXPO_STAGING; + delete env.EXPO_UNIVERSE_DIR; + return env; +} + +export async function installSdkPackagesAsync( + projectDir: string, + { 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 }); + const manualSteps: string[] = []; + if (installResult.status === 'failed') { + manualSteps.push( + `The ${label} SDK packages didn't install. Run npx expo install ${packages.join(' ')} from your project directory.` + ); + } + if (installResult.status === 'installed' && 'dynamicConfigGuidance' in installResult) { + manualSteps.push(installResult.dynamicConfigGuidance); + } else { + const pluginManualStep = await addConfigPluginAsync(projectDir, exp, { plugin }); + if (pluginManualStep) { + manualSteps.push(pluginManualStep); + } + } + return manualSteps; +} 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..7d0e1f6210 --- /dev/null +++ b/packages/eas-cli/src/integrations/supabase/__tests__/env-test.ts @@ -0,0 +1,438 @@ +import * as fs from 'fs-extra'; + +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 { + EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME, + EAS_SUPABASE_URL_ENV_VAR_NAME, + createSupabaseEnvVars, + ensureAdditionalEnvWritesAllowedAsync, + mergeEnvContent, + upsertEasEnvVarAsync, + upsertEasEnvVarForEnvironmentsAsync, + writeEnvLocalAsync, + writeEnvVarsAsync, +} from '../env'; + +jest.mock('fs-extra'); +jest.mock('../../../graphql/mutations/EnvironmentVariableMutation'); +jest.mock('../../../graphql/queries/EnvironmentVariablesQuery'); +jest.mock('../../../prompts'); +jest.mock('../../../log'); + +describe('createSupabaseEnvVars / mergeEnvContent / writeEnvVarsAsync', () => { + it('createSupabaseEnvVars 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('mergeEnvContent 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('writeEnvVarsAsync runs upsert for each var', async () => { + const upsert = jest.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false); + await expect( + writeEnvVarsAsync( + [ + { + name: 'A', + value: '1', + visibility: EnvironmentVariableVisibility.Public, + }, + { + name: 'B', + value: '2', + visibility: EnvironmentVariableVisibility.Public, + }, + ], + upsert + ) + ).resolves.toEqual([true, false]); + }); +}); + +describe('writeEnvLocalAsync', () => { + const envVars = createSupabaseEnvVars('https://example.supabase.co', 'pk'); + + 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, true, false)).resolves.toBe(true); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('.env.local'), + expect.stringContaining(EAS_SUPABASE_URL_ENV_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(`${EAS_SUPABASE_URL_ENV_VAR_NAME}=old\n` as never); + + await expect(writeEnvLocalAsync('/project', envVars, true, 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(`${EAS_SUPABASE_URL_ENV_VAR_NAME}=old\n` as never); + jest.mocked(confirmAsync).mockResolvedValue(false); + + await expect(writeEnvLocalAsync('/project', envVars, false, 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(`${EAS_SUPABASE_URL_ENV_VAR_NAME}=old\n` as never); + jest.mocked(confirmAsync).mockResolvedValue(true); + + await expect(writeEnvLocalAsync('/project', envVars, false, false)).resolves.toBe(true); + await expect(writeEnvLocalAsync('/project', envVars, true, true)).resolves.toBe(true); + expect(fs.writeFile).toHaveBeenCalled(); + }); +}); + +describe('upsertEasEnvVarAsync', () => { + const client = {} as ExpoGraphqlClient; + const envVar = createSupabaseEnvVars('https://example.supabase.co', 'pk')[0]; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('creates when no project-scoped variable exists', async () => { + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([]); + jest.mocked(EnvironmentVariableMutation.createForAppAsync).mockResolvedValue({} as never); + + await expect( + upsertEasEnvVarAsync(client, 'app-1', envVar, ['production'], true, false) + ).resolves.toBe(true); + expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled(); + }); + + it('skips existing variable without overwrite in non-interactive mode', async () => { + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ + { + id: 'v1', + scope: EnvironmentVariableScope.Project, + environments: ['production'], + } as never, + ]); + + await expect( + upsertEasEnvVarAsync(client, 'app-1', envVar, ['production'], true, false) + ).resolves.toBe(false); + expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('--overwrite')); + }); + + it('updates existing variable and deletes extras when overwriting', async () => { + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ + { + id: 'keeper', + scope: EnvironmentVariableScope.Project, + environments: ['production'], + }, + { + id: 'extra', + scope: EnvironmentVariableScope.Project, + environments: ['preview'], + }, + ] as never); + jest.mocked(EnvironmentVariableMutation.deleteAsync).mockResolvedValue({} as never); + jest.mocked(EnvironmentVariableMutation.updateAsync).mockResolvedValue({} as never); + + await expect( + upsertEasEnvVarAsync(client, 'app-1', envVar, ['production', 'preview'], true, true) + ).resolves.toBe(true); + expect(EnvironmentVariableMutation.deleteAsync).toHaveBeenCalledWith(client, 'extra'); + expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith( + client, + expect.objectContaining({ id: 'keeper' }) + ); + }); + + it('prompts with multi-variable message interactively', async () => { + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ + { + id: 'keeper', + scope: EnvironmentVariableScope.Project, + environments: ['production'], + }, + { + id: 'extra', + scope: EnvironmentVariableScope.Project, + environments: ['preview'], + }, + ] as never); + jest.mocked(confirmAsync).mockResolvedValue(true); + jest.mocked(EnvironmentVariableMutation.deleteAsync).mockResolvedValue({} as never); + jest.mocked(EnvironmentVariableMutation.updateAsync).mockResolvedValue({} as never); + + await expect( + upsertEasEnvVarAsync(client, 'app-1', envVar, ['production'], false, false) + ).resolves.toBe(true); + expect(confirmAsync).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('multiple') }) + ); + }); +}); + +describe('ensureAdditionalEnvWritesAllowedAsync', () => { + const client = {} as ExpoGraphqlClient; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns true immediately with overwrite', async () => { + await expect( + ensureAdditionalEnvWritesAllowedAsync(client, 'app-1', ['preview'], true, true) + ).resolves.toBe(true); + expect(EnvironmentVariablesQuery.byAppIdAsync).not.toHaveBeenCalled(); + }); + + it('returns false when there is no overlap', async () => { + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([]); + await expect( + ensureAdditionalEnvWritesAllowedAsync(client, 'app-1', ['preview'], true, false) + ).resolves.toBe(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( + ensureAdditionalEnvWritesAllowedAsync(client, 'app-1', ['preview'], true, false) + ).rejects.toThrow(/Re-run with --overwrite/); + }); + + it('returns true 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( + ensureAdditionalEnvWritesAllowedAsync(client, 'app-1', ['preview'], false, false) + ).resolves.toBe(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( + ensureAdditionalEnvWritesAllowedAsync(client, 'app-1', ['preview'], false, false) + ).rejects.toThrow(/Canceled/); + }); +}); + +describe('upsertEasEnvVarForEnvironmentsAsync', () => { + const client = {} as ExpoGraphqlClient; + const envVar = createSupabaseEnvVars('https://example.supabase.co', 'pk')[0]; + + beforeEach(() => { + jest.resetAllMocks(); + jest.mocked(EnvironmentVariableMutation.createForAppAsync).mockResolvedValue({} as never); + jest.mocked(EnvironmentVariableMutation.updateAsync).mockResolvedValue({} as never); + jest.mocked(EnvironmentVariableMutation.deleteAsync).mockResolvedValue({} as never); + }); + + it('updates an exact environment match', async () => { + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ + { + id: 'exact', + scope: EnvironmentVariableScope.Project, + environments: ['preview'], + value: 'old', + } as never, + ]); + + await expect( + upsertEasEnvVarForEnvironmentsAsync(client, 'app-1', envVar, ['preview'], true, true) + ).resolves.toBe(true); + expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith( + client, + expect.objectContaining({ id: 'exact' }) + ); + }); + + it('skips exact match without overwrite when values differ', async () => { + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ + { + id: 'exact', + scope: EnvironmentVariableScope.Project, + environments: ['preview'], + value: 'old', + } as never, + ]); + + await expect( + upsertEasEnvVarForEnvironmentsAsync(client, 'app-1', envVar, ['preview'], true, false) + ).resolves.toBe(false); + }); + + it('auto-overwrites exact match when values already match', async () => { + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ + { + id: 'exact', + scope: EnvironmentVariableScope.Project, + environments: ['preview'], + value: envVar.value, + } as never, + ]); + + await expect( + upsertEasEnvVarForEnvironmentsAsync(client, 'app-1', envVar, ['preview'], true, false) + ).resolves.toBe(true); + }); + + it('creates after shrinking overlapping environments', async () => { + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ + { + id: 'shared', + scope: EnvironmentVariableScope.Project, + environments: ['production', 'preview'], + value: 'old', + } as never, + ]); + + await expect( + upsertEasEnvVarForEnvironmentsAsync(client, 'app-1', envVar, ['preview'], true, true) + ).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('skips when overlapping move is declined', async () => { + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ + { + id: 'shared', + scope: EnvironmentVariableScope.Project, + environments: ['production', 'preview'], + value: 'old', + } as never, + ]); + jest.mocked(confirmAsync).mockResolvedValue(false); + + await expect( + upsertEasEnvVarForEnvironmentsAsync(client, 'app-1', envVar, ['preview'], false, false) + ).resolves.toBe(false); + expect(confirmAsync).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('to the additional Supabase project?'), + }) + ); + }); + + it('deletes variables fully covered by the target environments', async () => { + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ + { + id: 'preview-only', + scope: EnvironmentVariableScope.Project, + environments: ['preview'], + value: 'old', + }, + ] as never); + + await expect( + upsertEasEnvVarForEnvironmentsAsync( + client, + 'app-1', + envVar, + ['preview', 'development'], + true, + true + ) + ).resolves.toBe(true); + expect(EnvironmentVariableMutation.deleteAsync).toHaveBeenCalledWith(client, 'preview-only'); + expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled(); + }); + + it('ignores variables with no overlapping environments', async () => { + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ + { + id: 'production-only', + scope: EnvironmentVariableScope.Project, + environments: ['production'], + value: 'old', + }, + { + id: 'no-envs', + scope: EnvironmentVariableScope.Project, + environments: null, + value: 'old', + }, + ] as never); + + await expect( + upsertEasEnvVarForEnvironmentsAsync(client, 'app-1', envVar, ['preview'], true, false) + ).resolves.toBe(true); + expect(EnvironmentVariableMutation.deleteAsync).not.toHaveBeenCalled(); + expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled(); + }); + + it('prompts before overwriting an exact match interactively', async () => { + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ + { + id: 'exact', + scope: EnvironmentVariableScope.Project, + environments: ['preview'], + value: 'old', + } as never, + ]); + jest.mocked(confirmAsync).mockResolvedValue(true); + + await expect( + upsertEasEnvVarForEnvironmentsAsync(client, 'app-1', envVar, ['preview'], false, false) + ).resolves.toBe(true); + }); +}); diff --git a/packages/eas-cli/src/integrations/supabase/__tests__/environments-test.ts b/packages/eas-cli/src/integrations/supabase/__tests__/environments-test.ts new file mode 100644 index 0000000000..1d12793b80 --- /dev/null +++ b/packages/eas-cli/src/integrations/supabase/__tests__/environments-test.ts @@ -0,0 +1,42 @@ +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { DefaultEnvironment } from '../../../build/utils/environment'; +import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery'; +import { confirmAsync } from '../../../prompts'; +import { EAS_SUPABASE_ENVIRONMENTS, resolveTargetEnvironmentsAsync } from '../environments'; + +jest.mock('../../../graphql/queries/EnvironmentVariablesQuery'); +jest.mock('../../../prompts'); + +describe('resolveTargetEnvironmentsAsync', () => { + const client = {} as ExpoGraphqlClient; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('falls back to the default EAS environments when none are known', async () => { + expect(EAS_SUPABASE_ENVIRONMENTS).toEqual([ + DefaultEnvironment.Production, + DefaultEnvironment.Preview, + DefaultEnvironment.Development, + ]); + jest + .mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync) + .mockResolvedValue([]); + + await expect( + resolveTargetEnvironmentsAsync(client, 'app-1', ['preview'], true) + ).resolves.toEqual(['preview']); + }); + + it('names Supabase 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) + ).rejects.toThrow('Canceled. No additional Supabase project was provisioned.'); + }); +}); 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..41e88d2d90 --- /dev/null +++ b/packages/eas-cli/src/integrations/supabase/__tests__/provision-test.ts @@ -0,0 +1,419 @@ +import openBrowserAsync from 'better-opn'; + +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; + text: string; +} { + const spinner = { + start: jest.fn().mockReturnThis(), + succeed: jest.fn().mockReturnThis(), + fail: jest.fn().mockReturnThis(), + text: '', + }; + jest.mocked(ora).mockReturnValue(spinner as never); + return spinner; +} + +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'); + expect(additionalProvisionFailureHint(['preview'])).toContain('--environment preview'); + }); + + 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; + + beforeEach(() => { + jest.resetAllMocks(); + mockOraSpinner(); + }); + + it('returns finalized receipt', 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.finalized).toBe(receipt); + }); + + 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({ + state: 's', + 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({ + state: 's', + 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 fails spinner on poll error', async () => { + jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({ + state: 's', + url: 'https://oauth.example', + }); + jest.mocked(openBrowserAsync).mockResolvedValue(true as never); + jest + .mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync) + .mockRejectedValue(new Error('always fail')); + + 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 query errors', async () => { + jest + .mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync) + .mockRejectedValueOnce(new Error('blip')) + .mockResolvedValueOnce(connection); + + const promise = pollForConnectionAsync(client, 'acct-1'); + await jest.advanceTimersByTimeAsync(2_000); + await expect(promise).resolves.toEqual(connection); + expect(Log.debug).toHaveBeenCalled(); + }); +}); + +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('throws after consecutive readiness errors', async () => { + jest + .mocked(SupabaseMutation.fetchSupabasePublishableKeyAsync) + .mockRejectedValue(new Error('revoked')); + + const promise = resolvePublishableKeyAsync(client, 'app-1', project); + promise.catch(() => undefined); + await jest.advanceTimersByTimeAsync(3_000); + await jest.advanceTimersByTimeAsync(3_000); + await expect(promise).rejects.toThrow('revoked'); + }); + + 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('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/__tests__/sdk-test.ts b/packages/eas-cli/src/integrations/supabase/__tests__/sdk-test.ts new file mode 100644 index 0000000000..ca366b4ec0 --- /dev/null +++ b/packages/eas-cli/src/integrations/supabase/__tests__/sdk-test.ts @@ -0,0 +1,186 @@ +import spawnAsync from '@expo/spawn-async'; +import { ExpoConfig } from '@expo/config'; + +import Log from '../../../log'; +import { createOrModifyExpoConfigAsync } from '../../../project/expoConfig'; +import { + CONFIG_PLUGIN, + SDK_PACKAGES, + 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(), + })), +})); + +describe('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', false)).resolves.toEqual({ + status: 'installed', + }); + expect(spawnAsync).toHaveBeenCalledWith( + 'npx', + ['expo', 'install', ...SDK_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', 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', 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: [[CONFIG_PLUGIN, {}], 'other'], + }) + ).resolves.toBeNull(); + await expect( + addConfigPluginAsync('/project', { + ...exp, + plugins: [CONFIG_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)).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)).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)).resolves.toContain( + JSON.stringify(CONFIG_PLUGIN) + ); + }); +}); + +describe('setupSdkAndConfigAsync', () => { + const exp = { name: 'app', slug: 'app' } as ExpoConfig; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('adds install failure guidance', async () => { + jest.mocked(spawnAsync).mockRejectedValue(new Error('nope')); + jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({ type: 'success' } as never); + + const steps = await setupSdkAndConfigAsync('/project', exp, true); + expect(steps[0]).toContain('npx expo install'); + }); + + 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, 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, true); + expect(steps[0]).toContain('edit app.config.js'); + }); +}); 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..f8cef447fa --- /dev/null +++ b/packages/eas-cli/src/integrations/supabase/env.ts @@ -0,0 +1,119 @@ +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { + EnvVar, + loadProjectScopedEnvVarsAsync, + upsertEasEnvVarForEnvironmentsAsync as upsertEasEnvVarForEnvironmentsWithLabelAsync, +} from '../../environments/variables'; +import { EnvironmentVariableVisibility } from '../../graphql/generated'; +import { confirmAsync } from '../../prompts'; +import { + mergeEnvContent, + writeEnvLocalAsync as writeEnvLocalWithLabelAsync, +} from '../shared/envFile'; + +export type { EnvVar }; +export { mergeEnvContent }; +export { upsertEasEnvVarAsync } from '../../environments/variables'; + +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'; + +const SUPABASE_ENV_LABEL = 'Supabase'; + +export async function upsertEasEnvVarForEnvironmentsAsync( + graphqlClient: ExpoGraphqlClient, + projectId: string, + envVar: EnvVar, + environments: string[], + nonInteractive: boolean, + overwrite: boolean +): Promise { + return await upsertEasEnvVarForEnvironmentsWithLabelAsync( + graphqlClient, + projectId, + envVar, + environments, + nonInteractive, + overwrite, + { label: SUPABASE_ENV_LABEL } + ); +} + +export function createSupabaseEnvVars(url: string, publishableKey: string): EnvVar[] { + return [ + { + name: EAS_SUPABASE_URL_ENV_VAR_NAME, + value: url, + visibility: EnvironmentVariableVisibility.Public, + }, + { + name: EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME, + value: publishableKey, + visibility: EnvironmentVariableVisibility.Public, + }, + ]; +} + +export async function writeEnvLocalAsync( + projectDir: string, + envVars: EnvVar[], + nonInteractive: boolean, + overwrite: boolean +): Promise { + return await writeEnvLocalWithLabelAsync(projectDir, envVars, { + label: SUPABASE_ENV_LABEL, + nonInteractive, + overwrite, + }); +} + +export async function writeEnvVarsAsync( + envVars: EnvVar[], + upsert: (envVar: EnvVar) => Promise +): Promise { + const easWritten: boolean[] = []; + for (const envVar of envVars) { + easWritten.push(await upsert(envVar)); + } + return easWritten; +} + +export async function ensureAdditionalEnvWritesAllowedAsync( + graphqlClient: ExpoGraphqlClient, + projectId: string, + environments: string[], + nonInteractive: boolean, + overwrite: boolean +): Promise { + if (overwrite) { + return true; + } + const names = [EAS_SUPABASE_URL_ENV_VAR_NAME, EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME]; + const targetSet = new Set(environments); + for (const name of names) { + const existingVariables = await 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 true; + } + return false; +} diff --git a/packages/eas-cli/src/integrations/supabase/environments.ts b/packages/eas-cli/src/integrations/supabase/environments.ts new file mode 100644 index 0000000000..816d6f9cc8 --- /dev/null +++ b/packages/eas-cli/src/integrations/supabase/environments.ts @@ -0,0 +1,29 @@ +import { DefaultEnvironment } from '../../build/utils/environment'; +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { resolveTargetEnvironmentsAsync as resolveTargetEnvironmentsWithDefaultsAsync } from '../../environments/resolve'; + +export { parseEnvironmentFlag } from '../../environments/resolve'; + +// Production-first, matching the PostHog and Convex integrations. +export const EAS_SUPABASE_ENVIRONMENTS = [ + DefaultEnvironment.Production, + DefaultEnvironment.Preview, + DefaultEnvironment.Development, +]; + +const SUPABASE_ENVIRONMENTS_LABEL = 'Supabase'; + +export async function resolveTargetEnvironmentsAsync( + graphqlClient: ExpoGraphqlClient, + projectId: string, + requested: string[], + nonInteractive: boolean +): Promise { + return await resolveTargetEnvironmentsWithDefaultsAsync( + graphqlClient, + projectId, + requested, + nonInteractive, + { defaultEnvironments: EAS_SUPABASE_ENVIRONMENTS, label: SUPABASE_ENVIRONMENTS_LABEL } + ); +} 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..4013793503 --- /dev/null +++ b/packages/eas-cli/src/integrations/supabase/provision.ts @@ -0,0 +1,322 @@ +import openBrowserAsync from 'better-opn'; +import chalk from 'chalk'; + +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { + formatSupabaseOrganization, + getSupabaseProjectDashboardUrl, +} from '../../commandUtils/supabase'; +import { BackgroundJobReceiptDataFragment } from '../../graphql/generated'; +import { SupabaseMutation } from '../../graphql/mutations/SupabaseMutation'; +import { SupabaseQuery } from '../../graphql/queries/SupabaseQuery'; +import { + SupabaseConnectionData, + SupabaseOrganizationData, + SupabaseProjectData, +} from '../../graphql/types/SupabaseConnection'; +import Log, { link } from '../../log'; +import { Ora, ora } from '../../ora'; +import { selectAsync } from '../../prompts'; +import { 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; + +// A freshly provisioned project takes a minute or two to become healthy; the publishable key only +// resolves once it is. +const READINESS_POLL_INTERVAL_MS = 3_000; +const READINESS_POLL_TIMEOUT_MS = 5 * 60 * 1_000; +const MAX_CONSECUTIVE_READINESS_ERRORS = 3; + +// Background job create + publishable-key polling can take 5+ minutes; allow 7 min at 1s interval. +export const PROVISION_RECEIPT_MAX_CHECKS = 420; +export const PROVISION_RECEIPT_MAX_CONSECUTIVE_FETCH_ERRORS = 3; + +export const SUPABASE_REGION_CHOICES = [ + { title: 'Americas (US)', value: 'americas' }, + { title: 'Europe / Middle East / Africa', value: 'emea' }, + { title: 'Asia Pacific', value: 'apac' }, +]; + +export function 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 [ + 'If a project already exists in your Supabase dashboard, point those environments at it with --link:', + ` eas integrations:supabase:connect --environment ${environments.join(',')} --link `, + 'Or free a slot on your Supabase plan (delete, pause, or upgrade a project), then re-run without --link.', + ].join('\n'); +} + +/** Where to find a project for --link (primary connect path). */ +export function primaryProvisionFailureHint(): string { + return [ + 'If a project already exists in your Supabase dashboard, link it instead of provisioning:', + ' eas integrations:supabase:connect --link ', + ].join('\n'); +} + +export async function pollProvisionReceiptAsync( + graphqlClient: ExpoGraphqlClient, + receipt: BackgroundJobReceiptDataFragment, + { + startMessage, + waitingMessage, + failureMessage, + failureHint, + }: { + startMessage: string; + waitingMessage: string; + failureMessage: string; + failureHint: string; + } +): Promise<{ finalized: BackgroundJobReceiptDataFragment; spinner: Ora }> { + const spinner = ora(startMessage).start(); + try { + spinner.text = waitingMessage; + const finalized = await pollForBackgroundJobReceiptAsync(graphqlClient, receipt, { + maxChecks: PROVISION_RECEIPT_MAX_CHECKS, + maxConsecutiveFetchErrors: PROVISION_RECEIPT_MAX_CONSECUTIVE_FETCH_ERRORS, + }); + if (!finalized) { + throw new Error('Supabase project provision finished without a receipt.'); + } + return { finalized, spinner }; + } catch (error) { + spinner.fail(failureMessage); + throw toProvisionPollError(error, { hint: failureHint }); + } +} + +export async function authorizeViaBrowserAsync( + graphqlClient: ExpoGraphqlClient, + account: { id: string; name: string }, + nonInteractive: boolean +): Promise { + if (nonInteractive) { + throw new Error( + `Connecting Supabase requires approving access in a browser, which isn't possible in non-interactive mode. Re-run \`eas integrations:supabase:connect\` interactively.` + ); + } + + const { url } = await SupabaseMutation.beginSupabaseOAuthAsync(graphqlClient, { + accountId: account.id, + }); + Log.addNewLineIfNone(); + Log.log( + `Authorize Expo to access your Supabase account in the browser. You'll need an existing Supabase account.` + ); + const opened = await openBrowserAsync(url).catch(() => false); + Log.log(opened ? `Opened ${link(url)}` : `Open this URL to authorize: ${link(url)}`); + + const spinner = ora( + 'Waiting for you to authorize in Supabase (up to 15 minutes; press Ctrl-C to cancel)' + ).start(); + try { + const connection = await pollForConnectionAsync(graphqlClient, account.id); + const organizations = await loadOrganizationsBestEffortAsync(graphqlClient, account.id); + spinner.succeed( + `Connected Supabase organization ${chalk.bold( + formatSupabaseOrganization(connection, organizations ?? undefined) + )}` + ); + return connection; + } catch (error) { + spinner.fail("Couldn't confirm the Supabase connection"); + throw error; + } +} + +export async function loadOrganizationsBestEffortAsync( + graphqlClient: ExpoGraphqlClient, + accountId: string +): Promise { + try { + return await SupabaseMutation.listSupabaseOrganizationsAsync(graphqlClient, accountId); + } catch { + return null; + } +} + +export async function pollForConnectionAsync( + graphqlClient: ExpoGraphqlClient, + accountId: string +): Promise { + const deadline = Date.now() + CONNECTION_POLL_TIMEOUT_MS; + for (;;) { + let connection: SupabaseConnectionData | null = null; + try { + connection = await SupabaseQuery.getSupabaseConnectionByAccountIdAsync( + graphqlClient, + accountId, + { useCache: false } + ); + } catch (error) { + Log.debug(`Polling for the Supabase connection failed, will retry: ${error}`); + } + if (connection) { + return connection; + } + if (Date.now() >= deadline) { + throw new Error( + 'Timed out waiting for the Supabase connection. If you authorized it in your browser, re-run `eas integrations:supabase:connect` — it will pick up the connection.' + ); + } + await 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 but throws for a real + // problem (revoked authorization, etc.). Tolerate a transient blip, but stop retrying for the + // full timeout once the errors are persistent — that isn't a provisioning delay. + let consecutiveErrors = 0; + for (;;) { + let key: string | null = null; + try { + key = await SupabaseMutation.fetchSupabasePublishableKeyAsync(graphqlClient, appId); + consecutiveErrors = 0; + } catch (error) { + consecutiveErrors += 1; + Log.debug(`Polling for the Supabase project readiness failed, will retry: ${error}`); + if (consecutiveErrors >= MAX_CONSECUTIVE_READINESS_ERRORS) { + spinner.fail("Couldn't reach the Supabase project"); + throw error; + } + } + if (key) { + spinner.succeed('Supabase project is ready'); + return key; + } + if (Date.now() >= deadline) { + spinner.fail('Supabase project did not finish provisioning in time'); + throw new Error( + `The Supabase project is still provisioning. Once it's healthy (check ${getSupabaseProjectDashboardUrl( + project + )}), re-run \`eas integrations:supabase:connect\` to finish writing the environment variables.` + ); + } + await 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/integrations/supabase/sdk.ts b/packages/eas-cli/src/integrations/supabase/sdk.ts new file mode 100644 index 0000000000..12c14e5f73 --- /dev/null +++ b/packages/eas-cli/src/integrations/supabase/sdk.ts @@ -0,0 +1,51 @@ +import { ExpoConfig } from '@expo/config'; + +import { + SdkInstallResult, + addConfigPluginAsync as addConfigPluginWithConfigAsync, + installSdkPackagesAsync as installSdkPackagesWithConfigAsync, + setupSdkAndConfigAsync as setupSdkAndConfigWithConfigAsync, +} from '../shared/sdk'; + +export type { SdkInstallResult }; +export { + getSpawnErrorOutput, + extractDynamicConfigGuidance, + envForExpoInstall, +} from '../shared/sdk'; + +export const SDK_PACKAGES = ['@supabase/supabase-js', 'react-native-url-polyfill', 'expo-sqlite']; +export const CONFIG_PLUGIN = 'expo-sqlite'; + +const SUPABASE_SDK_LABEL = 'Supabase'; + +export async function installSdkPackagesAsync( + projectDir: string, + jsonFlag: boolean +): Promise { + return await installSdkPackagesWithConfigAsync(projectDir, { + packages: SDK_PACKAGES, + label: SUPABASE_SDK_LABEL, + jsonFlag, + }); +} + +export async function addConfigPluginAsync( + projectDir: string, + exp: ExpoConfig +): Promise { + return await addConfigPluginWithConfigAsync(projectDir, exp, { plugin: CONFIG_PLUGIN }); +} + +export async function setupSdkAndConfigAsync( + projectDir: string, + exp: ExpoConfig, + jsonFlag: boolean +): Promise { + return await setupSdkAndConfigWithConfigAsync(projectDir, exp, { + packages: SDK_PACKAGES, + plugin: CONFIG_PLUGIN, + label: SUPABASE_SDK_LABEL, + jsonFlag, + }); +} 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..18505abf7f 100644 --- a/packages/eas-cli/src/utils/prompts.ts +++ b/packages/eas-cli/src/utils/prompts.ts @@ -1,14 +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 { 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 @@ -19,8 +17,8 @@ export async function getProjectEnvironmentVariableEnvironmentsAsync( projectId ); return environments; - } catch { - throw new Error('Failed to fetch available environments'); + } catch (error) { + throw new Error('Failed to fetch available environments', { cause: error }); } } From 54833fb5b7037ce0d43c8a7f0d35c7c85b851357 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Fri, 31 Jul 2026 19:45:21 -0700 Subject: [PATCH 2/9] [eas-cli] Fix additional Supabase provision failure hint Stop suggesting --environment with --link; that combo is rejected by connect. --- .../src/integrations/supabase/__tests__/provision-test.ts | 5 ++++- packages/eas-cli/src/integrations/supabase/provision.ts | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/eas-cli/src/integrations/supabase/__tests__/provision-test.ts b/packages/eas-cli/src/integrations/supabase/__tests__/provision-test.ts index 41e88d2d90..6f418d599b 100644 --- a/packages/eas-cli/src/integrations/supabase/__tests__/provision-test.ts +++ b/packages/eas-cli/src/integrations/supabase/__tests__/provision-test.ts @@ -81,7 +81,10 @@ const project: SupabaseProjectData = { describe('provision hints and poll errors', () => { it('builds failure hints', () => { expect(primaryProvisionFailureHint()).toContain('--link'); - expect(additionalProvisionFailureHint(['preview'])).toContain('--environment preview'); + 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', () => { diff --git a/packages/eas-cli/src/integrations/supabase/provision.ts b/packages/eas-cli/src/integrations/supabase/provision.ts index 4013793503..6de0bff6d7 100644 --- a/packages/eas-cli/src/integrations/supabase/provision.ts +++ b/packages/eas-cli/src/integrations/supabase/provision.ts @@ -77,9 +77,9 @@ export function toProvisionPollError(error: unknown, { hint }: { hint: string }) /** Fast-forward guidance when additional (--environment) provision fails permanently. */ export function additionalProvisionFailureHint(environments: string[]): string { return [ - 'If a project already exists in your Supabase dashboard, point those environments at it with --link:', - ` eas integrations:supabase:connect --environment ${environments.join(',')} --link `, - 'Or free a slot on your Supabase plan (delete, pause, or upgrade a project), then re-run without --link.', + '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'); } From b270f833a9121fdd3a5be7d204f7483c668eddae Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sun, 2 Aug 2026 19:07:29 -0700 Subject: [PATCH 3/9] [eas-cli] Reach 100% coverage on Supabase foundation helpers --- .../commandUtils/__tests__/supabase-test.ts | 6 ++ .../supabase/__tests__/env-test.ts | 55 +++++++++++++++++++ .../supabase/__tests__/provision-test.ts | 20 +++++++ 3 files changed, 81 insertions(+) diff --git a/packages/eas-cli/src/commandUtils/__tests__/supabase-test.ts b/packages/eas-cli/src/commandUtils/__tests__/supabase-test.ts index 6b018e94cc..8f21dad645 100644 --- a/packages/eas-cli/src/commandUtils/__tests__/supabase-test.ts +++ b/packages/eas-cli/src/commandUtils/__tests__/supabase-test.ts @@ -148,4 +148,10 @@ describe(parseSupabaseProjectRef, () => { ); 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/integrations/supabase/__tests__/env-test.ts b/packages/eas-cli/src/integrations/supabase/__tests__/env-test.ts index 7d0e1f6210..4af5985e12 100644 --- a/packages/eas-cli/src/integrations/supabase/__tests__/env-test.ts +++ b/packages/eas-cli/src/integrations/supabase/__tests__/env-test.ts @@ -200,6 +200,48 @@ describe('upsertEasEnvVarAsync', () => { expect.objectContaining({ message: expect.stringContaining('multiple') }) ); }); + + it('uses other-environments wording when extras have no environment lists', async () => { + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ + { + id: 'keeper', + scope: EnvironmentVariableScope.Project, + environments: ['production'], + }, + { + id: 'extra', + scope: EnvironmentVariableScope.Project, + environments: null, + }, + ] as never); + jest.mocked(confirmAsync).mockResolvedValue(true); + jest.mocked(EnvironmentVariableMutation.deleteAsync).mockResolvedValue({} as never); + jest.mocked(EnvironmentVariableMutation.updateAsync).mockResolvedValue({} as never); + + await expect( + upsertEasEnvVarAsync(client, 'app-1', envVar, ['production'], false, false) + ).resolves.toBe(true); + expect(confirmAsync).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('other environments') }) + ); + }); + + it('skips interactively without mentioning --overwrite', async () => { + jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ + { + id: 'v1', + scope: EnvironmentVariableScope.Project, + environments: ['production'], + } as never, + ]); + jest.mocked(confirmAsync).mockResolvedValue(false); + + await expect( + upsertEasEnvVarAsync(client, 'app-1', envVar, ['production'], false, false) + ).resolves.toBe(false); + expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('Skipped updating')); + expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining('--overwrite')); + }); }); describe('ensureAdditionalEnvWritesAllowedAsync', () => { @@ -223,6 +265,19 @@ describe('ensureAdditionalEnvWritesAllowedAsync', () => { ).resolves.toBe(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( + ensureAdditionalEnvWritesAllowedAsync(client, 'app-1', ['preview'], true, false) + ).resolves.toBe(false); + }); + it('throws in non-interactive mode when overlap exists', async () => { jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ { diff --git a/packages/eas-cli/src/integrations/supabase/__tests__/provision-test.ts b/packages/eas-cli/src/integrations/supabase/__tests__/provision-test.ts index 6f418d599b..4fb0415263 100644 --- a/packages/eas-cli/src/integrations/supabase/__tests__/provision-test.ts +++ b/packages/eas-cli/src/integrations/supabase/__tests__/provision-test.ts @@ -219,6 +219,22 @@ describe('authorizeViaBrowserAsync / loadOrganizationsBestEffortAsync / pollForC expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Open this URL')); }); + it('authorizeViaBrowserAsync succeeds when organization listing fails', async () => { + jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({ + state: 's', + 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 poll error', async () => { jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({ state: 's', @@ -312,6 +328,10 @@ describe('resolveRegionAsync', () => { 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/); }); From b1a664a42f9a9c279d751e72ebca397466d8dad4 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sun, 2 Aug 2026 19:09:47 -0700 Subject: [PATCH 4/9] [eas-cli] Regenerate GraphQL after rebase onto main --- packages/eas-cli/src/graphql/generated.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index 7ca688ad35..250d3a2f19 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -14339,6 +14339,7 @@ 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; }>; @@ -15490,6 +15491,7 @@ export type SubmissionWithSubmittedBuildFragment = { __typename?: 'Submission', | { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __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 }; From c2bf4800d9a11e1aa7e816078a99a375486f4a6d Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Mon, 3 Aug 2026 16:39:08 -0700 Subject: [PATCH 5/9] [eas-cli] Drop thin Supabase env wrappers and re-exports Call shared writeEnvLocal / env upsert / resolveTargetEnvironments with the Supabase label at the callsite instead of one-arg wrappers. --- packages/eas-cli/src/environments/resolve.ts | 4 + .../supabase/__tests__/env-test.ts | 138 +++++++++++++++--- .../supabase/__tests__/environments-test.ts | 35 +---- .../eas-cli/src/integrations/supabase/env.ts | 49 +------ .../src/integrations/supabase/environments.ts | 21 --- 5 files changed, 124 insertions(+), 123 deletions(-) diff --git a/packages/eas-cli/src/environments/resolve.ts b/packages/eas-cli/src/environments/resolve.ts index 619251951c..0a7e11cb05 100644 --- a/packages/eas-cli/src/environments/resolve.ts +++ b/packages/eas-cli/src/environments/resolve.ts @@ -34,6 +34,10 @@ export function parseEnvironmentFlag(value: string | undefined): string[] | null return environments; } +/** + * Resolve requested EAS environment names against what the project already uses. + * Shared across integrations; pass integration-specific defaults and label at each callsite. + */ export async function resolveTargetEnvironmentsAsync( graphqlClient: ExpoGraphqlClient, projectId: string, diff --git a/packages/eas-cli/src/integrations/supabase/__tests__/env-test.ts b/packages/eas-cli/src/integrations/supabase/__tests__/env-test.ts index 4af5985e12..8378034395 100644 --- a/packages/eas-cli/src/integrations/supabase/__tests__/env-test.ts +++ b/packages/eas-cli/src/integrations/supabase/__tests__/env-test.ts @@ -1,23 +1,25 @@ import * as fs from 'fs-extra'; import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { + upsertEasEnvVarAsync, + upsertEasEnvVarForEnvironmentsAsync, +} from '../../../environments/variables'; import { EnvironmentVariableScope, EnvironmentVariableVisibility, } from '../../../graphql/generated'; import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation'; import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery'; +import { mergeEnvContent, writeEnvLocalAsync } from '../../shared/envFile'; import Log from '../../../log'; import { confirmAsync } from '../../../prompts'; import { EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME, EAS_SUPABASE_URL_ENV_VAR_NAME, + SUPABASE_ENV_LABEL, createSupabaseEnvVars, ensureAdditionalEnvWritesAllowedAsync, - mergeEnvContent, - upsertEasEnvVarAsync, - upsertEasEnvVarForEnvironmentsAsync, - writeEnvLocalAsync, writeEnvVarsAsync, } from '../env'; @@ -27,7 +29,7 @@ jest.mock('../../../graphql/queries/EnvironmentVariablesQuery'); jest.mock('../../../prompts'); jest.mock('../../../log'); -describe('createSupabaseEnvVars / mergeEnvContent / writeEnvVarsAsync', () => { +describe('createSupabaseEnvVars / writeEnvVarsAsync', () => { it('createSupabaseEnvVars returns public URL and key vars', () => { expect(createSupabaseEnvVars('https://example.supabase.co', 'pk')).toEqual([ { @@ -43,11 +45,6 @@ describe('createSupabaseEnvVars / mergeEnvContent / writeEnvVarsAsync', () => { ]); }); - it('mergeEnvContent 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('writeEnvVarsAsync runs upsert for each var', async () => { const upsert = jest.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false); await expect( @@ -70,7 +67,7 @@ describe('createSupabaseEnvVars / mergeEnvContent / writeEnvVarsAsync', () => { }); }); -describe('writeEnvLocalAsync', () => { +describe('shared writeEnvLocalAsync / mergeEnvContent (via Supabase label)', () => { const envVars = createSupabaseEnvVars('https://example.supabase.co', 'pk'); beforeEach(() => { @@ -79,8 +76,19 @@ describe('writeEnvLocalAsync', () => { jest.mocked(fs.writeFile).mockResolvedValue(undefined as never); }); + it('mergeEnvContent 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('writes a new .env.local file', async () => { - await expect(writeEnvLocalAsync('/project', envVars, true, false)).resolves.toBe(true); + await expect( + writeEnvLocalAsync('/project', envVars, { + label: SUPABASE_ENV_LABEL, + nonInteractive: true, + overwrite: false, + }) + ).resolves.toBe(true); expect(fs.writeFile).toHaveBeenCalledWith( expect.stringContaining('.env.local'), expect.stringContaining(EAS_SUPABASE_URL_ENV_VAR_NAME) @@ -92,7 +100,13 @@ describe('writeEnvLocalAsync', () => { jest.mocked(fs.pathExists).mockResolvedValue(true as never); jest.mocked(fs.readFile).mockResolvedValue(`${EAS_SUPABASE_URL_ENV_VAR_NAME}=old\n` as never); - await expect(writeEnvLocalAsync('/project', envVars, true, false)).resolves.toBe(false); + await expect( + writeEnvLocalAsync('/project', envVars, { + label: SUPABASE_ENV_LABEL, + nonInteractive: true, + overwrite: false, + }) + ).resolves.toBe(false); expect(fs.writeFile).not.toHaveBeenCalled(); expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('skipped')); }); @@ -102,7 +116,13 @@ describe('writeEnvLocalAsync', () => { jest.mocked(fs.readFile).mockResolvedValue(`${EAS_SUPABASE_URL_ENV_VAR_NAME}=old\n` as never); jest.mocked(confirmAsync).mockResolvedValue(false); - await expect(writeEnvLocalAsync('/project', envVars, false, false)).resolves.toBe(false); + await expect( + writeEnvLocalAsync('/project', envVars, { + label: SUPABASE_ENV_LABEL, + nonInteractive: false, + overwrite: false, + }) + ).resolves.toBe(false); expect(fs.writeFile).not.toHaveBeenCalled(); }); @@ -111,8 +131,20 @@ describe('writeEnvLocalAsync', () => { jest.mocked(fs.readFile).mockResolvedValue(`${EAS_SUPABASE_URL_ENV_VAR_NAME}=old\n` as never); jest.mocked(confirmAsync).mockResolvedValue(true); - await expect(writeEnvLocalAsync('/project', envVars, false, false)).resolves.toBe(true); - await expect(writeEnvLocalAsync('/project', envVars, true, true)).resolves.toBe(true); + await expect( + writeEnvLocalAsync('/project', envVars, { + label: SUPABASE_ENV_LABEL, + nonInteractive: false, + overwrite: false, + }) + ).resolves.toBe(true); + await expect( + writeEnvLocalAsync('/project', envVars, { + label: SUPABASE_ENV_LABEL, + nonInteractive: true, + overwrite: true, + }) + ).resolves.toBe(true); expect(fs.writeFile).toHaveBeenCalled(); }); }); @@ -326,6 +358,7 @@ describe('ensureAdditionalEnvWritesAllowedAsync', () => { describe('upsertEasEnvVarForEnvironmentsAsync', () => { const client = {} as ExpoGraphqlClient; const envVar = createSupabaseEnvVars('https://example.supabase.co', 'pk')[0]; + const labelOpts = { label: SUPABASE_ENV_LABEL }; beforeEach(() => { jest.resetAllMocks(); @@ -345,7 +378,15 @@ describe('upsertEasEnvVarForEnvironmentsAsync', () => { ]); await expect( - upsertEasEnvVarForEnvironmentsAsync(client, 'app-1', envVar, ['preview'], true, true) + upsertEasEnvVarForEnvironmentsAsync( + client, + 'app-1', + envVar, + ['preview'], + true, + true, + labelOpts + ) ).resolves.toBe(true); expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith( client, @@ -364,7 +405,15 @@ describe('upsertEasEnvVarForEnvironmentsAsync', () => { ]); await expect( - upsertEasEnvVarForEnvironmentsAsync(client, 'app-1', envVar, ['preview'], true, false) + upsertEasEnvVarForEnvironmentsAsync( + client, + 'app-1', + envVar, + ['preview'], + true, + false, + labelOpts + ) ).resolves.toBe(false); }); @@ -379,7 +428,15 @@ describe('upsertEasEnvVarForEnvironmentsAsync', () => { ]); await expect( - upsertEasEnvVarForEnvironmentsAsync(client, 'app-1', envVar, ['preview'], true, false) + upsertEasEnvVarForEnvironmentsAsync( + client, + 'app-1', + envVar, + ['preview'], + true, + false, + labelOpts + ) ).resolves.toBe(true); }); @@ -394,7 +451,15 @@ describe('upsertEasEnvVarForEnvironmentsAsync', () => { ]); await expect( - upsertEasEnvVarForEnvironmentsAsync(client, 'app-1', envVar, ['preview'], true, true) + upsertEasEnvVarForEnvironmentsAsync( + client, + 'app-1', + envVar, + ['preview'], + true, + true, + labelOpts + ) ).resolves.toBe(true); expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(client, { id: 'shared', @@ -419,7 +484,15 @@ describe('upsertEasEnvVarForEnvironmentsAsync', () => { jest.mocked(confirmAsync).mockResolvedValue(false); await expect( - upsertEasEnvVarForEnvironmentsAsync(client, 'app-1', envVar, ['preview'], false, false) + upsertEasEnvVarForEnvironmentsAsync( + client, + 'app-1', + envVar, + ['preview'], + false, + false, + labelOpts + ) ).resolves.toBe(false); expect(confirmAsync).toHaveBeenCalledWith( expect.objectContaining({ @@ -445,7 +518,8 @@ describe('upsertEasEnvVarForEnvironmentsAsync', () => { envVar, ['preview', 'development'], true, - true + true, + labelOpts ) ).resolves.toBe(true); expect(EnvironmentVariableMutation.deleteAsync).toHaveBeenCalledWith(client, 'preview-only'); @@ -469,7 +543,15 @@ describe('upsertEasEnvVarForEnvironmentsAsync', () => { ] as never); await expect( - upsertEasEnvVarForEnvironmentsAsync(client, 'app-1', envVar, ['preview'], true, false) + upsertEasEnvVarForEnvironmentsAsync( + client, + 'app-1', + envVar, + ['preview'], + true, + false, + labelOpts + ) ).resolves.toBe(true); expect(EnvironmentVariableMutation.deleteAsync).not.toHaveBeenCalled(); expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled(); @@ -487,7 +569,15 @@ describe('upsertEasEnvVarForEnvironmentsAsync', () => { jest.mocked(confirmAsync).mockResolvedValue(true); await expect( - upsertEasEnvVarForEnvironmentsAsync(client, 'app-1', envVar, ['preview'], false, false) + upsertEasEnvVarForEnvironmentsAsync( + client, + 'app-1', + envVar, + ['preview'], + false, + false, + labelOpts + ) ).resolves.toBe(true); }); }); diff --git a/packages/eas-cli/src/integrations/supabase/__tests__/environments-test.ts b/packages/eas-cli/src/integrations/supabase/__tests__/environments-test.ts index 1d12793b80..c24dcb57fc 100644 --- a/packages/eas-cli/src/integrations/supabase/__tests__/environments-test.ts +++ b/packages/eas-cli/src/integrations/supabase/__tests__/environments-test.ts @@ -1,42 +1,13 @@ -import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; import { DefaultEnvironment } from '../../../build/utils/environment'; -import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery'; -import { confirmAsync } from '../../../prompts'; -import { EAS_SUPABASE_ENVIRONMENTS, resolveTargetEnvironmentsAsync } from '../environments'; -jest.mock('../../../graphql/queries/EnvironmentVariablesQuery'); -jest.mock('../../../prompts'); +import { EAS_SUPABASE_ENVIRONMENTS } from '../environments'; -describe('resolveTargetEnvironmentsAsync', () => { - const client = {} as ExpoGraphqlClient; - - beforeEach(() => { - jest.resetAllMocks(); - }); - - it('falls back to the default EAS environments when none are known', async () => { +describe('EAS_SUPABASE_ENVIRONMENTS', () => { + it('lists production, preview, and development in that order', () => { expect(EAS_SUPABASE_ENVIRONMENTS).toEqual([ DefaultEnvironment.Production, DefaultEnvironment.Preview, DefaultEnvironment.Development, ]); - jest - .mocked(EnvironmentVariablesQuery.environmentVariableEnvironmentsAsync) - .mockResolvedValue([]); - - await expect( - resolveTargetEnvironmentsAsync(client, 'app-1', ['preview'], true) - ).resolves.toEqual(['preview']); - }); - - it('names Supabase 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) - ).rejects.toThrow('Canceled. No additional Supabase project was provisioned.'); }); }); diff --git a/packages/eas-cli/src/integrations/supabase/env.ts b/packages/eas-cli/src/integrations/supabase/env.ts index f8cef447fa..787cbaa1c2 100644 --- a/packages/eas-cli/src/integrations/supabase/env.ts +++ b/packages/eas-cli/src/integrations/supabase/env.ts @@ -1,43 +1,13 @@ import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; -import { - EnvVar, - loadProjectScopedEnvVarsAsync, - upsertEasEnvVarForEnvironmentsAsync as upsertEasEnvVarForEnvironmentsWithLabelAsync, -} from '../../environments/variables'; +import { EnvVar, loadProjectScopedEnvVarsAsync } from '../../environments/variables'; import { EnvironmentVariableVisibility } from '../../graphql/generated'; import { confirmAsync } from '../../prompts'; -import { - mergeEnvContent, - writeEnvLocalAsync as writeEnvLocalWithLabelAsync, -} from '../shared/envFile'; - -export type { EnvVar }; -export { mergeEnvContent }; -export { upsertEasEnvVarAsync } from '../../environments/variables'; 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'; -const SUPABASE_ENV_LABEL = 'Supabase'; - -export async function upsertEasEnvVarForEnvironmentsAsync( - graphqlClient: ExpoGraphqlClient, - projectId: string, - envVar: EnvVar, - environments: string[], - nonInteractive: boolean, - overwrite: boolean -): Promise { - return await upsertEasEnvVarForEnvironmentsWithLabelAsync( - graphqlClient, - projectId, - envVar, - environments, - nonInteractive, - overwrite, - { label: SUPABASE_ENV_LABEL } - ); -} +/** Label passed into shared env helpers for prompts and log lines. */ +export const SUPABASE_ENV_LABEL = 'Supabase'; export function createSupabaseEnvVars(url: string, publishableKey: string): EnvVar[] { return [ @@ -54,19 +24,6 @@ export function createSupabaseEnvVars(url: string, publishableKey: string): EnvV ]; } -export async function writeEnvLocalAsync( - projectDir: string, - envVars: EnvVar[], - nonInteractive: boolean, - overwrite: boolean -): Promise { - return await writeEnvLocalWithLabelAsync(projectDir, envVars, { - label: SUPABASE_ENV_LABEL, - nonInteractive, - overwrite, - }); -} - export async function writeEnvVarsAsync( envVars: EnvVar[], upsert: (envVar: EnvVar) => Promise diff --git a/packages/eas-cli/src/integrations/supabase/environments.ts b/packages/eas-cli/src/integrations/supabase/environments.ts index 816d6f9cc8..cadab61ed4 100644 --- a/packages/eas-cli/src/integrations/supabase/environments.ts +++ b/packages/eas-cli/src/integrations/supabase/environments.ts @@ -1,8 +1,4 @@ import { DefaultEnvironment } from '../../build/utils/environment'; -import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; -import { resolveTargetEnvironmentsAsync as resolveTargetEnvironmentsWithDefaultsAsync } from '../../environments/resolve'; - -export { parseEnvironmentFlag } from '../../environments/resolve'; // Production-first, matching the PostHog and Convex integrations. export const EAS_SUPABASE_ENVIRONMENTS = [ @@ -10,20 +6,3 @@ export const EAS_SUPABASE_ENVIRONMENTS = [ DefaultEnvironment.Preview, DefaultEnvironment.Development, ]; - -const SUPABASE_ENVIRONMENTS_LABEL = 'Supabase'; - -export async function resolveTargetEnvironmentsAsync( - graphqlClient: ExpoGraphqlClient, - projectId: string, - requested: string[], - nonInteractive: boolean -): Promise { - return await resolveTargetEnvironmentsWithDefaultsAsync( - graphqlClient, - projectId, - requested, - nonInteractive, - { defaultEnvironments: EAS_SUPABASE_ENVIRONMENTS, label: SUPABASE_ENVIRONMENTS_LABEL } - ); -} From 87d74e235c22537c5c93dd90dc55a4044a883d82 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Mon, 3 Aug 2026 16:57:00 -0700 Subject: [PATCH 6/9] [eas-cli] Slim supabase/sdk.ts to package constants only Call shared setupSdkAndConfigAsync with packages/plugin/label at the connect callsite instead of thin binder wrappers. --- .../integrations/shared/__tests__/sdk-test.ts | 219 ++++++++++++++++++ .../supabase/__tests__/sdk-test.ts | 192 +-------------- .../eas-cli/src/integrations/supabase/sdk.ts | 52 +---- 3 files changed, 231 insertions(+), 232 deletions(-) create mode 100644 packages/eas-cli/src/integrations/shared/__tests__/sdk-test.ts 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..f518b7224e --- /dev/null +++ b/packages/eas-cli/src/integrations/shared/__tests__/sdk-test.ts @@ -0,0 +1,219 @@ +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', 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[0]).toContain('npx expo install'); + }); + + 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/supabase/__tests__/sdk-test.ts b/packages/eas-cli/src/integrations/supabase/__tests__/sdk-test.ts index ca366b4ec0..987d984106 100644 --- a/packages/eas-cli/src/integrations/supabase/__tests__/sdk-test.ts +++ b/packages/eas-cli/src/integrations/supabase/__tests__/sdk-test.ts @@ -1,186 +1,12 @@ -import spawnAsync from '@expo/spawn-async'; -import { ExpoConfig } from '@expo/config'; - -import Log from '../../../log'; -import { createOrModifyExpoConfigAsync } from '../../../project/expoConfig'; -import { - CONFIG_PLUGIN, - SDK_PACKAGES, - 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(), - })), -})); - -describe('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', false)).resolves.toEqual({ - status: 'installed', - }); - expect(spawnAsync).toHaveBeenCalledWith( - 'npx', - ['expo', 'install', ...SDK_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', 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', 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: [[CONFIG_PLUGIN, {}], 'other'], - }) - ).resolves.toBeNull(); - await expect( - addConfigPluginAsync('/project', { - ...exp, - plugins: [CONFIG_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)).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)).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)).resolves.toContain( - JSON.stringify(CONFIG_PLUGIN) - ); - }); -}); - -describe('setupSdkAndConfigAsync', () => { - const exp = { name: 'app', slug: 'app' } as ExpoConfig; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('adds install failure guidance', async () => { - jest.mocked(spawnAsync).mockRejectedValue(new Error('nope')); - jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({ type: 'success' } as never); - - const steps = await setupSdkAndConfigAsync('/project', exp, true); - expect(steps[0]).toContain('npx expo install'); - }); - - 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, true); - expect(steps).toEqual([ - expect.stringContaining('Cannot automatically write to dynamic config'), +import { CONFIG_PLUGIN, SDK_PACKAGES } from '../sdk'; + +describe('Supabase SDK constants', () => { + it('lists the packages and plugin connect installs', () => { + expect(SDK_PACKAGES).toEqual([ + '@supabase/supabase-js', + 'react-native-url-polyfill', + 'expo-sqlite', ]); - 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, true); - expect(steps[0]).toContain('edit app.config.js'); + expect(CONFIG_PLUGIN).toBe('expo-sqlite'); }); }); diff --git a/packages/eas-cli/src/integrations/supabase/sdk.ts b/packages/eas-cli/src/integrations/supabase/sdk.ts index 12c14e5f73..0028f1dec7 100644 --- a/packages/eas-cli/src/integrations/supabase/sdk.ts +++ b/packages/eas-cli/src/integrations/supabase/sdk.ts @@ -1,51 +1,5 @@ -import { ExpoConfig } from '@expo/config'; - -import { - SdkInstallResult, - addConfigPluginAsync as addConfigPluginWithConfigAsync, - installSdkPackagesAsync as installSdkPackagesWithConfigAsync, - setupSdkAndConfigAsync as setupSdkAndConfigWithConfigAsync, -} from '../shared/sdk'; - -export type { SdkInstallResult }; -export { - getSpawnErrorOutput, - extractDynamicConfigGuidance, - envForExpoInstall, -} from '../shared/sdk'; - +/** Packages installed by `eas integrations:supabase:connect`. */ export const SDK_PACKAGES = ['@supabase/supabase-js', 'react-native-url-polyfill', 'expo-sqlite']; -export const CONFIG_PLUGIN = 'expo-sqlite'; -const SUPABASE_SDK_LABEL = 'Supabase'; - -export async function installSdkPackagesAsync( - projectDir: string, - jsonFlag: boolean -): Promise { - return await installSdkPackagesWithConfigAsync(projectDir, { - packages: SDK_PACKAGES, - label: SUPABASE_SDK_LABEL, - jsonFlag, - }); -} - -export async function addConfigPluginAsync( - projectDir: string, - exp: ExpoConfig -): Promise { - return await addConfigPluginWithConfigAsync(projectDir, exp, { plugin: CONFIG_PLUGIN }); -} - -export async function setupSdkAndConfigAsync( - projectDir: string, - exp: ExpoConfig, - jsonFlag: boolean -): Promise { - return await setupSdkAndConfigWithConfigAsync(projectDir, exp, { - packages: SDK_PACKAGES, - plugin: CONFIG_PLUGIN, - label: SUPABASE_SDK_LABEL, - jsonFlag, - }); -} +/** Config plugin added alongside the SDK packages. */ +export const CONFIG_PLUGIN = 'expo-sqlite'; From c46e699ca9b74217adbc11a24e1f86b14edb8a28 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Mon, 3 Aug 2026 17:06:06 -0700 Subject: [PATCH 7/9] [eas-cli] Consolidate supabase helpers; move writeEnvVarsAsync Drop constant-only sdk.ts/environments.ts into env.ts. Move the generic writeEnvVarsAsync loop into environments/variables. --- .../eas-cli/src/environments/variables.ts | 11 +++++++++++ .../supabase/__tests__/env-test.ts | 14 ++++++++++++-- .../supabase/__tests__/environments-test.ts | 13 ------------- .../supabase/__tests__/sdk-test.ts | 12 ------------ .../eas-cli/src/integrations/supabase/env.ts | 19 ++++++++----------- .../src/integrations/supabase/environments.ts | 8 -------- .../eas-cli/src/integrations/supabase/sdk.ts | 5 ----- 7 files changed, 31 insertions(+), 51 deletions(-) delete mode 100644 packages/eas-cli/src/integrations/supabase/__tests__/environments-test.ts delete mode 100644 packages/eas-cli/src/integrations/supabase/__tests__/sdk-test.ts delete mode 100644 packages/eas-cli/src/integrations/supabase/environments.ts delete mode 100644 packages/eas-cli/src/integrations/supabase/sdk.ts diff --git a/packages/eas-cli/src/environments/variables.ts b/packages/eas-cli/src/environments/variables.ts index 10b353db01..d604a6a9bb 100644 --- a/packages/eas-cli/src/environments/variables.ts +++ b/packages/eas-cli/src/environments/variables.ts @@ -204,3 +204,14 @@ export async function upsertEasEnvVarForEnvironmentsAsync( ); return true; } + +export async function writeEnvVarsAsync( + envVars: EnvVar[], + upsert: (envVar: EnvVar) => Promise +): Promise { + const easWritten: boolean[] = []; + for (const envVar of envVars) { + easWritten.push(await upsert(envVar)); + } + return easWritten; +} diff --git a/packages/eas-cli/src/integrations/supabase/__tests__/env-test.ts b/packages/eas-cli/src/integrations/supabase/__tests__/env-test.ts index 8378034395..035be2c2a7 100644 --- a/packages/eas-cli/src/integrations/supabase/__tests__/env-test.ts +++ b/packages/eas-cli/src/integrations/supabase/__tests__/env-test.ts @@ -4,6 +4,7 @@ import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/cr import { upsertEasEnvVarAsync, upsertEasEnvVarForEnvironmentsAsync, + writeEnvVarsAsync, } from '../../../environments/variables'; import { EnvironmentVariableScope, @@ -14,13 +15,14 @@ import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentV import { mergeEnvContent, writeEnvLocalAsync } from '../../shared/envFile'; import Log from '../../../log'; import { confirmAsync } from '../../../prompts'; +import { DefaultEnvironment } from '../../../build/utils/environment'; import { + EAS_SUPABASE_ENVIRONMENTS, EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME, EAS_SUPABASE_URL_ENV_VAR_NAME, SUPABASE_ENV_LABEL, createSupabaseEnvVars, ensureAdditionalEnvWritesAllowedAsync, - writeEnvVarsAsync, } from '../env'; jest.mock('fs-extra'); @@ -29,7 +31,7 @@ jest.mock('../../../graphql/queries/EnvironmentVariablesQuery'); jest.mock('../../../prompts'); jest.mock('../../../log'); -describe('createSupabaseEnvVars / writeEnvVarsAsync', () => { +describe('createSupabaseEnvVars / writeEnvVarsAsync / constants', () => { it('createSupabaseEnvVars returns public URL and key vars', () => { expect(createSupabaseEnvVars('https://example.supabase.co', 'pk')).toEqual([ { @@ -45,6 +47,14 @@ describe('createSupabaseEnvVars / writeEnvVarsAsync', () => { ]); }); + it('lists production, preview, and development in that order', () => { + expect(EAS_SUPABASE_ENVIRONMENTS).toEqual([ + DefaultEnvironment.Production, + DefaultEnvironment.Preview, + DefaultEnvironment.Development, + ]); + }); + it('writeEnvVarsAsync runs upsert for each var', async () => { const upsert = jest.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false); await expect( diff --git a/packages/eas-cli/src/integrations/supabase/__tests__/environments-test.ts b/packages/eas-cli/src/integrations/supabase/__tests__/environments-test.ts deleted file mode 100644 index c24dcb57fc..0000000000 --- a/packages/eas-cli/src/integrations/supabase/__tests__/environments-test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { DefaultEnvironment } from '../../../build/utils/environment'; - -import { EAS_SUPABASE_ENVIRONMENTS } from '../environments'; - -describe('EAS_SUPABASE_ENVIRONMENTS', () => { - it('lists production, preview, and development in that order', () => { - expect(EAS_SUPABASE_ENVIRONMENTS).toEqual([ - DefaultEnvironment.Production, - DefaultEnvironment.Preview, - DefaultEnvironment.Development, - ]); - }); -}); diff --git a/packages/eas-cli/src/integrations/supabase/__tests__/sdk-test.ts b/packages/eas-cli/src/integrations/supabase/__tests__/sdk-test.ts deleted file mode 100644 index 987d984106..0000000000 --- a/packages/eas-cli/src/integrations/supabase/__tests__/sdk-test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { CONFIG_PLUGIN, SDK_PACKAGES } from '../sdk'; - -describe('Supabase SDK constants', () => { - it('lists the packages and plugin connect installs', () => { - expect(SDK_PACKAGES).toEqual([ - '@supabase/supabase-js', - 'react-native-url-polyfill', - 'expo-sqlite', - ]); - expect(CONFIG_PLUGIN).toBe('expo-sqlite'); - }); -}); diff --git a/packages/eas-cli/src/integrations/supabase/env.ts b/packages/eas-cli/src/integrations/supabase/env.ts index 787cbaa1c2..89c1747247 100644 --- a/packages/eas-cli/src/integrations/supabase/env.ts +++ b/packages/eas-cli/src/integrations/supabase/env.ts @@ -1,3 +1,4 @@ +import { DefaultEnvironment } from '../../build/utils/environment'; import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; import { EnvVar, loadProjectScopedEnvVarsAsync } from '../../environments/variables'; import { EnvironmentVariableVisibility } from '../../graphql/generated'; @@ -9,6 +10,13 @@ export const EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME = 'EXPO_PUBLIC_SUPABASE_P /** 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 createSupabaseEnvVars(url: string, publishableKey: string): EnvVar[] { return [ { @@ -24,17 +32,6 @@ export function createSupabaseEnvVars(url: string, publishableKey: string): EnvV ]; } -export async function writeEnvVarsAsync( - envVars: EnvVar[], - upsert: (envVar: EnvVar) => Promise -): Promise { - const easWritten: boolean[] = []; - for (const envVar of envVars) { - easWritten.push(await upsert(envVar)); - } - return easWritten; -} - export async function ensureAdditionalEnvWritesAllowedAsync( graphqlClient: ExpoGraphqlClient, projectId: string, diff --git a/packages/eas-cli/src/integrations/supabase/environments.ts b/packages/eas-cli/src/integrations/supabase/environments.ts deleted file mode 100644 index cadab61ed4..0000000000 --- a/packages/eas-cli/src/integrations/supabase/environments.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { DefaultEnvironment } from '../../build/utils/environment'; - -// Production-first, matching the PostHog and Convex integrations. -export const EAS_SUPABASE_ENVIRONMENTS = [ - DefaultEnvironment.Production, - DefaultEnvironment.Preview, - DefaultEnvironment.Development, -]; diff --git a/packages/eas-cli/src/integrations/supabase/sdk.ts b/packages/eas-cli/src/integrations/supabase/sdk.ts deleted file mode 100644 index 0028f1dec7..0000000000 --- a/packages/eas-cli/src/integrations/supabase/sdk.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** Packages installed by `eas integrations:supabase:connect`. */ -export const SDK_PACKAGES = ['@supabase/supabase-js', 'react-native-url-polyfill', 'expo-sqlite']; - -/** Config plugin added alongside the SDK packages. */ -export const CONFIG_PLUGIN = 'expo-sqlite'; From be03008340738c7f664b653d27c3f9085af65f5d Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Mon, 3 Aug 2026 23:19:08 -0700 Subject: [PATCH 8/9] [eas-cli] Address Supabase foundation review feedback --- .../commandUtils/__tests__/supabase-test.ts | 7 + packages/eas-cli/src/commandUtils/supabase.ts | 3 +- .../environments/__tests__/resolve-test.ts | 7 +- .../environments/__tests__/variables-test.ts | 426 +++++++++++++++ packages/eas-cli/src/environments/resolve.ts | 17 +- .../eas-cli/src/environments/variables.ts | 273 +++++----- packages/eas-cli/src/graphql/generated.ts | 2 +- .../src/graphql/mutations/SupabaseMutation.ts | 1 - .../src/graphql/types/SupabaseConnection.ts | 2 +- .../shared/__tests__/envFile-test.ts | 198 +++++++ .../integrations/shared/__tests__/sdk-test.ts | 6 +- .../src/integrations/shared/envFile.ts | 64 ++- .../eas-cli/src/integrations/shared/sdk.ts | 26 +- .../supabase/__tests__/env-test.ts | 509 +----------------- .../supabase/__tests__/provision-test.ts | 15 +- .../eas-cli/src/integrations/supabase/env.ts | 21 +- .../src/integrations/supabase/provision.ts | 8 +- packages/eas-cli/src/utils/prompts.ts | 17 +- 18 files changed, 898 insertions(+), 704 deletions(-) create mode 100644 packages/eas-cli/src/environments/__tests__/variables-test.ts create mode 100644 packages/eas-cli/src/integrations/shared/__tests__/envFile-test.ts diff --git a/packages/eas-cli/src/commandUtils/__tests__/supabase-test.ts b/packages/eas-cli/src/commandUtils/__tests__/supabase-test.ts index 8f21dad645..b2880bf80a 100644 --- a/packages/eas-cli/src/commandUtils/__tests__/supabase-test.ts +++ b/packages/eas-cli/src/commandUtils/__tests__/supabase-test.ts @@ -133,6 +133,13 @@ describe(parseSupabaseProjectRef, () => { ); }); + 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/ diff --git a/packages/eas-cli/src/commandUtils/supabase.ts b/packages/eas-cli/src/commandUtils/supabase.ts index 0c8b367cf4..949c6393da 100644 --- a/packages/eas-cli/src/commandUtils/supabase.ts +++ b/packages/eas-cli/src/commandUtils/supabase.ts @@ -51,7 +51,8 @@ export function parseSupabaseProjectRef(input: string): string { `"${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.` ); } - return ref; + // 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`. */ diff --git a/packages/eas-cli/src/environments/__tests__/resolve-test.ts b/packages/eas-cli/src/environments/__tests__/resolve-test.ts index 85cb36f02c..c1b6e20f97 100644 --- a/packages/eas-cli/src/environments/__tests__/resolve-test.ts +++ b/packages/eas-cli/src/environments/__tests__/resolve-test.ts @@ -33,7 +33,10 @@ describe('parseEnvironmentFlag', () => { describe('resolveTargetEnvironmentsAsync', () => { const client = {} as ExpoGraphqlClient; - const options = { defaultEnvironments: DEFAULT_ENVIRONMENTS, label: 'Example' }; + const options = { + defaultEnvironments: DEFAULT_ENVIRONMENTS, + cancelMessage: (knownEnvironments: string) => `Canceled. Known: ${knownEnvironments}.`, + }; beforeEach(() => { jest.resetAllMocks(); @@ -132,7 +135,7 @@ describe('resolveTargetEnvironmentsAsync', () => { await expect( resolveTargetEnvironmentsAsync(client, 'app-1', ['preview'], false, options) - ).rejects.toThrow(/Canceled\. No additional Example project was provisioned\./); + ).rejects.toThrow('Canceled. Known: production.'); }); it('mentions enterprise plan for custom environments interactively', async () => { 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/resolve.ts b/packages/eas-cli/src/environments/resolve.ts index 0a7e11cb05..f98943d41a 100644 --- a/packages/eas-cli/src/environments/resolve.ts +++ b/packages/eas-cli/src/environments/resolve.ts @@ -1,6 +1,7 @@ import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; import { confirmAsync } from '../prompts'; -import { getProjectEnvironmentVariableEnvironmentsAsync } from '../utils/prompts'; + +import { getProjectEnvironmentVariableEnvironmentsAsync } from './variables'; export function parseEnvironmentFlag(value: string | undefined): string[] | null { if (value === undefined) { @@ -36,14 +37,20 @@ export function parseEnvironmentFlag(value: string | undefined): string[] | null /** * Resolve requested EAS environment names against what the project already uses. - * Shared across integrations; pass integration-specific defaults and label at each callsite. + * 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, label }: { defaultEnvironments: string[]; label: string } + { + defaultEnvironments, + cancelMessage, + }: { + defaultEnvironments: string[]; + cancelMessage: (knownEnvironments: string) => string; + } ): Promise { let known = await getProjectEnvironmentVariableEnvironmentsAsync(graphqlClient, projectId); if (known.length === 0) { @@ -75,9 +82,7 @@ export async function resolveTargetEnvironmentsAsync( message: `EAS ${noun} ${listed} ${verb} not used on this project yet.${customHint} Continue provisioning?`, }); if (!create) { - throw new Error( - `Canceled. No additional ${label} project was provisioned. Create the environment(s) first, or pass only existing ones (known: ${known.join(', ')}).` - ); + 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 index d604a6a9bb..e7eb7a2a02 100644 --- a/packages/eas-cli/src/environments/variables.ts +++ b/packages/eas-cli/src/environments/variables.ts @@ -17,6 +17,29 @@ 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, @@ -30,188 +53,134 @@ export async function loadProjectScopedEnvVarsAsync( ).filter(variable => variable.scope === EnvironmentVariableScope.Project); } -export async function upsertEasEnvVarAsync( - graphqlClient: ExpoGraphqlClient, - projectId: string, - envVar: EnvVar, - environments: string[], - nonInteractive: boolean, - overwrite: boolean -): Promise { - const existingProjectVariables = await loadProjectScopedEnvVarsAsync( - graphqlClient, - projectId, - envVar.name - ); - - if (existingProjectVariables.length === 0) { - await EnvironmentVariableMutation.createForAppAsync( - graphqlClient, - { - name: envVar.name, - value: envVar.value, - environments, - visibility: envVar.visibility, - type: EnvironmentSecretType.String, - }, - projectId - ); - Log.withTick( - `Created EAS environment variable ${chalk.bold(envVar.name)} for ${environments.join(', ')}` - ); - return true; - } - - const [keeper, ...extras] = existingProjectVariables; - const extraEnvironments = [...new Set(extras.flatMap(variable => variable.environments ?? []))]; - const shouldOverwrite = - overwrite || - (!nonInteractive && - (await confirmAsync({ - message: - extras.length > 0 - ? `EAS has multiple ${envVar.name} variables for this project (including ${extraEnvironments.join(', ') || 'other environments'}). Replace them with one value for ${environments.join(', ')}?` - : `EAS already has an ${envVar.name} environment variable for this project. Overwrite it?`, - }))); - if (!shouldOverwrite) { - Log.warn( - `Skipped updating EAS environment variable ${chalk.bold(envVar.name)}${ - nonInteractive ? ' (pass --overwrite to replace it)' : '' - }.` - ); - return false; - } - - for (const extra of extras) { - await EnvironmentVariableMutation.deleteAsync(graphqlClient, extra.id); - } - await EnvironmentVariableMutation.updateAsync(graphqlClient, { - id: keeper.id, - name: envVar.name, - value: envVar.value, - environments, - visibility: envVar.visibility, - type: EnvironmentSecretType.String, - }); - Log.withTick( - `Updated EAS environment variable ${chalk.bold(envVar.name)} for ${environments.join(', ')}` - ); - return true; -} - -export async function upsertEasEnvVarForEnvironmentsAsync( +export async function upsertEnvVarAsync( graphqlClient: ExpoGraphqlClient, projectId: string, envVar: EnvVar, environments: string[], nonInteractive: boolean, overwrite: boolean, - { label }: { label: string } -): Promise { - const existingVariables = await loadProjectScopedEnvVarsAsync( - graphqlClient, - projectId, - envVar.name - ); - - const targetSet = new Set(environments); - const exactMatch = existingVariables.find(variable => { - const current = variable.environments ?? []; - return ( - current.length === targetSet.size && current.every(environment => targetSet.has(environment)) - ); - }); - if (exactMatch) { - const shouldOverwrite = - overwrite || - exactMatch.value === envVar.value || - (!nonInteractive && - (await confirmAsync({ - message: `EAS already has ${envVar.name} for ${environments.join(', ')}. Overwrite it?`, - }))); - if (!shouldOverwrite) { - Log.warn(`Skipped updating EAS environment variable ${chalk.bold(envVar.name)}.`); - return false; - } - await EnvironmentVariableMutation.updateAsync(graphqlClient, { - id: exactMatch.id, - name: envVar.name, - value: envVar.value, - environments, - visibility: envVar.visibility, - type: EnvironmentSecretType.String, - }); - Log.withTick( - `Updated EAS environment variable ${chalk.bold(envVar.name)} for ${environments.join(', ')}` - ); - return true; + { + 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); - const toDelete: { id: string; overlap: string[] }[] = []; - const toShrink: { id: string; overlap: string[]; remaining: string[] }[] = []; - for (const variable of existingVariables) { - const current = variable.environments ?? []; - const overlap = current.filter(environment => targetSet.has(environment)); - if (overlap.length === 0) { + // 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; } - const remaining = current.filter(environment => !targetSet.has(environment)); - if (remaining.length === 0) { - toDelete.push({ id: variable.id, overlap }); + 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 { - toShrink.push({ id: variable.id, overlap, remaining }); + 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 ((toDelete.length > 0 || toShrink.length > 0) && !overwrite) { - const overlapLabel = [ - ...new Set([...toDelete, ...toShrink].flatMap(item => item.overlap)), - ].join(', '); - const shouldOverwrite = - !nonInteractive && - (await confirmAsync({ - message: `Move ${envVar.name} for ${overlapLabel} to the additional ${label} project?`, - })); - if (!shouldOverwrite) { - Log.warn(`Skipped updating EAS environment variable ${chalk.bold(envVar.name)}.`); + 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; } } - for (const item of toDelete) { - await EnvironmentVariableMutation.deleteAsync(graphqlClient, item.id); - } - for (const item of toShrink) { + // 3. Apply: hand environments over, drop redundant rows, then land the value. + for (const { id, keptEnvironments } of rowsToShrink) { await EnvironmentVariableMutation.updateAsync(graphqlClient, { - id: item.id, - environments: item.remaining, + id, + environments: keptEnvironments, }); } + for (const { id } of redundantRows) { + await EnvironmentVariableMutation.deleteAsync(graphqlClient, id); + } - await EnvironmentVariableMutation.createForAppAsync( - graphqlClient, - { - name: envVar.name, - value: envVar.value, - environments, - visibility: envVar.visibility, - type: EnvironmentSecretType.String, - }, - projectId - ); + 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( - `Created EAS environment variable ${chalk.bold(envVar.name)} for ${environments.join(', ')}` + `${rowToReuse ? 'Updated' : 'Created'} EAS environment variable ${chalk.bold( + envVar.name + )} for ${environments.join(', ')}` ); return true; } -export async function writeEnvVarsAsync( +export async function upsertEnvVarsSequentiallyAsync( envVars: EnvVar[], upsert: (envVar: EnvVar) => Promise ): Promise { - const easWritten: boolean[] = []; + const written: boolean[] = []; for (const envVar of envVars) { - easWritten.push(await upsert(envVar)); + written.push(await upsert(envVar)); } - return easWritten; + return written; } diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index 250d3a2f19..91fed12a8e 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -14345,7 +14345,7 @@ export type BeginSupabaseOAuthMutationVariables = Exact<{ }>; -export type BeginSupabaseOAuthMutation = { __typename?: 'RootMutation', supabaseConnection: { __typename?: 'SupabaseConnectionMutation', beginSupabaseOAuth: { __typename?: 'SupabaseOAuthStart', state: string, url: string } } }; +export type BeginSupabaseOAuthMutation = { __typename?: 'RootMutation', supabaseConnection: { __typename?: 'SupabaseConnectionMutation', beginSupabaseOAuth: { __typename?: 'SupabaseOAuthStart', url: string } } }; export type SetSupabaseConnectionOrganizationMutationVariables = Exact<{ input: SetSupabaseConnectionOrganizationInput; diff --git a/packages/eas-cli/src/graphql/mutations/SupabaseMutation.ts b/packages/eas-cli/src/graphql/mutations/SupabaseMutation.ts index f82099214c..9b7c9e6cbc 100644 --- a/packages/eas-cli/src/graphql/mutations/SupabaseMutation.ts +++ b/packages/eas-cli/src/graphql/mutations/SupabaseMutation.ts @@ -34,7 +34,6 @@ export const SupabaseMutation = { mutation BeginSupabaseOAuth($input: BeginSupabaseOAuthInput!) { supabaseConnection { beginSupabaseOAuth(input: $input) { - state url } } diff --git a/packages/eas-cli/src/graphql/types/SupabaseConnection.ts b/packages/eas-cli/src/graphql/types/SupabaseConnection.ts index 3ee1bbda46..fedc3446e2 100644 --- a/packages/eas-cli/src/graphql/types/SupabaseConnection.ts +++ b/packages/eas-cli/src/graphql/types/SupabaseConnection.ts @@ -33,7 +33,7 @@ export type SupabaseProjectData = Pick< | 'updatedAt' >; -export type SupabaseOAuthStartData = Pick; +export type SupabaseOAuthStartData = Pick; export const SupabaseConnectionFragmentNode = gql` fragment SupabaseConnectionFragment on SupabaseConnection { 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 index f518b7224e..40ed02730e 100644 --- a/packages/eas-cli/src/integrations/shared/__tests__/sdk-test.ts +++ b/packages/eas-cli/src/integrations/shared/__tests__/sdk-test.ts @@ -172,7 +172,7 @@ describe('setupSdkAndConfigAsync', () => { jest.clearAllMocks(); }); - it('adds install failure guidance', async () => { + 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); @@ -182,7 +182,9 @@ describe('setupSdkAndConfigAsync', () => { label, jsonFlag: true, }); - expect(steps[0]).toContain('npx expo install'); + 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 () => { diff --git a/packages/eas-cli/src/integrations/shared/envFile.ts b/packages/eas-cli/src/integrations/shared/envFile.ts index f4c0a6d70a..f1898669f9 100644 --- a/packages/eas-cli/src/integrations/shared/envFile.ts +++ b/packages/eas-cli/src/integrations/shared/envFile.ts @@ -50,24 +50,64 @@ export async function writeEnvLocalAsync( 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 { - let content = rawContent; - const keysToAdd: Record = { ...newVars }; - for (const [key, value] of Object.entries(newVars)) { - const regex = new RegExp(`^${key}=.*$`, 'm'); - if (regex.test(content)) { - content = content.replace(regex, () => `${key}=${value}`); - delete keysToAdd[key]; + const 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); } - const remaining = Object.entries(keysToAdd); - if (remaining.length > 0) { + + for (const [key, value] of Object.entries(newVars).filter(([key]) => !rewritten.has(key))) { if (content.length > 0 && !content.endsWith('\n')) { content += '\n'; } - for (const [key, value] of remaining) { - content += `${key}=${value}\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 index a1d684bd6f..a62ac4ae2a 100644 --- a/packages/eas-cli/src/integrations/shared/sdk.ts +++ b/packages/eas-cli/src/integrations/shared/sdk.ts @@ -9,9 +9,8 @@ import { createOrModifyExpoConfigAsync } from '../../project/expoConfig'; const DYNAMIC_CONFIG_MARKER = 'Cannot automatically write to dynamic config'; export type SdkInstallResult = - | { status: 'installed' } - | { status: 'failed' } - | { status: 'installed'; dynamicConfigGuidance: string }; + | { status: 'installed'; dynamicConfigGuidance?: string } + | { status: 'failed' }; export function getSpawnErrorOutput(error: unknown): string { const { stdout, stderr } = (error ?? {}) as { stdout?: string; stderr?: string }; @@ -26,6 +25,7 @@ export function extractDynamicConfigGuidance(output: string): string | 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; @@ -99,19 +99,15 @@ export async function setupSdkAndConfigAsync( }: { packages: string[]; plugin: string; label: string; jsonFlag: boolean } ): Promise { const installResult = await installSdkPackagesAsync(projectDir, { packages, label, jsonFlag }); - const manualSteps: string[] = []; + // Adding the plugin for a package that isn't installed leaves the app config unresolvable. if (installResult.status === 'failed') { - manualSteps.push( - `The ${label} SDK packages didn't install. Run npx expo install ${packages.join(' ')} from your project directory.` - ); + 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.status === 'installed' && 'dynamicConfigGuidance' in installResult) { - manualSteps.push(installResult.dynamicConfigGuidance); - } else { - const pluginManualStep = await addConfigPluginAsync(projectDir, exp, { plugin }); - if (pluginManualStep) { - manualSteps.push(pluginManualStep); - } + if (installResult.dynamicConfigGuidance) { + return [installResult.dynamicConfigGuidance]; } - return manualSteps; + 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 index 035be2c2a7..828127a54f 100644 --- a/packages/eas-cli/src/integrations/supabase/__tests__/env-test.ts +++ b/packages/eas-cli/src/integrations/supabase/__tests__/env-test.ts @@ -1,38 +1,27 @@ -import * as fs from 'fs-extra'; - +import { DefaultEnvironment } from '../../../build/utils/environment'; import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; -import { - upsertEasEnvVarAsync, - upsertEasEnvVarForEnvironmentsAsync, - writeEnvVarsAsync, -} from '../../../environments/variables'; import { EnvironmentVariableScope, EnvironmentVariableVisibility, } from '../../../graphql/generated'; -import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation'; import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery'; -import { mergeEnvContent, writeEnvLocalAsync } from '../../shared/envFile'; -import Log from '../../../log'; import { confirmAsync } from '../../../prompts'; -import { DefaultEnvironment } from '../../../build/utils/environment'; import { EAS_SUPABASE_ENVIRONMENTS, EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME, EAS_SUPABASE_URL_ENV_VAR_NAME, - SUPABASE_ENV_LABEL, + confirmOverwriteForAdditionalProjectAsync, createSupabaseEnvVars, - ensureAdditionalEnvWritesAllowedAsync, + supabaseEnvironmentCancelMessage, + supabaseMoveConfirmMessage, } from '../env'; -jest.mock('fs-extra'); -jest.mock('../../../graphql/mutations/EnvironmentVariableMutation'); jest.mock('../../../graphql/queries/EnvironmentVariablesQuery'); jest.mock('../../../prompts'); jest.mock('../../../log'); -describe('createSupabaseEnvVars / writeEnvVarsAsync / constants', () => { - it('createSupabaseEnvVars returns public URL and key vars', () => { +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, @@ -54,257 +43,44 @@ describe('createSupabaseEnvVars / writeEnvVarsAsync / constants', () => { DefaultEnvironment.Development, ]); }); - - it('writeEnvVarsAsync runs upsert for each var', async () => { - const upsert = jest.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false); - await expect( - writeEnvVarsAsync( - [ - { - name: 'A', - value: '1', - visibility: EnvironmentVariableVisibility.Public, - }, - { - name: 'B', - value: '2', - visibility: EnvironmentVariableVisibility.Public, - }, - ], - upsert - ) - ).resolves.toEqual([true, false]); - }); -}); - -describe('shared writeEnvLocalAsync / mergeEnvContent (via Supabase label)', () => { - const envVars = createSupabaseEnvVars('https://example.supabase.co', 'pk'); - - beforeEach(() => { - jest.resetAllMocks(); - jest.mocked(fs.pathExists).mockResolvedValue(false as never); - jest.mocked(fs.writeFile).mockResolvedValue(undefined as never); - }); - - it('mergeEnvContent 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('writes a new .env.local file', async () => { - await expect( - writeEnvLocalAsync('/project', envVars, { - label: SUPABASE_ENV_LABEL, - nonInteractive: true, - overwrite: false, - }) - ).resolves.toBe(true); - expect(fs.writeFile).toHaveBeenCalledWith( - expect.stringContaining('.env.local'), - expect.stringContaining(EAS_SUPABASE_URL_ENV_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(`${EAS_SUPABASE_URL_ENV_VAR_NAME}=old\n` as never); - - await expect( - writeEnvLocalAsync('/project', envVars, { - label: SUPABASE_ENV_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(`${EAS_SUPABASE_URL_ENV_VAR_NAME}=old\n` as never); - jest.mocked(confirmAsync).mockResolvedValue(false); - - await expect( - writeEnvLocalAsync('/project', envVars, { - label: SUPABASE_ENV_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(`${EAS_SUPABASE_URL_ENV_VAR_NAME}=old\n` as never); - jest.mocked(confirmAsync).mockResolvedValue(true); - - await expect( - writeEnvLocalAsync('/project', envVars, { - label: SUPABASE_ENV_LABEL, - nonInteractive: false, - overwrite: false, - }) - ).resolves.toBe(true); - await expect( - writeEnvLocalAsync('/project', envVars, { - label: SUPABASE_ENV_LABEL, - nonInteractive: true, - overwrite: true, - }) - ).resolves.toBe(true); - expect(fs.writeFile).toHaveBeenCalled(); - }); }); -describe('upsertEasEnvVarAsync', () => { - const client = {} as ExpoGraphqlClient; - const envVar = createSupabaseEnvVars('https://example.supabase.co', 'pk')[0]; - - beforeEach(() => { - jest.resetAllMocks(); - }); - - it('creates when no project-scoped variable exists', async () => { - jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([]); - jest.mocked(EnvironmentVariableMutation.createForAppAsync).mockResolvedValue({} as never); - - await expect( - upsertEasEnvVarAsync(client, 'app-1', envVar, ['production'], true, false) - ).resolves.toBe(true); - expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled(); - }); - - it('skips existing variable without overwrite in non-interactive mode', async () => { - jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ - { - id: 'v1', - scope: EnvironmentVariableScope.Project, - environments: ['production'], - } as never, - ]); - - await expect( - upsertEasEnvVarAsync(client, 'app-1', envVar, ['production'], true, false) - ).resolves.toBe(false); - expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('--overwrite')); - }); - - it('updates existing variable and deletes extras when overwriting', async () => { - jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ - { - id: 'keeper', - scope: EnvironmentVariableScope.Project, - environments: ['production'], - }, - { - id: 'extra', - scope: EnvironmentVariableScope.Project, - environments: ['preview'], - }, - ] as never); - jest.mocked(EnvironmentVariableMutation.deleteAsync).mockResolvedValue({} as never); - jest.mocked(EnvironmentVariableMutation.updateAsync).mockResolvedValue({} as never); - - await expect( - upsertEasEnvVarAsync(client, 'app-1', envVar, ['production', 'preview'], true, true) - ).resolves.toBe(true); - expect(EnvironmentVariableMutation.deleteAsync).toHaveBeenCalledWith(client, 'extra'); - expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith( - client, - expect.objectContaining({ id: 'keeper' }) +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('prompts with multi-variable message interactively', async () => { - jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ - { - id: 'keeper', - scope: EnvironmentVariableScope.Project, - environments: ['production'], - }, - { - id: 'extra', - scope: EnvironmentVariableScope.Project, - environments: ['preview'], - }, - ] as never); - jest.mocked(confirmAsync).mockResolvedValue(true); - jest.mocked(EnvironmentVariableMutation.deleteAsync).mockResolvedValue({} as never); - jest.mocked(EnvironmentVariableMutation.updateAsync).mockResolvedValue({} as never); - - await expect( - upsertEasEnvVarAsync(client, 'app-1', envVar, ['production'], false, false) - ).resolves.toBe(true); - expect(confirmAsync).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining('multiple') }) + it('lists known environments when provisioning is canceled', () => { + expect(supabaseEnvironmentCancelMessage('production, preview')).toContain( + 'No additional Supabase project was provisioned' ); - }); - - it('uses other-environments wording when extras have no environment lists', async () => { - jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ - { - id: 'keeper', - scope: EnvironmentVariableScope.Project, - environments: ['production'], - }, - { - id: 'extra', - scope: EnvironmentVariableScope.Project, - environments: null, - }, - ] as never); - jest.mocked(confirmAsync).mockResolvedValue(true); - jest.mocked(EnvironmentVariableMutation.deleteAsync).mockResolvedValue({} as never); - jest.mocked(EnvironmentVariableMutation.updateAsync).mockResolvedValue({} as never); - - await expect( - upsertEasEnvVarAsync(client, 'app-1', envVar, ['production'], false, false) - ).resolves.toBe(true); - expect(confirmAsync).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining('other environments') }) + expect(supabaseEnvironmentCancelMessage('production, preview')).toContain( + 'known: production, preview' ); }); - - it('skips interactively without mentioning --overwrite', async () => { - jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ - { - id: 'v1', - scope: EnvironmentVariableScope.Project, - environments: ['production'], - } as never, - ]); - jest.mocked(confirmAsync).mockResolvedValue(false); - - await expect( - upsertEasEnvVarAsync(client, 'app-1', envVar, ['production'], false, false) - ).resolves.toBe(false); - expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('Skipped updating')); - expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining('--overwrite')); - }); }); -describe('ensureAdditionalEnvWritesAllowedAsync', () => { +describe('confirmOverwriteForAdditionalProjectAsync', () => { const client = {} as ExpoGraphqlClient; beforeEach(() => { jest.resetAllMocks(); }); - it('returns true immediately with overwrite', async () => { + it('forces overwrite immediately with overwrite', async () => { await expect( - ensureAdditionalEnvWritesAllowedAsync(client, 'app-1', ['preview'], true, true) - ).resolves.toBe(true); + confirmOverwriteForAdditionalProjectAsync(client, 'app-1', ['preview'], true, true) + ).resolves.toEqual({ forceOverwrite: true }); expect(EnvironmentVariablesQuery.byAppIdAsync).not.toHaveBeenCalled(); }); - it('returns false when there is no overlap', async () => { + it('does not force overwrite when there is no overlap', async () => { jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([]); await expect( - ensureAdditionalEnvWritesAllowedAsync(client, 'app-1', ['preview'], true, false) - ).resolves.toBe(false); + confirmOverwriteForAdditionalProjectAsync(client, 'app-1', ['preview'], true, false) + ).resolves.toEqual({ forceOverwrite: false }); }); it('treats null environments as no overlap', async () => { @@ -316,8 +92,8 @@ describe('ensureAdditionalEnvWritesAllowedAsync', () => { } as never, ]); await expect( - ensureAdditionalEnvWritesAllowedAsync(client, 'app-1', ['preview'], true, false) - ).resolves.toBe(false); + confirmOverwriteForAdditionalProjectAsync(client, 'app-1', ['preview'], true, false) + ).resolves.toEqual({ forceOverwrite: false }); }); it('throws in non-interactive mode when overlap exists', async () => { @@ -330,11 +106,11 @@ describe('ensureAdditionalEnvWritesAllowedAsync', () => { ]); await expect( - ensureAdditionalEnvWritesAllowedAsync(client, 'app-1', ['preview'], true, false) + confirmOverwriteForAdditionalProjectAsync(client, 'app-1', ['preview'], true, false) ).rejects.toThrow(/Re-run with --overwrite/); }); - it('returns true after interactive confirmation', async () => { + it('forces overwrite after interactive confirmation', async () => { jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ { id: 'v1', @@ -345,8 +121,8 @@ describe('ensureAdditionalEnvWritesAllowedAsync', () => { jest.mocked(confirmAsync).mockResolvedValue(true); await expect( - ensureAdditionalEnvWritesAllowedAsync(client, 'app-1', ['preview'], false, false) - ).resolves.toBe(true); + confirmOverwriteForAdditionalProjectAsync(client, 'app-1', ['preview'], false, false) + ).resolves.toEqual({ forceOverwrite: true }); }); it('throws when interactive confirmation is declined', async () => { @@ -360,234 +136,7 @@ describe('ensureAdditionalEnvWritesAllowedAsync', () => { jest.mocked(confirmAsync).mockResolvedValue(false); await expect( - ensureAdditionalEnvWritesAllowedAsync(client, 'app-1', ['preview'], false, false) + confirmOverwriteForAdditionalProjectAsync(client, 'app-1', ['preview'], false, false) ).rejects.toThrow(/Canceled/); }); }); - -describe('upsertEasEnvVarForEnvironmentsAsync', () => { - const client = {} as ExpoGraphqlClient; - const envVar = createSupabaseEnvVars('https://example.supabase.co', 'pk')[0]; - const labelOpts = { label: SUPABASE_ENV_LABEL }; - - beforeEach(() => { - jest.resetAllMocks(); - jest.mocked(EnvironmentVariableMutation.createForAppAsync).mockResolvedValue({} as never); - jest.mocked(EnvironmentVariableMutation.updateAsync).mockResolvedValue({} as never); - jest.mocked(EnvironmentVariableMutation.deleteAsync).mockResolvedValue({} as never); - }); - - it('updates an exact environment match', async () => { - jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ - { - id: 'exact', - scope: EnvironmentVariableScope.Project, - environments: ['preview'], - value: 'old', - } as never, - ]); - - await expect( - upsertEasEnvVarForEnvironmentsAsync( - client, - 'app-1', - envVar, - ['preview'], - true, - true, - labelOpts - ) - ).resolves.toBe(true); - expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith( - client, - expect.objectContaining({ id: 'exact' }) - ); - }); - - it('skips exact match without overwrite when values differ', async () => { - jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ - { - id: 'exact', - scope: EnvironmentVariableScope.Project, - environments: ['preview'], - value: 'old', - } as never, - ]); - - await expect( - upsertEasEnvVarForEnvironmentsAsync( - client, - 'app-1', - envVar, - ['preview'], - true, - false, - labelOpts - ) - ).resolves.toBe(false); - }); - - it('auto-overwrites exact match when values already match', async () => { - jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ - { - id: 'exact', - scope: EnvironmentVariableScope.Project, - environments: ['preview'], - value: envVar.value, - } as never, - ]); - - await expect( - upsertEasEnvVarForEnvironmentsAsync( - client, - 'app-1', - envVar, - ['preview'], - true, - false, - labelOpts - ) - ).resolves.toBe(true); - }); - - it('creates after shrinking overlapping environments', async () => { - jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ - { - id: 'shared', - scope: EnvironmentVariableScope.Project, - environments: ['production', 'preview'], - value: 'old', - } as never, - ]); - - await expect( - upsertEasEnvVarForEnvironmentsAsync( - client, - 'app-1', - envVar, - ['preview'], - true, - true, - labelOpts - ) - ).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('skips when overlapping move is declined', async () => { - jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ - { - id: 'shared', - scope: EnvironmentVariableScope.Project, - environments: ['production', 'preview'], - value: 'old', - } as never, - ]); - jest.mocked(confirmAsync).mockResolvedValue(false); - - await expect( - upsertEasEnvVarForEnvironmentsAsync( - client, - 'app-1', - envVar, - ['preview'], - false, - false, - labelOpts - ) - ).resolves.toBe(false); - expect(confirmAsync).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('to the additional Supabase project?'), - }) - ); - }); - - it('deletes variables fully covered by the target environments', async () => { - jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ - { - id: 'preview-only', - scope: EnvironmentVariableScope.Project, - environments: ['preview'], - value: 'old', - }, - ] as never); - - await expect( - upsertEasEnvVarForEnvironmentsAsync( - client, - 'app-1', - envVar, - ['preview', 'development'], - true, - true, - labelOpts - ) - ).resolves.toBe(true); - expect(EnvironmentVariableMutation.deleteAsync).toHaveBeenCalledWith(client, 'preview-only'); - expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled(); - }); - - it('ignores variables with no overlapping environments', async () => { - jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ - { - id: 'production-only', - scope: EnvironmentVariableScope.Project, - environments: ['production'], - value: 'old', - }, - { - id: 'no-envs', - scope: EnvironmentVariableScope.Project, - environments: null, - value: 'old', - }, - ] as never); - - await expect( - upsertEasEnvVarForEnvironmentsAsync( - client, - 'app-1', - envVar, - ['preview'], - true, - false, - labelOpts - ) - ).resolves.toBe(true); - expect(EnvironmentVariableMutation.deleteAsync).not.toHaveBeenCalled(); - expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled(); - }); - - it('prompts before overwriting an exact match interactively', async () => { - jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([ - { - id: 'exact', - scope: EnvironmentVariableScope.Project, - environments: ['preview'], - value: 'old', - } as never, - ]); - jest.mocked(confirmAsync).mockResolvedValue(true); - - await expect( - upsertEasEnvVarForEnvironmentsAsync( - client, - 'app-1', - envVar, - ['preview'], - false, - false, - labelOpts - ) - ).resolves.toBe(true); - }); -}); diff --git a/packages/eas-cli/src/integrations/supabase/__tests__/provision-test.ts b/packages/eas-cli/src/integrations/supabase/__tests__/provision-test.ts index 4fb0415263..cdb0d09e1f 100644 --- a/packages/eas-cli/src/integrations/supabase/__tests__/provision-test.ts +++ b/packages/eas-cli/src/integrations/supabase/__tests__/provision-test.ts @@ -48,12 +48,14 @@ 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); @@ -135,13 +137,14 @@ describe('provision hints and poll errors', () => { describe('pollProvisionReceiptAsync', () => { const client = {} as ExpoGraphqlClient; + let spinner: ReturnType; beforeEach(() => { jest.resetAllMocks(); - mockOraSpinner(); + spinner = mockOraSpinner(); }); - it('returns finalized receipt', async () => { + it('returns the finalized receipt and stops its own spinner', async () => { const receipt = { id: 'r1' } as never; jest.mocked(pollForBackgroundJobReceiptAsync).mockResolvedValue(receipt); @@ -151,7 +154,9 @@ describe('pollProvisionReceiptAsync', () => { failureMessage: 'fail', failureHint: 'hint', }); - expect(result.finalized).toBe(receipt); + expect(result).toBe(receipt); + expect(spinner.stop).toHaveBeenCalled(); + expect(spinner.succeed).not.toHaveBeenCalled(); }); it('fails spinner when poll returns null', async () => { @@ -189,7 +194,6 @@ describe('authorizeViaBrowserAsync / loadOrganizationsBestEffortAsync / pollForC it('authorizeViaBrowserAsync polls until connected', async () => { jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({ - state: 's', url: 'https://oauth.example', }); jest.mocked(openBrowserAsync).mockResolvedValue(true as never); @@ -208,7 +212,6 @@ describe('authorizeViaBrowserAsync / loadOrganizationsBestEffortAsync / pollForC it('authorizeViaBrowserAsync shows URL when browser open fails', async () => { jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({ - state: 's', url: 'https://oauth.example', }); jest.mocked(openBrowserAsync).mockRejectedValue(new Error('no browser')); @@ -221,7 +224,6 @@ describe('authorizeViaBrowserAsync / loadOrganizationsBestEffortAsync / pollForC it('authorizeViaBrowserAsync succeeds when organization listing fails', async () => { jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({ - state: 's', url: 'https://oauth.example', }); jest.mocked(openBrowserAsync).mockResolvedValue(true as never); @@ -237,7 +239,6 @@ describe('authorizeViaBrowserAsync / loadOrganizationsBestEffortAsync / pollForC it('authorizeViaBrowserAsync fails spinner on poll error', async () => { jest.mocked(SupabaseMutation.beginSupabaseOAuthAsync).mockResolvedValue({ - state: 's', url: 'https://oauth.example', }); jest.mocked(openBrowserAsync).mockResolvedValue(true as never); diff --git a/packages/eas-cli/src/integrations/supabase/env.ts b/packages/eas-cli/src/integrations/supabase/env.ts index 89c1747247..41d266f753 100644 --- a/packages/eas-cli/src/integrations/supabase/env.ts +++ b/packages/eas-cli/src/integrations/supabase/env.ts @@ -17,6 +17,17 @@ export const EAS_SUPABASE_ENVIRONMENTS = [ 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 [ { @@ -32,15 +43,15 @@ export function createSupabaseEnvVars(url: string, publishableKey: string): EnvV ]; } -export async function ensureAdditionalEnvWritesAllowedAsync( +export async function confirmOverwriteForAdditionalProjectAsync( graphqlClient: ExpoGraphqlClient, projectId: string, environments: string[], nonInteractive: boolean, overwrite: boolean -): Promise { +): Promise<{ forceOverwrite: boolean }> { if (overwrite) { - return true; + return { forceOverwrite: true }; } const names = [EAS_SUPABASE_URL_ENV_VAR_NAME, EAS_SUPABASE_PUBLISHABLE_KEY_ENV_VAR_NAME]; const targetSet = new Set(environments); @@ -67,7 +78,7 @@ export async function ensureAdditionalEnvWritesAllowedAsync( } // One confirm covers both vars; stop asking. Force overwrite so the per-var upsert // doesn't prompt again and can't return false after we already billed the project. - return true; + return { forceOverwrite: true }; } - return false; + return { forceOverwrite: false }; } diff --git a/packages/eas-cli/src/integrations/supabase/provision.ts b/packages/eas-cli/src/integrations/supabase/provision.ts index 6de0bff6d7..7e086c7546 100644 --- a/packages/eas-cli/src/integrations/supabase/provision.ts +++ b/packages/eas-cli/src/integrations/supabase/provision.ts @@ -15,7 +15,7 @@ import { SupabaseProjectData, } from '../../graphql/types/SupabaseConnection'; import Log, { link } from '../../log'; -import { Ora, ora } from '../../ora'; +import { ora } from '../../ora'; import { selectAsync } from '../../prompts'; import { sleepAsync } from '../../utils/promise'; import { @@ -105,7 +105,7 @@ export async function pollProvisionReceiptAsync( failureMessage: string; failureHint: string; } -): Promise<{ finalized: BackgroundJobReceiptDataFragment; spinner: Ora }> { +): Promise { const spinner = ora(startMessage).start(); try { spinner.text = waitingMessage; @@ -116,7 +116,9 @@ export async function pollProvisionReceiptAsync( if (!finalized) { throw new Error('Supabase project provision finished without a receipt.'); } - return { finalized, spinner }; + // 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 }); diff --git a/packages/eas-cli/src/utils/prompts.ts b/packages/eas-cli/src/utils/prompts.ts index 18505abf7f..b081b129d7 100644 --- a/packages/eas-cli/src/utils/prompts.ts +++ b/packages/eas-cli/src/utils/prompts.ts @@ -2,26 +2,11 @@ import chalk from 'chalk'; 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'; -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 }); - } -} - const CUSTOM_ENVIRONMENT_VALUE = '~~CUSTOM~~'; export async function promptVariableTypeAsync( From b1a0203eb0abdfecbac04a19794575dc6431cd97 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 5 Aug 2026 20:24:38 -0700 Subject: [PATCH 9/9] [eas-cli] Stop polling when a Supabase request fails for good --- .../src/graphql/__tests__/client-test.ts | 42 ++++++ packages/eas-cli/src/graphql/client.ts | 19 +++ .../supabase/__tests__/provision-test.ts | 123 ++++++++++++++++-- .../src/integrations/supabase/provision.ts | 34 ++++- 4 files changed, 203 insertions(+), 15 deletions(-) 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/integrations/supabase/__tests__/provision-test.ts b/packages/eas-cli/src/integrations/supabase/__tests__/provision-test.ts index cdb0d09e1f..557de7423d 100644 --- a/packages/eas-cli/src/integrations/supabase/__tests__/provision-test.ts +++ b/packages/eas-cli/src/integrations/supabase/__tests__/provision-test.ts @@ -1,4 +1,6 @@ +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'; @@ -62,6 +64,18 @@ function mockOraSpinner(): { 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', @@ -237,14 +251,28 @@ describe('authorizeViaBrowserAsync / loadOrganizationsBestEffortAsync / pollForC expect(spinner.succeed).toHaveBeenCalledWith(expect.stringContaining('org-slug')); }); - it('authorizeViaBrowserAsync fails spinner on poll error', async () => { + 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(new Error('always fail')); + .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); @@ -261,10 +289,10 @@ describe('authorizeViaBrowserAsync / loadOrganizationsBestEffortAsync / pollForC await expect(loadOrganizationsBestEffortAsync(client, 'acct-1')).resolves.toBeNull(); }); - it('pollForConnectionAsync retries after query errors', async () => { + it('pollForConnectionAsync retries after retriable query errors', async () => { jest .mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync) - .mockRejectedValueOnce(new Error('blip')) + .mockRejectedValueOnce(new CombinedError({ networkError: new Error('offline') })) .mockResolvedValueOnce(connection); const promise = pollForConnectionAsync(client, 'acct-1'); @@ -272,6 +300,54 @@ describe('authorizeViaBrowserAsync / loadOrganizationsBestEffortAsync / pollForC 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', () => { @@ -298,16 +374,47 @@ describe('resolvePublishableKeyAsync', () => { await expect(promise).resolves.toBe('pk_live'); }); - it('throws after consecutive readiness errors', async () => { + it('stops on a non-retriable readiness error', async () => { jest .mocked(SupabaseMutation.fetchSupabasePublishableKeyAsync) - .mockRejectedValue(new Error('revoked')); + .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); - promise.catch(() => undefined); 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).rejects.toThrow('revoked'); + await expect(promise).resolves.toBe('pk_live'); }); it('times out when key never becomes ready', async () => { diff --git a/packages/eas-cli/src/integrations/supabase/provision.ts b/packages/eas-cli/src/integrations/supabase/provision.ts index 7e086c7546..4cc710d3d4 100644 --- a/packages/eas-cli/src/integrations/supabase/provision.ts +++ b/packages/eas-cli/src/integrations/supabase/provision.ts @@ -6,6 +6,7 @@ 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'; @@ -28,12 +29,15 @@ import { // 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; -const MAX_CONSECUTIVE_READINESS_ERRORS = 3; +// 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; @@ -180,6 +184,7 @@ export async function pollForConnectionAsync( accountId: string ): Promise { const deadline = Date.now() + CONNECTION_POLL_TIMEOUT_MS; + let consecutiveErrors = 0; for (;;) { let connection: SupabaseConnectionData | null = null; try { @@ -188,8 +193,19 @@ export async function pollForConnectionAsync( accountId, { useCache: false } ); + consecutiveErrors = 0; } catch (error) { - Log.debug(`Polling for the Supabase connection failed, will retry: ${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; @@ -210,9 +226,8 @@ export async function resolvePublishableKeyAsync( ): Promise { const spinner = ora('Waiting for the Supabase project to finish provisioning').start(); const deadline = Date.now() + READINESS_POLL_TIMEOUT_MS; - // The server returns a null key while the project is still provisioning but throws for a real - // problem (revoked authorization, etc.). Tolerate a transient blip, but stop retrying for the - // full timeout once the errors are persistent — that isn't a provisioning delay. + // 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; @@ -221,9 +236,14 @@ export async function resolvePublishableKeyAsync( consecutiveErrors = 0; } catch (error) { consecutiveErrors += 1; - Log.debug(`Polling for the Supabase project readiness failed, will retry: ${error}`); - if (consecutiveErrors >= MAX_CONSECUTIVE_READINESS_ERRORS) { + 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; } }