diff --git a/packages/eas-cli/src/commands/env/__tests__/EnvironmentVariableUnlink.test.ts b/packages/eas-cli/src/commands/env/__tests__/EnvironmentVariableUnlink.test.ts new file mode 100644 index 0000000000..0f9509821f --- /dev/null +++ b/packages/eas-cli/src/commands/env/__tests__/EnvironmentVariableUnlink.test.ts @@ -0,0 +1,232 @@ +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 EnvironmentVariableUnlink from '../unlink'; + +jest.mock('../../../graphql/queries/EnvironmentVariablesQuery'); +jest.mock('../../../graphql/mutations/EnvironmentVariableMutation'); +jest.mock('../../../prompts'); +jest.mock('../../../graphql/queries/AppQuery'); +jest.mock('../../../log'); + +describe(EnvironmentVariableUnlink, () => { + 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 => + `Unlinked variable ${chalk.bold('TEST_VARIABLE')} from project ${chalk.bold( + '@testuser/testpp' + )} in ${env.toLocaleLowerCase()}.`; + + beforeEach(() => { + jest.resetAllMocks(); + jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment()); + }); + + it('unlinks a shared variable from the current project in non-interactive mode', async () => { + const mockVariables = [ + { + id: variableId, + name: 'TEST_VARIABLE', + scope: EnvironmentVariableScope.Shared, + environments: [EnvironmentVariableEnvironment.Development], + linkedEnvironments: [EnvironmentVariableEnvironment.Development], + }, + ]; + (EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables); + ( + EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync as jest.Mock + ).mockResolvedValue(mockVariables[0]); + + const command = new EnvironmentVariableUnlink( + ['--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.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith( + graphqlClient, + variableId, + projectId + ); + expect(Log.withTick).toHaveBeenCalledWith( + successMessage(EnvironmentVariableEnvironment.Development) + ); + }); + + it('unlinks a shared variable from the current project in a specified environment', async () => { + const mockVariables = [ + { + id: variableId, + name: 'TEST_VARIABLE', + scope: EnvironmentVariableScope.Shared, + environments: [EnvironmentVariableEnvironment.Development], + linkedEnvironments: [EnvironmentVariableEnvironment.Production], + }, + ]; + (EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables); + ( + EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync as jest.Mock + ).mockResolvedValue(mockVariables[0]); + + const command = new EnvironmentVariableUnlink( + ['--variable-name', 'TEST_VARIABLE', '--environment', 'production', '--non-interactive'], + mockConfig + ); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext); + await command.run(); + + expect(Log.withTick).toHaveBeenCalledWith( + successMessage(EnvironmentVariableEnvironment.Production) + ); + expect(EnvironmentVariablesQuery.sharedAsync).toHaveBeenCalledWith(graphqlClient, { + appId: projectId, + filterNames: ['TEST_VARIABLE'], + }); + expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).toHaveBeenCalledWith( + graphqlClient, + variableId, + projectId, + 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], + linkedEnvironments: [EnvironmentVariableEnvironment.Preview], + }, + { + id: 'other-id', + name: 'TEST_VARIABLE', + scope: EnvironmentVariableScope.Shared, + environments: [EnvironmentVariableEnvironment.Development], + linkedEnvironments: [EnvironmentVariableEnvironment.Development], + }, + ]; + (EnvironmentVariablesQuery.sharedAsync as jest.Mock).mockResolvedValue(mockVariables); + ( + EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync as jest.Mock + ).mockResolvedValue(mockVariables[0]); + (selectAsync as jest.Mock).mockResolvedValue(mockVariables[0]); + (promptAsync as jest.Mock).mockResolvedValue({ + environments: [], + }); + (toggleConfirmAsync as jest.Mock).mockResolvedValue(true); + + const command = new EnvironmentVariableUnlink([], mockConfig); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext); + await command.runAsync(); + + expect(selectAsync).toHaveBeenCalled(); + expect(EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync).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 EnvironmentVariableUnlink( + ['--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 EnvironmentVariableUnlink([], 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 EnvironmentVariableUnlink( + ['--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/unlink.ts b/packages/eas-cli/src/commands/env/unlink.ts index 8176ab69ba..46b990ed8b 100644 --- a/packages/eas-cli/src/commands/env/unlink.ts +++ b/packages/eas-cli/src/commands/env/unlink.ts @@ -2,14 +2,15 @@ import { Flags } from '@oclif/core'; import chalk from 'chalk'; import EasCommand from '../../commandUtils/EasCommand'; -import { EASEnvironmentFlag, EASNonInteractiveFlag } from '../../commandUtils/flags'; -import { EnvironmentVariableScope } from '../../graphql/generated'; +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 EnvironmentVariableUnlink extends EasCommand { static override description = 'unlink a shared environment variable to the current project'; @@ -17,10 +18,10 @@ export default class EnvironmentVariableUnlink extends EasCommand { static override hidden = true; static override flags = { - name: Flags.string({ + 'variable-name': Flags.string({ description: 'Name of the variable', }), - ...EASEnvironmentFlag, + ...EASMultiEnvironmentFlag, ...EASNonInteractiveFlag, }; @@ -31,7 +32,11 @@ export default class EnvironmentVariableUnlink extends EasCommand { async runAsync(): Promise { let { - flags: { name, 'non-interactive': nonInteractive, environment }, + flags: { + 'variable-name': name, + 'non-interactive': nonInteractive, + environment: unlinkEnvironments, + }, } = await this.parse(EnvironmentVariableUnlink); const { privateProjectConfig: { projectId }, @@ -40,57 +45,102 @@ export default class EnvironmentVariableUnlink extends EasCommand { nonInteractive, }); - if (!environment) { - environment = await promptVariableEnvironmentAsync({ nonInteractive }); - } - const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId); - - const appVariables = await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, { + const variables = await EnvironmentVariablesQuery.sharedAsync(graphqlClient, { appId: projectId, - environment, + filterNames: name ? [name] : undefined, }); - const linkedVariables = appVariables.filter( - ({ scope }) => scope === EnvironmentVariableScope.Shared - ); - - if (linkedVariables.length === 0) { - throw new Error(`There are no linked shared env variables for project ${projectDisplayName}`); - } - if (!name) { - name = await selectAsync( - 'Select shared variable to unlink', - linkedVariables.map(variable => ({ - title: variable.name, - value: variable.name, + let selectedVariable = variables[0]; + + if (variables.length > 1) { + if (nonInteractive) { + throw new Error("Multiple variables found, please select one using '--variable-name'"); + } + selectedVariable = await selectAsync( + 'Select shared variable', + variables.map(variable => ({ + title: formatVariableName(variable), + value: variable, })) ); } - const selectedVariable = linkedVariables.find(variable => variable.name === name); - if (!selectedVariable) { throw new Error(`Shared variable ${name} doesn't exist`); } - const unlinkedVariable = await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync( - graphqlClient, - selectedVariable.id, - projectId, - environment - ); + let explicitSelect = false; + + if (!nonInteractive && !unlinkEnvironments) { + const selectedEnvironments = + (selectedVariable.linkedEnvironments ?? []).length > 0 + ? selectedVariable.linkedEnvironments + : selectedVariable.environments; + const environments = await promptVariableEnvironmentAsync({ + nonInteractive, + multiple: true, + selectedEnvironments: selectedEnvironments ?? [], + }); + explicitSelect = true; + unlinkEnvironments = Object.values(EnvironmentVariableEnvironment).filter( + env => !environments.includes(env) + ); + } - if (!unlinkedVariable) { - throw new Error( - `Could not unlink variable with name ${selectedVariable.name} from project ${projectDisplayName}` + if (!unlinkEnvironments) { + await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync( + graphqlClient, + selectedVariable.id, + projectId ); + Log.withTick( + `Unlinked variable ${chalk.bold(selectedVariable.name)} from project ${chalk.bold( + projectDisplayName + )} in ${selectedVariable.environments?.join(', ').toLocaleLowerCase()}.` + ); + return; } - Log.withTick( - `Unlinked variable ${chalk.bold(unlinkedVariable.name)} from the project ${chalk.bold( - projectDisplayName - )}.` - ); + for (const environment of Object.values(EnvironmentVariableEnvironment)) { + if ( + selectedVariable.linkedEnvironments?.includes(environment) !== + unlinkEnvironments.includes(environment) + ) { + if (!explicitSelect && unlinkEnvironments.includes(environment)) { + Log.withTick( + `Variable ${chalk.bold( + selectedVariable.name + )} is already unlinked from ${environment.toLocaleLowerCase()}.` + ); + } + continue; + } + if (unlinkEnvironments.includes(environment)) { + await EnvironmentVariableMutation.unlinkSharedEnvironmentVariableAsync( + graphqlClient, + selectedVariable.id, + projectId, + environment + ); + Log.withTick( + `Unlinked variable ${chalk.bold(selectedVariable.name)} from project ${chalk.bold( + projectDisplayName + )} in ${environment.toLocaleLowerCase()}.` + ); + } else if (explicitSelect) { + await EnvironmentVariableMutation.linkSharedEnvironmentVariableAsync( + graphqlClient, + selectedVariable.id, + projectId, + environment + ); + Log.withTick( + `Linked variable ${chalk.bold(selectedVariable.name)} to project ${chalk.bold( + projectDisplayName + )} in ${environment.toLocaleLowerCase()}.` + ); + } + } } }