Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages.

### 🎉 New features

- Add diff to `eas env:pull` command. ([#2984](https://github.com/expo/eas-cli/pull/2984) by [@khamilowicz](https://github.com/khamilowicz))

### 🐛 Bug fixes

### 🧹 Chores
Expand Down
243 changes: 243 additions & 0 deletions packages/eas-cli/src/commands/env/__tests__/EnvPull.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import { Config } from '@oclif/core';
import chalk from 'chalk';
import fs from 'fs-extra';
import { vol } from 'memfs';

import { EnvironmentSecretType, EnvironmentVariableVisibility } from '../../../graphql/generated';
import {
EnvironmentVariableWithFileContent,
EnvironmentVariablesQuery,
} from '../../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../../log';
import { confirmAsync } from '../../../prompts';
import EnvPull from '../pull';

jest.mock('fs');
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
// jest.mock('../../../log');
jest.mock('../../../prompts');
jest.mock('../../../utils/prompts');

beforeEach(async () => {
vol.reset();
});

describe(EnvPull, () => {
const mockConfig = {} as unknown as Config;
const graphqlClient = {};
const projectId = 'test-project-id';
const mockContext = {
projectId,
loggedIn: { graphqlClient },
projectDir: '/mock/project/dir',
};

beforeEach(() => {
jest.resetAllMocks();
vol.reset();
});

it('pulls environment variables and writes to .env file', async () => {
const mockVariables = [
{
name: 'TEST_VAR',
value: 'value',
type: EnvironmentSecretType.String,
visibility: EnvironmentVariableVisibility.Public,
},
{
name: 'FILE_VAR',
valueWithFileContent: 'value',
type: EnvironmentSecretType.FileBase64,
visibility: EnvironmentVariableVisibility.Public,
},
{
name: 'SECRET_VAR',
value: null,
type: EnvironmentSecretType.String,
visibility: EnvironmentVariableVisibility.Secret,
},
{
name: 'SENSITIVE_VAR',
value: 'value',
type: EnvironmentSecretType.String,
visibility: EnvironmentVariableVisibility.Sensitive,
},
];
jest
.mocked(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync)
.mockResolvedValue(mockVariables as EnvironmentVariableWithFileContent[]);

// @ts-expect-error
jest.spyOn(fs, 'writeFile').mockResolvedValue(undefined);
// @ts-expect-error
jest.spyOn(Log, 'log').mockResolvedValue(undefined);

const command = new EnvPull(['--environment', 'production'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);

await command.runAsync();

expect(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync).toHaveBeenCalledWith(
graphqlClient,
{
appId: projectId,
environment: 'PRODUCTION',
includeFileContent: true,
}
);

const expectedFileContent = [
'# Environment: production',
'',
'TEST_VAR=value',
'FILE_VAR=/mock/project/dir/.eas/.env/FILE_VAR',
'# SECRET_VAR=***** (secret)',
'SENSITIVE_VAR=value',
];

expect(fs.writeFile).toHaveBeenNthCalledWith(
1,
'/mock/project/dir/.eas/.env/FILE_VAR',
'value',
'base64'
);

expect(fs.writeFile).toHaveBeenNthCalledWith(2, '.env.local', expectedFileContent.join('\n'));

expect(Log.log).toHaveBeenCalledWith(
`Pulled plain text and sensitive environment variables from "production" environment to .env.local.`
);
});

it('throws an error if the environment is invalid', async () => {
const command = new EnvPull(['--environment', 'invalid'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);

await expect(command.runAsync()).rejects.toThrow(
/Expected --environment=invalid to be one of: development, preview, production/
);
});

it('overwrites existing .env file if confirmed', async () => {
jest
.mocked(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync)
.mockResolvedValue([] as EnvironmentVariableWithFileContent[]);

vol.fromJSON({
'./.env.local': '',
});

// @ts-expect-error
jest.spyOn(fs, 'writeFile').mockResolvedValue(undefined);
jest.mocked(confirmAsync).mockResolvedValue(true);

const command = new EnvPull(['--environment', 'production'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);

await command.runAsync();

expect(confirmAsync).toHaveBeenCalledWith({
message: 'File .env.local already exists. Do you want to overwrite it?',
});
expect(fs.writeFile).toHaveBeenCalled();
});

it('aborts if user declines to overwrite existing .env file', async () => {
vol.fromJSON({
'./.env.local': 'existing content',
});
jest.mocked(confirmAsync).mockResolvedValue(false);
// @ts-expect-error
jest.spyOn(fs, 'writeFile').mockResolvedValue(undefined);

const command = new EnvPull(['--environment', 'production'], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);

await expect(command.runAsync()).rejects.toThrow('File .env.local already exists.');
expect(fs.writeFile).not.toHaveBeenCalled();
});

it('handles secret variables correctly', async () => {
const mockVariables = [
{
name: 'SECRET_VAR',
value: '*****',
type: EnvironmentSecretType.String,
visibility: EnvironmentVariableVisibility.Secret,
},
];
jest
.mocked(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync)
.mockResolvedValue(mockVariables as EnvironmentVariableWithFileContent[]);

const command = new EnvPull(['--environment', 'production', '--non-interactive'], mockConfig);

// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
// @ts-expect-error
jest.spyOn(fs, 'writeFile').mockResolvedValue(undefined);
jest.spyOn(Log, 'log').mockImplementation(() => {});

await command.runAsync();

expect(fs.writeFile).toHaveBeenCalledWith(
'.env.local',
expect.stringContaining('# SECRET_VAR=***** (secret)')
);
expect(Log.log).toHaveBeenCalledWith(
"The following variables have the secret visibility and can't be read outside of EAS servers. Set their values manually in your .env file: SECRET_VAR."
);
});

it('diffLogAsync generates correct diff log', async () => {
const mockVariables = [
{ name: 'NEW_VAR', value: 'new_value', type: EnvironmentSecretType.String },
{ name: 'UNCHANGED_VAR', value: 'unchanged_value', type: EnvironmentSecretType.String },
{
name: 'UNCHANGED_FILE_VAR',
valueWithFileContent: Buffer.from('unchanged_value').toString('base64'),
type: EnvironmentSecretType.FileBase64,
},
{ name: 'CHANGED_VAR', value: 'changed_value', type: EnvironmentSecretType.String },
{
name: 'CHANGED_FILE_VAR',
valueWithFileContent: Buffer.from('changed_value').toString('base64'),
type: EnvironmentSecretType.FileBase64,
},
];

vol.fromJSON({
'./.eas/.env/UNCHANGED_FILE_VAR': 'unchanged_value',
'./.eas/.env/CHANGED_FILE_VAR': 'changing_value',
});

const currentEnvLocal = {
UNCHANGED_VAR: 'unchanged_value',
CHANGED_VAR: 'changing_value',
UNCHANGED_FILE_VAR: './.eas/.env/UNCHANGED_FILE_VAR',
CHANGED_FILE_VAR: './.eas/.env/CHANGED_FILE_VAR',
REMOVED_VAR: 'removed_value',
};

const command = new EnvPull([], mockConfig);
// @ts-expect-error
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);

// @ts-expect-error
const diffLog = await command.diffLogAsync(mockVariables, currentEnvLocal);

expect(diffLog).toEqual([
chalk.green('+ NEW_VAR'),
' UNCHANGED_VAR',
' UNCHANGED_FILE_VAR',
chalk.yellow('~ CHANGED_VAR'),
chalk.yellow('~ CHANGED_FILE_VAR'),
chalk.red('- REMOVED_VAR'),
]);
});
});
65 changes: 65 additions & 0 deletions packages/eas-cli/src/commands/env/pull.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import dotenv from 'dotenv';
import * as fs from 'fs-extra';
import path from 'path';
Expand Down Expand Up @@ -43,6 +44,31 @@ export default class EnvPull extends EasCommand {
}),
};

async isVariableEqualAsync(
currentEnvValue: string | undefined,
newVariable: EnvironmentVariableWithFileContent
): Promise<boolean> {
if (newVariable.visibility === EnvironmentVariableVisibility.Secret) {
return true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be undefined? Do we know values of secret variables? Maybe we should never compare them (and tell the user we can't)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secret variables are a special case: if they are set in the .env var, we keep the current local value (so they are equal), and if it is a new variable, we inform user that they should set it manually (line 185 and 195).

}

if (
newVariable.type === EnvironmentSecretType.FileBase64 &&
newVariable.valueWithFileContent &&
currentEnvValue
) {
if (!(await fs.pathExists(currentEnvValue))) {
return false;
}

const fileContent = await fs.readFile(currentEnvValue, 'base64');

return fileContent === newVariable.valueWithFileContent;
}

return currentEnvValue === newVariable.value;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can a variable type change? In which case just checking the value would no longer be sufficient?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you change a variable type, then values would also necessarly change, yes? if you change a string variable to a file, then you need to upload a new value.

}

async runAsync(): Promise<void> {
let {
args: { environment: argEnvironment },
Expand Down Expand Up @@ -105,6 +131,10 @@ export default class EnvPull extends EasCommand {
await fs.mkdir(envDir, { recursive: true });
}

const diffLog = await this.diffLogAsync(environmentVariables, currentEnvLocal);

Log.addNewLineIfNone();

const skippedSecretVariables: string[] = [];
const overridenSecretVariables: string[] = [];

Expand Down Expand Up @@ -146,5 +176,40 @@ export default class EnvPull extends EasCommand {
)}.`
);
}

Log.addNewLineIfNone();
diffLog.forEach(line => {
Log.log(line);
});
}

async diffLogAsync(
environmentVariables: EnvironmentVariableWithFileContent[],
currentEnvLocal: Record<string, string>
): Promise<string[]> {
const allVariableNames = new Set([
...environmentVariables.map(v => v.name),
...Object.keys(currentEnvLocal),
]);

const diffLog = [];

for (const variableName of allVariableNames) {
const newVariable = environmentVariables.find(v => v.name === variableName);
if (newVariable) {
if (Object.hasOwn(currentEnvLocal, variableName)) {
if (await this.isVariableEqualAsync(currentEnvLocal[variableName], newVariable)) {
diffLog.push(` ${variableName}`);
} else {
diffLog.push(chalk.yellow(`~ ${variableName}`));
}
} else {
diffLog.push(chalk.green(`+ ${variableName}`));
}
} else if (Object.hasOwn(currentEnvLocal, variableName)) {
diffLog.push(chalk.red(`- ${variableName}`));
}
}
return diffLog;
}
}