diff --git a/packages/eas-cli/src/commandUtils/flags.ts b/packages/eas-cli/src/commandUtils/flags.ts index 3a34829b2b..0984149022 100644 --- a/packages/eas-cli/src/commandUtils/flags.ts +++ b/packages/eas-cli/src/commandUtils/flags.ts @@ -26,7 +26,7 @@ export const EasNonInteractiveAndJsonFlags = { }), }; -const EasEnvironmentFlagParameters = { +export const EasEnvironmentFlagParameters = { description: "Environment variable's environment", parse: upperCaseAsync, options: mapToLowercase([ diff --git a/packages/eas-cli/src/commands/env/__tests__/EnvironmentVariableList-test.ts b/packages/eas-cli/src/commands/env/__tests__/EnvironmentVariableList-test.ts new file mode 100644 index 0000000000..1b933cd4e7 --- /dev/null +++ b/packages/eas-cli/src/commands/env/__tests__/EnvironmentVariableList-test.ts @@ -0,0 +1,66 @@ +import { Config } from '@oclif/core'; + +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { testProjectId } from '../../../credentials/__tests__/fixtures-constants'; +import { + EnvironmentVariableEnvironment, + EnvironmentVariableScope, + EnvironmentVariableVisibility, +} from '../../../graphql/generated'; +import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery'; +import EnvironmentVariableList from '../list'; + +jest.mock('../../../graphql/queries/EnvironmentVariablesQuery'); + +describe(EnvironmentVariableList, () => { + const graphqlClient = {} as any as ExpoGraphqlClient; + const mockConfig = {} as unknown as Config; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('lists environment variables successfully', async () => { + const mockVariables = [ + { + id: 'var1', + name: 'TEST_VAR_1', + value: 'value1', + environment: EnvironmentVariableEnvironment.Production, + scope: EnvironmentVariableScope.Project, + visibility: EnvironmentVariableVisibility.Public, + }, + { + id: 'var2', + name: 'TEST_VAR_2', + value: 'value2', + environment: EnvironmentVariableEnvironment.Development, + scope: EnvironmentVariableScope.Project, + visibility: EnvironmentVariableVisibility.Public, + }, + ]; + + EnvironmentVariablesQuery.byAppIdAsync.mockResolvedValueOnce(mockVariables); + + const command = new EnvironmentVariableList([], mockConfig); + await command.runAsync(); + + expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, { + projectId: testProjectId, + }); + expect(command.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_1')); + expect(command.log).toHaveBeenCalledWith(expect.stringContaining('TEST_VAR_2')); + }); + + it('handles errors during listing', async () => { + const errorMessage = 'Failed to list environment variables'; + EnvironmentVariablesQuery.byAppIdAsync.mockRejectedValueOnce(new Error(errorMessage)); + + const command = new EnvironmentVariableList([], mockConfig); + await expect(command.runAsync()).rejects.toThrow(errorMessage); + + expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, { + projectId: testProjectId, + }); + }); +}); diff --git a/packages/eas-cli/src/commands/env/__tests__/EnvironmentVariableUpdate-test.ts b/packages/eas-cli/src/commands/env/__tests__/EnvironmentVariableUpdate-test.ts new file mode 100644 index 0000000000..9ac5937e93 --- /dev/null +++ b/packages/eas-cli/src/commands/env/__tests__/EnvironmentVariableUpdate-test.ts @@ -0,0 +1,164 @@ +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, toggleConfirmAsync } from '../../../prompts'; +import EnvironmentVariableUpdate from '../update'; + +jest.mock('../../../graphql/queries/EnvironmentVariablesQuery'); +jest.mock('../../../graphql/mutations/EnvironmentVariableMutation'); +jest.mock('../../../prompts'); +jest.mock('../../../graphql/queries/AppQuery'); +jest.mock('../../../log'); + +describe(EnvironmentVariableUpdate, () => { + const projectId = 'test-project-id'; + const variableId = '1'; + const graphqlClient = {}; + const mockConfig = {} as unknown as Config; + const mockContext = { + privateProjectConfig: { projectId }, + loggedIn: { graphqlClient }, + }; + + beforeEach(() => { + jest.resetAllMocks(); + jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment()); + }); + + it('updates a project variable by current name in non-interactive mode', async () => { + const mockVariables = [ + { + id: variableId, + name: 'TEST_VARIABLE', + scope: EnvironmentVariableScope.Project, + environments: [EnvironmentVariableEnvironment.Development], + }, + ]; + (EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables); + (EnvironmentVariableMutation.updateAsync as jest.Mock).mockResolvedValue(mockVariables[0]); + + const command = new EnvironmentVariableUpdate( + [ + '--current-name', + 'TEST_VARIABLE', + '--current-environment', + 'development', + '--non-interactive', + '--value', + 'new-value', + '--name', + 'NEW_VARIABLE', + '--environment', + 'production', + ], + mockConfig + ); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext); + await command.runAsync(); + + expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, { + id: variableId, + name: 'NEW_VARIABLE', + value: 'new-value', + environments: [EnvironmentVariableEnvironment.Production], + }); + expect(Log.withTick).toHaveBeenCalledWith( + `Updated variable ${chalk.bold('TEST_VARIABLE')} on project @testuser/testpp.` + ); + }); + + it('updates a shared variable by current name 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.updateAsync as jest.Mock).mockResolvedValue(mockVariables[0]); + + const command = new EnvironmentVariableUpdate( + [ + '--current-name', + 'TEST_VARIABLE', + '--current-environment', + 'development', + '--non-interactive', + '--value', + 'new-value', + '--name', + 'NEW_VARIABLE', + '--environment', + 'production', + '--scope', + 'shared', + ], + mockConfig + ); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext); + await command.runAsync(); + + expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, { + id: variableId, + name: 'NEW_VARIABLE', + value: 'new-value', + environments: [EnvironmentVariableEnvironment.Production], + }); + expect(Log.withTick).toHaveBeenCalledWith( + `Updated variable ${chalk.bold('TEST_VARIABLE')} on account testuser.` + ); + }); + + it('prompts for variable selection when current name is not provided', async () => { + const mockVariables = [ + { id: variableId, name: 'TEST_VARIABLE', scope: EnvironmentVariableScope.Project }, + ]; + (EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables); + (EnvironmentVariableMutation.updateAsync as jest.Mock).mockResolvedValue(mockVariables[0]); + (promptAsync as jest.Mock).mockResolvedValue({ variable: mockVariables[0] }); + (toggleConfirmAsync as jest.Mock).mockResolvedValue(true); + + const command = new EnvironmentVariableUpdate([], mockConfig); + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext); + await command.runAsync(); + + expect(promptAsync).toHaveBeenCalled(); + expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith( + graphqlClient, + expect.objectContaining({ id: variableId }) + ); + expect(Log.withTick).toHaveBeenCalledWith( + `Updated variable ${chalk.bold('TEST_VARIABLE')} on project @testuser/testpp.` + ); + }); + + it('throws an error when variable name is not found', async () => { + const mockVariables: never[] = []; + (EnvironmentVariablesQuery.byAppIdAsync as jest.Mock).mockResolvedValue(mockVariables); + + const command = new EnvironmentVariableUpdate( + ['--current-name', 'NON_EXISTENT_VARIABLE'], + mockConfig + ); + + // @ts-expect-error + jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext); + await expect(command.runAsync()).rejects.toThrow( + 'Variable with name NON_EXISTENT_VARIABLE does not exist on project @testuser/testpp.' + ); + }); +}); diff --git a/packages/eas-cli/src/commands/env/__tests__/__snapshots__/EnvironmentVariableUpdate-test.ts.snap b/packages/eas-cli/src/commands/env/__tests__/__snapshots__/EnvironmentVariableUpdate-test.ts.snap new file mode 100644 index 0000000000..b497089a25 --- /dev/null +++ b/packages/eas-cli/src/commands/env/__tests__/__snapshots__/EnvironmentVariableUpdate-test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EnvironmentVariableUpdate cancels deletion when user does not confirm 1`] = `"Variable \`id\` is required in non-interactive mode. Run the command with --id flag."`; diff --git a/packages/eas-cli/src/commands/env/update.ts b/packages/eas-cli/src/commands/env/update.ts index 74f46f262a..9b516e91a8 100644 --- a/packages/eas-cli/src/commands/env/update.ts +++ b/packages/eas-cli/src/commands/env/update.ts @@ -1,15 +1,18 @@ import { Flags } from '@oclif/core'; +import assert from 'assert'; import chalk from 'chalk'; import EasCommand from '../../commandUtils/EasCommand'; import { - EASEnvironmentFlag, + EASMultiEnvironmentFlag, EASNonInteractiveFlag, EASVariableScopeFlag, EASVariableVisibilityFlag, + EasEnvironmentFlagParameters, } from '../../commandUtils/flags'; import { EnvironmentVariableEnvironment, + EnvironmentVariableFragment, EnvironmentVariableScope, EnvironmentVariableVisibility, } from '../../graphql/generated'; @@ -21,14 +24,22 @@ import { getOwnerAccountForProjectIdAsync, } from '../../project/projectUtils'; import { selectAsync } from '../../prompts'; -import { promptVariableEnvironmentAsync, promptVariableValueAsync } from '../../utils/prompts'; +import { + promptVariableEnvironmentAsync, + promptVariableNameAsync, + promptVariableValueAsync, + promptVariableVisibilityAsync, +} from '../../utils/prompts'; +import { formatVariableName } from '../../utils/variableUtils'; type UpdateFlags = { name?: string; value?: string; scope?: EnvironmentVariableScope; - environment?: EnvironmentVariableEnvironment; + environment?: EnvironmentVariableEnvironment[]; visibility?: EnvironmentVariableVisibility; + 'current-name'?: string; + 'current-environment'?: EnvironmentVariableEnvironment; 'non-interactive': boolean; }; @@ -39,15 +50,22 @@ export default class EnvironmentVariableUpdate extends EasCommand { static override hidden = true; static override flags = { + 'current-name': Flags.string({ + description: 'Current name of the variable', + }), + 'current-environment': Flags.enum({ + ...EasEnvironmentFlagParameters, + description: 'Current environment of the variable', + }), name: Flags.string({ - description: 'Name of the variable', + description: 'New name of the variable', }), value: Flags.string({ - description: 'Text value or the variable', + description: 'New value or the variable', }), ...EASVariableVisibilityFlag, ...EASVariableScopeFlag, - ...EASEnvironmentFlag, + ...EASMultiEnvironmentFlag, ...EASNonInteractiveFlag, }; @@ -63,8 +81,10 @@ export default class EnvironmentVariableUpdate extends EasCommand { name, value, scope, + 'current-name': currentName, + 'current-environment': currentEnvironment, 'non-interactive': nonInteractive, - environment, + environment: environments, visibility, } = this.validateFlags(flags); @@ -80,124 +100,105 @@ export default class EnvironmentVariableUpdate extends EasCommand { getOwnerAccountForProjectIdAsync(graphqlClient, projectId), ]); - if (!environment) { - environment = await promptVariableEnvironmentAsync({ nonInteractive }); - } - - const environments = environment ? [environment] : undefined; + let selectedVariable: EnvironmentVariableFragment; + let existingVariables: EnvironmentVariableFragment[] = []; + const suffix = + scope === EnvironmentVariableScope.Project + ? `on project ${projectDisplayName}` + : `on account ${ownerAccount.name}`; if (scope === EnvironmentVariableScope.Project) { - const existingVariables = await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, { + existingVariables = await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, { appId: projectId, - environment, + filterNames: currentName ? [currentName] : undefined, }); - if (!name) { - name = await selectAsync( - 'Select variable', - existingVariables.map(variable => ({ - title: variable.name, - value: variable.name, - })) - ); - } - - const existingVariable = existingVariables.find(variable => variable.name === name); - if (!existingVariable) { - throw new Error( - `Variable with name ${name} does not exist on project ${projectDisplayName}` - ); - } - - if (!value) { - value = await promptVariableValueAsync({ - nonInteractive, - required: false, - initial: existingVariable.value, - }); - if (!value || value.length === 0) { - value = ''; - } - } + } - const variable = await EnvironmentVariableMutation.updateAsync(graphqlClient, { - id: existingVariable.id, - name, - value, - environments, - visibility, + if (scope === EnvironmentVariableScope.Shared) { + existingVariables = await EnvironmentVariablesQuery.sharedAsync(graphqlClient, { + appId: projectId, + filterNames: currentName ? [currentName] : undefined, }); - if (!variable) { - throw new Error( - `Could not update variable with name ${name} on project ${projectDisplayName}` - ); - } + } - Log.withTick( - `Updated variable ${chalk.bold(name)} on project ${chalk.bold(projectDisplayName)}.` + if (currentEnvironment) { + existingVariables = existingVariables.filter( + variable => variable.environments?.some(env => env === currentEnvironment) ); - } else if (scope === EnvironmentVariableScope.Shared) { - const sharedVariables = await EnvironmentVariablesQuery.sharedAsync(graphqlClient, { - appId: projectId, - }); + } - if (!name) { - name = await selectAsync( - 'Select variable', - sharedVariables.map(variable => ({ - title: variable.name, - value: variable.name, - })) - ); - } + if (existingVariables.length === 0) { + throw new Error( + `Variable with name ${currentName} ${ + currentEnvironment ? `in environment ${currentEnvironment}` : '' + } does not exist ${suffix}.` + ); + } else if (existingVariables.length > 1) { + selectedVariable = await selectAsync( + 'Select variable', + existingVariables.map(variable => ({ + title: formatVariableName(variable), + value: variable, + })) + ); + } else { + selectedVariable = existingVariables[0]; + } - const existingVariable = sharedVariables.find(variable => variable.name === name); - if (!existingVariable) { - throw new Error( - "Variable with this name doesn't exist on this account. Use a different name." - ); + assert(selectedVariable, 'Variable must be selected'); + if (!nonInteractive) { + if (!name) { + name = await promptVariableNameAsync(nonInteractive, selectedVariable.name); + if (!name || name.length === 0) { + name = undefined; + } } if (!value) { value = await promptVariableValueAsync({ nonInteractive, required: false, - initial: existingVariable.value, + initial: selectedVariable.value, }); if (!value || value.length === 0) { - value = ''; + value = undefined; } } - const variable = await EnvironmentVariableMutation.updateAsync(graphqlClient, { - id: existingVariable.id, - name, - value, - visibility, - environments, - }); + if (!environments || environments.length === 0) { + environments = await promptVariableEnvironmentAsync({ + nonInteractive, + multiple: true, + selectedEnvironments: selectedVariable.environments ?? [], + }); + } - if (!variable) { - throw new Error( - `Could not update variable with name ${name} on account ${ownerAccount.name}` + if (!visibility) { + visibility = await promptVariableVisibilityAsync( + nonInteractive, + selectedVariable.visibility ); } + } - Log.withTick( - `Updated shared variable ${chalk.bold(name)} on account ${chalk.bold(ownerAccount.name)}.` - ); + const variable = await EnvironmentVariableMutation.updateAsync(graphqlClient, { + id: selectedVariable.id, + name, + value, + environments, + visibility, + }); + if (!variable) { + throw new Error(`Could not update variable with name ${name} ${suffix}`); } + + Log.withTick(`Updated variable ${chalk.bold(selectedVariable.name)} ${suffix}.`); } private validateFlags(flags: UpdateFlags): UpdateFlags { if (flags['non-interactive']) { - if (!flags.name) { - throw new Error( - 'Variable name is required in non-interactive mode. Run the command with --name flag.' - ); - } - - if (flags.scope === EnvironmentVariableScope.Project && !flags.environment) { + if (!flags['current-name']) { throw new Error( - 'Environment is required when updating project-wide variable in non-interactive mode. Run the command with --environment flag.' + 'Current name is required in non-interactive mode. Run the command with --current-name flag.' ); } } diff --git a/packages/eas-cli/src/utils/prompts.ts b/packages/eas-cli/src/utils/prompts.ts index 30f9bfdff4..51915ac980 100644 --- a/packages/eas-cli/src/utils/prompts.ts +++ b/packages/eas-cli/src/utils/prompts.ts @@ -1,9 +1,31 @@ import chalk from 'chalk'; import capitalize from './expodash/capitalize'; -import { EnvironmentVariableEnvironment } from '../graphql/generated'; +import { + EnvironmentVariableEnvironment, + EnvironmentVariableVisibility, +} from '../graphql/generated'; import { promptAsync, selectAsync } from '../prompts'; +export async function promptVariableVisibilityAsync( + nonInteractive: boolean, + selectedVisibility?: EnvironmentVariableVisibility | null +): Promise { + if (nonInteractive) { + throw new Error( + 'The `--visibility` flag must be set when running in `--non-interactive` mode.' + ); + } + return await selectAsync( + 'Select visibility:', + Object.values(EnvironmentVariableVisibility).map(visibility => ({ + title: capitalize(visibility), + value: visibility, + selected: visibility === selectedVisibility, + })) + ); +} + type EnvironmentPromptArgs = { nonInteractive: boolean; selectedEnvironments?: EnvironmentVariableEnvironment[]; @@ -95,7 +117,10 @@ export async function promptVariableValueAsync({ return variableValue; } -export async function promptVariableNameAsync(nonInteractive: boolean): Promise { +export async function promptVariableNameAsync( + nonInteractive: boolean, + initialValue?: string +): Promise { const validationMessage = 'Variable name may not be empty.'; if (nonInteractive) { throw new Error(validationMessage); @@ -105,6 +130,7 @@ export async function promptVariableNameAsync(nonInteractive: boolean): Promise< type: 'text', name: 'name', message: `Variable name:`, + initial: initialValue, validate: value => { if (!value) { return validationMessage;