diff --git a/packages/eas-cli/src/commands/env/__tests__/EnvironmentVariableLink.test.ts b/packages/eas-cli/src/commands/env/__tests__/EnvironmentVariableLink.test.ts new file mode 100644 index 0000000000..7b0fd35ad0 --- /dev/null +++ b/packages/eas-cli/src/commands/env/__tests__/EnvironmentVariableLink.test.ts @@ -0,0 +1,220 @@ +import { Config } from '@oclif/core'; +import chalk from 'chalk'; + +import { getMockAppFragment } from '../../../__tests__/commands/utils'; +import { + EnvironmentVariableEnvironment, + EnvironmentVariableScope, +} from '../../../graphql/generated'; +import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation'; +import { AppQuery } from '../../../graphql/queries/AppQuery'; +import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery'; +import Log from '../../../log'; +import { promptAsync, selectAsync, toggleConfirmAsync } from '../../../prompts'; +import EnvironmentVariableLink from '../link'; + +jest.mock('../../../graphql/queries/EnvironmentVariablesQuery'); +jest.mock('../../../graphql/mutations/EnvironmentVariableMutation'); +jest.mock('../../../prompts'); +jest.mock('../../../graphql/queries/AppQuery'); +jest.mock('../../../log'); + +describe(EnvironmentVariableLink, () => { + const projectId = 'test-project-id'; + const variableId = '1'; + const graphqlClient = {}; + const mockConfig = {} as unknown as Config; + const mockContext = { + privateProjectConfig: { projectId }, + loggedIn: { graphqlClient }, + }; + + const successMessage = (env: EnvironmentVariableEnvironment) => + `Linked variable ${chalk.bold('TEST_VARIABLE')} to project ${chalk.bold( + '@testuser/testpp' + )} in ${env}.`; + + beforeEach(() => { + jest.resetAllMocks(); + jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment()); + }); + + it('links a shared variable to the current project in non-interactive mode', async () => { + const mockVariables = [ + { + id: variableId, + name: 'TEST_VARIABLE', + scope: EnvironmentVariableScope.Shared, + environments: [EnvironmentVariableEnvironment.Development], + }, + ]; + (EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables); + (EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue( + mockVariables[0] + ); + + const command = new EnvironmentVariableLink( + ['--name', 'TEST_VARIABLE', '--non-interactive'], + mockConfig + ); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext); + await command.run(); + + expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, { + appId: projectId, + filterNames: ['TEST_VARIABLE'], + }); + expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith( + graphqlClient, + variableId, + projectId + ); + expect(Log.withTick).toHaveBeenCalledWith( + successMessage(EnvironmentVariableEnvironment.Development) + ); + }); + + it('links a shared variable to the current project to a specified environment', async () => { + const mockVariables = [ + { + id: variableId, + name: 'TEST_VARIABLE', + scope: EnvironmentVariableScope.Shared, + environments: [EnvironmentVariableEnvironment.Development], + }, + ]; + (EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables); + (EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue( + mockVariables[0] + ); + + const command = new EnvironmentVariableLink( + ['--name', 'TEST_VARIABLE', '--environment', 'production', '--non-interactive'], + mockConfig + ); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext); + await command.run(); + + expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, { + appId: projectId, + filterNames: ['TEST_VARIABLE'], + }); + expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith( + graphqlClient, + variableId, + projectId, + EnvironmentVariableEnvironment.Production + ); + expect(Log.withTick).toHaveBeenCalledWith( + successMessage(EnvironmentVariableEnvironment.Production) + ); + }); + + it('prompts for variable selection when the name is ambigous', async () => { + const mockVariables = [ + { + id: variableId, + name: 'TEST_VARIABLE', + scope: EnvironmentVariableScope.Shared, + environments: [EnvironmentVariableEnvironment.Preview], + }, + { + id: 'other-id', + name: 'TEST_VARIABLE', + scope: EnvironmentVariableScope.Shared, + environments: [EnvironmentVariableEnvironment.Development], + }, + ]; + (EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables); + (EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue( + mockVariables[0] + ); + (selectAsync as jest.Mock).mockResolvedValue(mockVariables[0]); + (promptAsync as jest.Mock).mockResolvedValue({ environments: mockVariables[0].environments }); + (toggleConfirmAsync as jest.Mock).mockResolvedValue(true); + + const command = new EnvironmentVariableLink([], mockConfig); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext); + await command.runAsync(); + + expect(selectAsync).toHaveBeenCalled(); + expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith( + graphqlClient, + variableId, + projectId, + EnvironmentVariableEnvironment.Preview + ); + expect(Log.withTick).toHaveBeenCalledWith( + successMessage(EnvironmentVariableEnvironment.Preview) + ); + }); + + it('throws an error when variable name is not found', async () => { + const mockVariables: never[] = []; + (EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables); + + const command = new EnvironmentVariableLink(['--name', 'NON_EXISTENT_VARIABLE'], mockConfig); + + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext); + await expect(command.runAsync()).rejects.toThrow( + "Shared variable NON_EXISTENT_VARIABLE doesn't exist" + ); + }); + + it('uses environments from prompt to both link and unlink environments', async () => { + const mockVariables = [ + { + id: variableId, + name: 'TEST_VARIABLE', + scope: EnvironmentVariableScope.Shared, + environments: [EnvironmentVariableEnvironment.Preview], + linkedEnvironments: [EnvironmentVariableEnvironment.Preview], + }, + ]; + (EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables); + (EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue( + mockVariables[0] + ); + (selectAsync as jest.Mock).mockResolvedValue(mockVariables[0]); + (promptAsync as jest.Mock).mockResolvedValue({ + environments: [EnvironmentVariableEnvironment.Production], + }); + (toggleConfirmAsync as jest.Mock).mockResolvedValue(true); + + const command = new EnvironmentVariableLink([], mockConfig); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext); + await command.runAsync(); + + expect(promptAsync).toHaveBeenCalled(); + expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith( + graphqlClient, + variableId, + projectId, + EnvironmentVariableEnvironment.Production + ); + expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith( + graphqlClient, + variableId, + projectId, + EnvironmentVariableEnvironment.Preview + ); + }); + + it('throws an error when variable name is not found', async () => { + const mockVariables: never[] = []; + (EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables); + + const command = new EnvironmentVariableLink(['--name', 'NON_EXISTENT_VARIABLE'], mockConfig); + + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext); + await expect(command.runAsync()).rejects.toThrow( + "Shared variable NON_EXISTENT_VARIABLE doesn't exist" + ); + }); +}); diff --git a/packages/eas-cli/src/commands/env/link.ts b/packages/eas-cli/src/commands/env/link.ts index c9445d8657..013c440fe6 100644 --- a/packages/eas-cli/src/commands/env/link.ts +++ b/packages/eas-cli/src/commands/env/link.ts @@ -2,13 +2,15 @@ import { Flags } from '@oclif/core'; import chalk from 'chalk'; import EasCommand from '../../commandUtils/EasCommand'; -import { EASEnvironmentFlag, EASNonInteractiveFlag } from '../../commandUtils/flags'; +import { EASMultiEnvironmentFlag, EASNonInteractiveFlag } from '../../commandUtils/flags'; +import { EnvironmentVariableEnvironment } from '../../graphql/generated'; import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation'; import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery'; import Log from '../../log'; import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils'; import { selectAsync } from '../../prompts'; import { promptVariableEnvironmentAsync } from '../../utils/prompts'; +import { formatVariableName } from '../../utils/variableUtils'; export default class EnvironmentVariableLink extends EasCommand { static override description = 'link a shared environment variable to the current project'; @@ -19,7 +21,7 @@ export default class EnvironmentVariableLink extends EasCommand { name: Flags.string({ description: 'Name of the variable', }), - ...EASEnvironmentFlag, + ...EASMultiEnvironmentFlag, ...EASNonInteractiveFlag, }; @@ -30,7 +32,7 @@ export default class EnvironmentVariableLink extends EasCommand { async runAsync(): Promise { let { - flags: { name, 'non-interactive': nonInteractive, environment }, + flags: { name, 'non-interactive': nonInteractive, environment: environments }, } = await this.parse(EnvironmentVariableLink); const { privateProjectConfig: { projectId }, @@ -42,44 +44,89 @@ export default class EnvironmentVariableLink extends EasCommand { const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId); const variables = await EnvironmentVariablesQuery.sharedAsync(graphqlClient, { appId: projectId, + filterNames: name ? [name] : undefined, }); - if (!name) { - name = await selectAsync( + let selectedVariable = variables[0]; + + if (variables.length > 1) { + selectedVariable = await selectAsync( 'Select shared variable', variables.map(variable => ({ - title: variable.name, - value: variable.name, + title: formatVariableName(variable), + value: variable, })) ); } - const selectedVariable = variables.find(variable => variable.name === name); - if (!selectedVariable) { throw new Error(`Shared variable ${name} doesn't exist`); } - if (!environment) { - environment = await promptVariableEnvironmentAsync({ nonInteractive }); + let explicitSelect = false; + if (!nonInteractive && !environments) { + const selectedEnvironments = + (selectedVariable.linkedEnvironments ?? []).length > 0 + ? selectedVariable.linkedEnvironments + : selectedVariable.environments; + environments = await promptVariableEnvironmentAsync({ + nonInteractive, + multiple: true, + selectedEnvironments: selectedEnvironments ?? [], + }); + explicitSelect = true; } - const linkedVariable = await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync( - graphqlClient, - selectedVariable.id, - projectId, - environment - ); - if (!linkedVariable) { - throw new Error( - `Could not link variable with name ${selectedVariable.name} to project with id ${projectId}` + if (!environments) { + await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync( + graphqlClient, + selectedVariable.id, + projectId ); + Log.withTick( + `Linked variable ${chalk.bold(selectedVariable.name)} to project ${chalk.bold( + projectDisplayName + )} in ${selectedVariable.environments?.join(', ')}.` + ); + return; } - Log.withTick( - `Linked variable ${chalk.bold(linkedVariable.name)} to project ${chalk.bold( - projectDisplayName - )}.` - ); + for (const environment of Object.values(EnvironmentVariableEnvironment)) { + try { + if ( + selectedVariable.linkedEnvironments?.includes(environment) === + environments.includes(environment) + ) { + continue; + } + if (environments.includes(environment)) { + await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync( + graphqlClient, + selectedVariable.id, + projectId, + environment + ); + Log.withTick( + `Linked variable ${chalk.bold(selectedVariable.name)} to project ${chalk.bold( + projectDisplayName + )} in ${environment}.` + ); + } else if (explicitSelect) { + await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync( + graphqlClient, + selectedVariable.id, + projectId, + environment + ); + Log.withTick( + `Unlinked variable ${chalk.bold(selectedVariable.name)} from project ${chalk.bold( + projectDisplayName + )} in ${environment}.` + ); + } + } catch (err: any) { + Log.warn(err.message); + } + } } } diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index c8c1eec582..8169363bb5 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -8653,7 +8653,7 @@ export type EnvironmentVariablesByAppIdQueryVariables = Exact<{ }>; -export type EnvironmentVariablesByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, environmentVariables: Array<{ __typename?: 'EnvironmentVariable', id: string, name: string, value?: string | null, environments?: Array | null, createdAt: any, updatedAt: any, scope: EnvironmentVariableScope, visibility?: EnvironmentVariableVisibility | null }> } } }; +export type EnvironmentVariablesByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, environmentVariables: Array<{ __typename?: 'EnvironmentVariable', id: string, linkedEnvironments?: Array | null, name: string, value?: string | null, environments?: Array | null, createdAt: any, updatedAt: any, scope: EnvironmentVariableScope, visibility?: EnvironmentVariableVisibility | null }> } } }; export type EnvironmentVariablesSharedQueryVariables = Exact<{ appId: Scalars['String']['input']; @@ -8662,7 +8662,7 @@ export type EnvironmentVariablesSharedQueryVariables = Exact<{ }>; -export type EnvironmentVariablesSharedQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, ownerAccount: { __typename?: 'Account', id: string, environmentVariables: Array<{ __typename?: 'EnvironmentVariable', id: string, name: string, value?: string | null, environments?: Array | null, createdAt: any, updatedAt: any, scope: EnvironmentVariableScope, visibility?: EnvironmentVariableVisibility | null }> } } } }; +export type EnvironmentVariablesSharedQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, ownerAccount: { __typename?: 'Account', id: string, environmentVariables: Array<{ __typename?: 'EnvironmentVariable', id: string, linkedEnvironments?: Array | null, name: string, value?: string | null, environments?: Array | null, createdAt: any, updatedAt: any, scope: EnvironmentVariableScope, visibility?: EnvironmentVariableVisibility | null }> } } } }; export type EnvironmentVariablesSharedWithSensitiveQueryVariables = Exact<{ appId: Scalars['String']['input']; diff --git a/packages/eas-cli/src/graphql/queries/EnvironmentVariablesQuery.ts b/packages/eas-cli/src/graphql/queries/EnvironmentVariablesQuery.ts index 5a273c1547..ea2e3b9523 100644 --- a/packages/eas-cli/src/graphql/queries/EnvironmentVariablesQuery.ts +++ b/packages/eas-cli/src/graphql/queries/EnvironmentVariablesQuery.ts @@ -12,6 +12,10 @@ import { } from '../generated'; import { EnvironmentVariableFragmentNode } from '../types/EnvironmentVariable'; +type EnvironmentVariableWithLinkedEnvironments = EnvironmentVariableFragment & { + linkedEnvironments?: EnvironmentVariableEnvironment[] | null; +}; + export const EnvironmentVariablesQuery = { async byAppIdWithSensitiveAsync( graphqlClient: ExpoGraphqlClient, @@ -68,7 +72,7 @@ export const EnvironmentVariablesQuery = { environment?: EnvironmentVariableEnvironment; filterNames?: string[]; } - ): Promise { + ): Promise { const data = await withErrorHandlingAsync( graphqlClient .query( @@ -83,6 +87,7 @@ export const EnvironmentVariablesQuery = { id environmentVariables(filterNames: $filterNames, environment: $environment) { id + linkedEnvironments(appId: $appId) ...EnvironmentVariableFragment } } @@ -105,7 +110,7 @@ export const EnvironmentVariablesQuery = { filterNames, environment, }: { appId: string; filterNames?: string[]; environment?: EnvironmentVariableEnvironment } - ): Promise { + ): Promise { const data = await withErrorHandlingAsync( graphqlClient .query( @@ -122,6 +127,7 @@ export const EnvironmentVariablesQuery = { id environmentVariables(filterNames: $filterNames, environment: $environment) { id + linkedEnvironments(appId: $appId) ...EnvironmentVariableFragment } }