Skip to content

Commit 16bec66

Browse files
committed
Add diff log to env:pull command
1 parent 20b4832 commit 16bec66

File tree

3 files changed

+310
-0
lines changed

3 files changed

+310
-0
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages.
88

99
### 🎉 New features
1010

11+
- Add diff to `eas env:pull` command. ([#2984](https://github.com/expo/eas-cli/pull/2984) by [@khamilowicz](https://github.com/khamilowicz))
12+
1113
### 🐛 Bug fixes
1214

1315
### 🧹 Chores
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import { Config } from '@oclif/core';
2+
import chalk from 'chalk';
3+
import fs from 'fs-extra';
4+
import { vol } from 'memfs';
5+
6+
import { EnvironmentSecretType, EnvironmentVariableVisibility } from '../../../graphql/generated';
7+
import {
8+
EnvironmentVariableWithFileContent,
9+
EnvironmentVariablesQuery,
10+
} from '../../../graphql/queries/EnvironmentVariablesQuery';
11+
import Log from '../../../log';
12+
import { confirmAsync } from '../../../prompts';
13+
import EnvPull from '../pull';
14+
15+
jest.mock('fs');
16+
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
17+
// jest.mock('../../../log');
18+
jest.mock('../../../prompts');
19+
jest.mock('../../../utils/prompts');
20+
21+
beforeEach(async () => {
22+
vol.reset();
23+
});
24+
25+
describe(EnvPull, () => {
26+
const mockConfig = {} as unknown as Config;
27+
const graphqlClient = {};
28+
const projectId = 'test-project-id';
29+
const mockContext = {
30+
projectId,
31+
loggedIn: { graphqlClient },
32+
projectDir: '/mock/project/dir',
33+
};
34+
35+
beforeEach(() => {
36+
jest.resetAllMocks();
37+
vol.reset();
38+
});
39+
40+
it('pulls environment variables and writes to .env file', async () => {
41+
const mockVariables = [
42+
{
43+
name: 'TEST_VAR',
44+
value: 'value',
45+
type: EnvironmentSecretType.String,
46+
visibility: EnvironmentVariableVisibility.Public,
47+
},
48+
{
49+
name: 'FILE_VAR',
50+
valueWithFileContent: 'value',
51+
type: EnvironmentSecretType.FileBase64,
52+
visibility: EnvironmentVariableVisibility.Public,
53+
},
54+
{
55+
name: 'SECRET_VAR',
56+
value: null,
57+
type: EnvironmentSecretType.String,
58+
visibility: EnvironmentVariableVisibility.Secret,
59+
},
60+
{
61+
name: 'SENSITIVE_VAR',
62+
value: 'value',
63+
type: EnvironmentSecretType.String,
64+
visibility: EnvironmentVariableVisibility.Sensitive,
65+
},
66+
];
67+
jest
68+
.mocked(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync)
69+
.mockResolvedValue(mockVariables as EnvironmentVariableWithFileContent[]);
70+
71+
// @ts-expect-error
72+
jest.spyOn(fs, 'writeFile').mockResolvedValue(undefined);
73+
// @ts-expect-error
74+
jest.spyOn(Log, 'log').mockResolvedValue(undefined);
75+
76+
const command = new EnvPull(['--environment', 'production'], mockConfig);
77+
// @ts-expect-error
78+
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
79+
80+
await command.runAsync();
81+
82+
expect(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync).toHaveBeenCalledWith(
83+
graphqlClient,
84+
{
85+
appId: projectId,
86+
environment: 'PRODUCTION',
87+
includeFileContent: true,
88+
}
89+
);
90+
91+
const expectedFileContent = [
92+
'# Environment: production',
93+
'',
94+
'TEST_VAR=value',
95+
'FILE_VAR=/mock/project/dir/.eas/.env/FILE_VAR',
96+
'# SECRET_VAR=***** (secret)',
97+
'SENSITIVE_VAR=value',
98+
];
99+
100+
expect(fs.writeFile).toHaveBeenNthCalledWith(
101+
1,
102+
'/mock/project/dir/.eas/.env/FILE_VAR',
103+
'value',
104+
'base64'
105+
);
106+
107+
expect(fs.writeFile).toHaveBeenNthCalledWith(2, '.env.local', expectedFileContent.join('\n'));
108+
109+
expect(Log.log).toHaveBeenCalledWith(
110+
`Pulled plain text and sensitive environment variables from "production" environment to .env.local.`
111+
);
112+
});
113+
114+
it('throws an error if the environment is invalid', async () => {
115+
const command = new EnvPull(['--environment', 'invalid'], mockConfig);
116+
// @ts-expect-error
117+
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
118+
119+
await expect(command.runAsync()).rejects.toThrow(
120+
/Expected --environment=invalid to be one of: development, preview, production/
121+
);
122+
});
123+
124+
it('overwrites existing .env file if confirmed', async () => {
125+
jest
126+
.mocked(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync)
127+
.mockResolvedValue([] as EnvironmentVariableWithFileContent[]);
128+
129+
vol.fromJSON({
130+
'./.env.local': '',
131+
});
132+
133+
// @ts-expect-error
134+
jest.spyOn(fs, 'writeFile').mockResolvedValue(undefined);
135+
jest.mocked(confirmAsync).mockResolvedValue(true);
136+
137+
const command = new EnvPull(['--environment', 'production'], mockConfig);
138+
// @ts-expect-error
139+
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
140+
141+
await command.runAsync();
142+
143+
expect(confirmAsync).toHaveBeenCalledWith({
144+
message: 'File .env.local already exists. Do you want to overwrite it?',
145+
});
146+
expect(fs.writeFile).toHaveBeenCalled();
147+
});
148+
149+
it('aborts if user declines to overwrite existing .env file', async () => {
150+
vol.fromJSON({
151+
'./.env.local': 'existing content',
152+
});
153+
jest.mocked(confirmAsync).mockResolvedValue(false);
154+
// @ts-expect-error
155+
jest.spyOn(fs, 'writeFile').mockResolvedValue(undefined);
156+
157+
const command = new EnvPull(['--environment', 'production'], mockConfig);
158+
// @ts-expect-error
159+
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
160+
161+
await expect(command.runAsync()).rejects.toThrow('File .env.local already exists.');
162+
expect(fs.writeFile).not.toHaveBeenCalled();
163+
});
164+
165+
it('handles secret variables correctly', async () => {
166+
const mockVariables = [
167+
{
168+
name: 'SECRET_VAR',
169+
value: '*****',
170+
type: EnvironmentSecretType.String,
171+
visibility: EnvironmentVariableVisibility.Secret,
172+
},
173+
];
174+
jest
175+
.mocked(EnvironmentVariablesQuery.byAppIdWithSensitiveAsync)
176+
.mockResolvedValue(mockVariables as EnvironmentVariableWithFileContent[]);
177+
178+
const command = new EnvPull(['--environment', 'production', '--non-interactive'], mockConfig);
179+
180+
// @ts-expect-error
181+
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
182+
// @ts-expect-error
183+
jest.spyOn(fs, 'writeFile').mockResolvedValue(undefined);
184+
jest.spyOn(Log, 'log').mockImplementation(() => {});
185+
186+
await command.runAsync();
187+
188+
expect(fs.writeFile).toHaveBeenCalledWith(
189+
'.env.local',
190+
expect.stringContaining('# SECRET_VAR=***** (secret)')
191+
);
192+
expect(Log.log).toHaveBeenCalledWith(
193+
"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."
194+
);
195+
});
196+
197+
it('diffLogAsync generates correct diff log', async () => {
198+
const mockVariables = [
199+
{ name: 'NEW_VAR', value: 'new_value', type: EnvironmentSecretType.String },
200+
{ name: 'UNCHANGED_VAR', value: 'unchanged_value', type: EnvironmentSecretType.String },
201+
{
202+
name: 'UNCHANGED_FILE_VAR',
203+
valueWithFileContent: Buffer.from('unchanged_value').toString('base64'),
204+
type: EnvironmentSecretType.FileBase64,
205+
},
206+
{ name: 'CHANGED_VAR', value: 'changed_value', type: EnvironmentSecretType.String },
207+
{
208+
name: 'CHANGED_FILE_VAR',
209+
valueWithFileContent: Buffer.from('changed_value').toString('base64'),
210+
type: EnvironmentSecretType.FileBase64,
211+
},
212+
];
213+
214+
vol.fromJSON({
215+
'./.eas/.env/UNCHANGED_FILE_VAR': 'unchanged_value',
216+
'./.eas/.env/CHANGED_FILE_VAR': 'changing_value',
217+
});
218+
219+
const currentEnvLocal = {
220+
UNCHANGED_VAR: 'unchanged_value',
221+
CHANGED_VAR: 'changing_value',
222+
UNCHANGED_FILE_VAR: './.eas/.env/UNCHANGED_FILE_VAR',
223+
CHANGED_FILE_VAR: './.eas/.env/CHANGED_FILE_VAR',
224+
REMOVED_VAR: 'removed_value',
225+
};
226+
227+
const command = new EnvPull([], mockConfig);
228+
// @ts-expect-error
229+
jest.spyOn(command, 'getContextAsync').mockReturnValue(mockContext);
230+
231+
// @ts-expect-error
232+
const diffLog = await command.diffLogAsync(mockVariables, currentEnvLocal);
233+
234+
expect(diffLog).toEqual([
235+
chalk.green('+ NEW_VAR'),
236+
' UNCHANGED_VAR',
237+
' UNCHANGED_FILE_VAR',
238+
chalk.yellow('~ CHANGED_VAR'),
239+
chalk.yellow('~ CHANGED_FILE_VAR'),
240+
chalk.red('- REMOVED_VAR'),
241+
]);
242+
});
243+
});

packages/eas-cli/src/commands/env/pull.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Flags } from '@oclif/core';
2+
import chalk from 'chalk';
23
import dotenv from 'dotenv';
34
import * as fs from 'fs-extra';
45
import path from 'path';
@@ -43,6 +44,31 @@ export default class EnvPull extends EasCommand {
4344
}),
4445
};
4546

47+
async isVariableEqualAsync(
48+
currentEnvValue: string | undefined,
49+
newVariable: EnvironmentVariableWithFileContent
50+
): Promise<boolean> {
51+
if (newVariable.visibility === EnvironmentVariableVisibility.Secret) {
52+
return true;
53+
}
54+
55+
if (
56+
newVariable.type === EnvironmentSecretType.FileBase64 &&
57+
newVariable.valueWithFileContent &&
58+
currentEnvValue
59+
) {
60+
if (!(await fs.pathExists(currentEnvValue))) {
61+
return false;
62+
}
63+
64+
const fileContent = await fs.readFile(currentEnvValue, 'base64');
65+
66+
return fileContent === newVariable.valueWithFileContent;
67+
}
68+
69+
return currentEnvValue === newVariable.value;
70+
}
71+
4672
async runAsync(): Promise<void> {
4773
let {
4874
args: { environment: argEnvironment },
@@ -105,6 +131,10 @@ export default class EnvPull extends EasCommand {
105131
await fs.mkdir(envDir, { recursive: true });
106132
}
107133

134+
const diffLog = await this.diffLogAsync(environmentVariables, currentEnvLocal);
135+
136+
Log.addNewLineIfNone();
137+
108138
const skippedSecretVariables: string[] = [];
109139
const overridenSecretVariables: string[] = [];
110140

@@ -146,5 +176,40 @@ export default class EnvPull extends EasCommand {
146176
)}.`
147177
);
148178
}
179+
180+
Log.addNewLineIfNone();
181+
diffLog.forEach(line => {
182+
Log.log(line);
183+
});
184+
}
185+
186+
async diffLogAsync(
187+
environmentVariables: EnvironmentVariableWithFileContent[],
188+
currentEnvLocal: Record<string, string>
189+
): Promise<string[]> {
190+
const allVariableNames = new Set([
191+
...environmentVariables.map(v => v.name),
192+
...Object.keys(currentEnvLocal),
193+
]);
194+
195+
const diffLog = [];
196+
197+
for (const variableName of allVariableNames) {
198+
const newVariable = environmentVariables.find(v => v.name === variableName);
199+
if (newVariable) {
200+
if (Object.hasOwn(currentEnvLocal, variableName)) {
201+
if (await this.isVariableEqualAsync(currentEnvLocal[variableName], newVariable)) {
202+
diffLog.push(` ${variableName}`);
203+
} else {
204+
diffLog.push(chalk.yellow(`~ ${variableName}`));
205+
}
206+
} else {
207+
diffLog.push(chalk.green(`+ ${variableName}`));
208+
}
209+
} else if (Object.hasOwn(currentEnvLocal, variableName)) {
210+
diffLog.push(chalk.red(`- ${variableName}`));
211+
}
212+
}
213+
return diffLog;
149214
}
150215
}

0 commit comments

Comments
 (0)