-
Notifications
You must be signed in to change notification settings - Fork 149
Add diff log to env:pull command #2984
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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'), | ||
]); | ||
}); | ||
}); |
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'; | ||
|
@@ -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; | ||
} | ||
|
||
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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
} | ||
|
||
async runAsync(): Promise<void> { | ||
let { | ||
args: { environment: argEnvironment }, | ||
|
@@ -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[] = []; | ||
|
||
|
@@ -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; | ||
} | ||
} |
There was a problem hiding this comment.
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)?There was a problem hiding this comment.
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).