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..425b45fe30 --- /dev/null +++ b/packages/eas-cli/src/commands/env/__tests__/EnvironmentVariableLink.test.ts @@ -0,0 +1,277 @@ +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): string => + `Linked variable ${chalk.bold('TEST_VARIABLE')} to project ${chalk.bold( + '@testuser/testpp' + )} in ${env.toLocaleLowerCase()}.`; + + 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( + ['--variable-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( + ['--variable-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('uses --variable-environment to select the variable with ambigous name', 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).mockImplementation( + (_client, { environment }) => { + return mockVariables.filter(v => v.environments.includes(environment)); + } + ); + (EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync as jest.Mock).mockResolvedValue( + mockVariables[0] + ); + (toggleConfirmAsync as jest.Mock).mockResolvedValue(true); + + const command = new EnvironmentVariableLink( + [ + '--variable-name', + 'TEST_VARIABLE', + '--variable-environment', + 'development', + '--environment', + 'production', + ], + mockConfig + ); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext); + await command.runAsync(); + + expect(EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync).toHaveBeenCalledWith( + graphqlClient, + 'other-id', + 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( + ['--variable-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( + ['--variable-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..647cacce82 100644 --- a/packages/eas-cli/src/commands/env/link.ts +++ b/packages/eas-cli/src/commands/env/link.ts @@ -2,13 +2,19 @@ import { Flags } from '@oclif/core'; import chalk from 'chalk'; import EasCommand from '../../commandUtils/EasCommand'; -import { EASEnvironmentFlag, EASNonInteractiveFlag } from '../../commandUtils/flags'; +import { + EASMultiEnvironmentFlag, + EASNonInteractiveFlag, + EasEnvironmentFlagParameters, +} 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'; @@ -16,10 +22,14 @@ export default class EnvironmentVariableLink extends EasCommand { static override hidden = true; static override flags = { - name: Flags.string({ + 'variable-name': Flags.string({ description: 'Name of the variable', }), - ...EASEnvironmentFlag, + 'variable-environment': Flags.enum({ + ...EasEnvironmentFlagParameters, + description: 'Current environment of the variable to link', + }), + ...EASMultiEnvironmentFlag, ...EASNonInteractiveFlag, }; @@ -30,7 +40,12 @@ export default class EnvironmentVariableLink extends EasCommand { async runAsync(): Promise { let { - flags: { name, 'non-interactive': nonInteractive, environment }, + flags: { + 'variable-name': name, + 'variable-environment': currentEnvironment, + 'non-interactive': nonInteractive, + environment: environments, + }, } = await this.parse(EnvironmentVariableLink); const { privateProjectConfig: { projectId }, @@ -42,44 +57,97 @@ export default class EnvironmentVariableLink extends EasCommand { const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId); const variables = await EnvironmentVariablesQuery.sharedAsync(graphqlClient, { appId: projectId, + environment: currentEnvironment, + 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(', ').toLocaleLowerCase()}.` + ); + 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) + ) { + if (!explicitSelect && environments.includes(environment)) { + Log.withTick( + `Variable ${chalk.bold( + selectedVariable.name + )} is already linked to ${environment.toLocaleLowerCase()}.` + ); + } + 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.toLocaleLowerCase()}.` + ); + } 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.toLocaleLowerCase()}.` + ); + } + } 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 470882b677..170b81e961 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -8722,7 +8722,7 @@ export type EnvironmentVariablesIncludingSensitiveByAppIdQueryVariables = Exact< }>; -export type EnvironmentVariablesIncludingSensitiveByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, environmentVariablesIncludingSensitive: Array<{ __typename?: 'EnvironmentVariableWithSecret', id: string, name: string, value?: string | null }> } } }; +export type EnvironmentVariablesIncludingSensitiveByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, environmentVariablesIncludingSensitive: Array<{ __typename?: 'EnvironmentVariableWithSecret', id: string, name: string, value?: string | null, environments?: Array | null }> } } }; export type EnvironmentVariablesByAppIdQueryVariables = Exact<{ appId: Scalars['String']['input']; @@ -8731,7 +8731,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']; @@ -8740,7 +8740,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']; @@ -8749,7 +8749,7 @@ export type EnvironmentVariablesSharedWithSensitiveQueryVariables = Exact<{ }>; -export type EnvironmentVariablesSharedWithSensitiveQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, ownerAccount: { __typename?: 'Account', id: string, environmentVariablesIncludingSensitive: Array<{ __typename?: 'EnvironmentVariableWithSecret', id: string, name: string, value?: string | null }> } } } }; +export type EnvironmentVariablesSharedWithSensitiveQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, ownerAccount: { __typename?: 'Account', id: string, environmentVariablesIncludingSensitive: Array<{ __typename?: 'EnvironmentVariableWithSecret', id: string, name: string, value?: string | null, environments?: Array | null }> } } } }; export type GoogleServiceAccountKeyByIdQueryVariables = Exact<{ ascApiKeyId: Scalars['ID']['input']; diff --git a/packages/eas-cli/src/graphql/queries/EnvironmentVariablesQuery.ts b/packages/eas-cli/src/graphql/queries/EnvironmentVariablesQuery.ts index 99e3151ca7..341e0d24d7 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, @@ -69,7 +73,7 @@ export const EnvironmentVariablesQuery = { environment?: EnvironmentVariableEnvironment; filterNames?: string[]; } - ): Promise { + ): Promise { const data = await withErrorHandlingAsync( graphqlClient .query( @@ -84,6 +88,7 @@ export const EnvironmentVariablesQuery = { id environmentVariables(filterNames: $filterNames, environment: $environment) { id + linkedEnvironments(appId: $appId) ...EnvironmentVariableFragment } } @@ -106,7 +111,7 @@ export const EnvironmentVariablesQuery = { filterNames, environment, }: { appId: string; filterNames?: string[]; environment?: EnvironmentVariableEnvironment } - ): Promise { + ): Promise { const data = await withErrorHandlingAsync( graphqlClient .query( @@ -123,6 +128,7 @@ export const EnvironmentVariablesQuery = { id environmentVariables(filterNames: $filterNames, environment: $environment) { id + linkedEnvironments(appId: $appId) ...EnvironmentVariableFragment } }