Skip to content
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

Implement env:init, env:load and env:unload commands #2504

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
231 changes: 231 additions & 0 deletions packages/eas-cli/src/commands/env/init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import spawnAsync from '@expo/spawn-async';
import { appendFile, pathExists, readFile, writeFile } from 'fs-extra';
import os from 'os';
import path from 'path';

import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import Log from '../../log';
import { confirmAsync } from '../../prompts';

const ENVRC_TEMPLATE =
'dotenv_if_exists .env;\ndotenv_if_exists .env.eas.local;\ndotenv_if_exists .env.local;\n';

export default class EnvironmentVariableInit extends EasCommand {
static override description = 'setup environment variables';

static override hidden = true;

static override flags = {
...EASNonInteractiveFlag,
};
Comment on lines +19 to +21
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
static override flags = {
...EASNonInteractiveFlag,
};


static override contextDefinition = {
...this.ContextOptions.ProjectDir,
};

async runAsync(): Promise<void> {
const {
flags: { 'non-interactive': nonInteractive },
} = await this.parse(EnvironmentVariableInit);

if (nonInteractive) {
throw new Error("Non-interactive mode is not supported for 'eas env:init'");
}
Comment on lines +32 to +34
Copy link
Member

Choose a reason for hiding this comment

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

I would just remove this flag from the command


const { projectDir } = await this.getContextAsync(EnvironmentVariableInit, {
nonInteractive,
});

await this.ensureDirenvInstalledAsync(projectDir);
await this.setupEnvrcFileAsync(projectDir);
await this.addDirenvHookToShellConfigAsync();
await this.addToGitIgnoreAsync(projectDir);
Log.log('Running direnv allow...');
await spawnAsync('direnv', ['allow'], { cwd: projectDir, stdio: 'inherit' });
}

private async addDirenvHookToShellConfigAsync(): Promise<void> {
const direnvConfig = this.getShellDirenvConfig();

if (direnvConfig && (await pathExists(direnvConfig.shellConfigPath))) {
const { shellConfigPath, direnvHookCmd, direnvInitCmd } = direnvConfig;

const confirm = await confirmAsync({
message: `Do you want to add the direnv hook to ${shellConfigPath}?`,
});

if (!confirm) {
Log.log('Skipping adding the direnv hook to the shell config');
Log.log('You may need to add the direnv hook to your shell config manually.');
Log.log('Learn more: https://direnv.net/docs/hook.html');
return;
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
return;
throw new Error

to make it exit with non-zero code in such case

}

const configContent = await readFile(shellConfigPath, 'utf8');

if (configContent.includes(direnvHookCmd)) {
Log.log('The direnv hook is already present in the shell config');
return;
Comment on lines +68 to +69
Copy link
Member

Choose a reason for hiding this comment

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

I would throw error here as well

}

await appendFile(shellConfigPath, `\n${direnvHookCmd}\n`, 'utf8');
Log.log(`Added direnv hook to ${shellConfigPath}`);
await spawnAsync(...direnvInitCmd);
} else {
Log.log("Unable to determine the user's shell");
Log.log('You may need to add the direnv hook to your shell config manually.');
Log.log('Learn more: https://direnv.net/docs/hook.html');
}
Comment on lines +75 to +79
Copy link
Member

Choose a reason for hiding this comment

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

I believe we also need to throw an error here

}

private getShellDirenvConfig(): {
shellConfigPath: string;
direnvHookCmd: string;
direnvInitCmd: [string, string[]];
} | null {
const shellEnv = process.env.SHELL;
if (!shellEnv) {
return null;
}

if (shellEnv.endsWith('bash')) {
return {
shellConfigPath: path.join(os.homedir(), '.bashrc'),
direnvHookCmd: 'eval "$(direnv hook bash)"',
direnvInitCmd: ['eval', ['"$(direnv hook bash)"']],
};
} else if (shellEnv.endsWith('zsh')) {
return {
shellConfigPath: path.join(os.homedir(), '.zshrc'),
direnvHookCmd: 'eval "$(direnv hook zsh)"',
direnvInitCmd: ['eval', ['"$(direnv hook zsh)"']],
};
} else if (shellEnv.endsWith('fish')) {
return {
shellConfigPath: path.join(os.homedir(), '.config/fish/config.fish'),
direnvHookCmd: 'direnv hook fish | source',
direnvInitCmd: ['eval', ['"$(direnv hook fish)"']],
};
} else {
return null;
}
}

private async addToGitIgnoreAsync(cwd: string): Promise<void> {
const gitIgnorePath = path.resolve(cwd, '.gitignore');
if (await pathExists(gitIgnorePath)) {
const gitignoreContent = await readFile(gitIgnorePath, 'utf8');

const filesToIgnore = ['.envrc', '.env.eas.local', '.env.eas.local.original'];
const linesToAdd = filesToIgnore.filter(file => !gitignoreContent.includes(file));

if (linesToAdd.length > 0) {
const confirm = await confirmAsync({
message: `Do you want to add ${linesToAdd.join(',')} to .gitignore?`,
});
if (confirm) {
await appendFile(gitIgnorePath, linesToAdd.join('\n') + '\n', 'utf8');
Log.log(`${linesToAdd.join(',')} added to .gitignore`);
} else {
Log.log('Skipping adding .envrc and .env.local to .gitignore');
}
} else {
Log.log('.envrc and .env.local are already present in .gitignore');
}
}
}

private async setupEnvrcFileAsync(cwd: string): Promise<void> {
const envrcPath = path.resolve(cwd, '.envrc');
if (await pathExists(envrcPath)) {
Log.log('.envrc file already exists');
const envrcContent = await readFile(envrcPath, 'utf8');
if (envrcContent.includes(ENVRC_TEMPLATE)) {
Log.log('.envrc file is already set up');
return;
}

const confirm = await confirmAsync({
message: 'Do you want to modify the existing .envrc file?',
});
if (confirm) {
Log.log('Modifying existing .envrc file...');
await appendFile(envrcPath, ENVRC_TEMPLATE, 'utf8');
Log.log('.envrc file modified');
} else {
Log.log('Skipping modifying .envrc file');
}
} else {
Log.log('Creating .envrc file...');
await writeFile(envrcPath, ENVRC_TEMPLATE, 'utf8');
Log.log('.envrc file created');
}
}

private async ensureDirenvInstalledAsync(cwd: string): Promise<void> {
Log.log('Checking direnv installation...');
try {
await spawnAsync('direnv', ['--version']);
Log.log('direnv is already installed');
} catch {
Log.log('direnv is not installed');
const install = await confirmAsync({
message: 'Do you want EAS CLI to install direnv for you?',
});
if (install) {
await this.installDirenvAsync(cwd);
Log.log('direnv installed');
} else {
Log.error("You'll need to install direnv manually");
throw new Error('Aborting...');
}
}
}

private async installDirenvAsync(cwd: string): Promise<void> {
const platform = os.platform();

let installCommand;
let installArgs;

if (platform === 'darwin') {
installCommand = 'brew';
installArgs = ['install', 'direnv'];
} else if (platform === 'linux') {
const linuxDistribution = await spawnAsync('cat', ['/etc/os-release']);
const stderr = linuxDistribution.stderr;
if (stderr) {
throw new Error(`Error reading OS release info: ${stderr}`);
}

const stdout = linuxDistribution.stdout;

if (stdout.includes('Ubuntu') || stdout.includes('Debian')) {
Log.log('Detected a Debian-based Linux distribution.');
installCommand = 'sudo apt-get';
installArgs = ['install', '-y', 'direnv'];
} else if (stdout.includes('Fedora') || stdout.includes('CentOS')) {
Log.log('Detected a Red Hat-based Linux distribution.');
installCommand = 'sudo dnf';
installArgs = ['install', '-y', 'direnv'];
} else {
throw new Error('Your Linux distribution is not supported by this script.');
}
} else {
Log.log(`Your platform (${platform}) is not supported by this script.`);
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
Log.log(`Your platform (${platform}) is not supported by this script.`);
throw new Error(`Your platform (${platform}) is not supported by this script.`);

}

if (!installCommand) {
throw new Error('Failed to determine the installation command for direnv');
}

try {
Log.log(`Running: ${installCommand}`);
await spawnAsync(installCommand, installArgs, { stdio: 'inherit', cwd });
} catch (error: any) {
Log.error(`Failed to install direnv: ${error.message}`);
throw error;
}
}
}
89 changes: 89 additions & 0 deletions packages/eas-cli/src/commands/env/load.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import assert from 'assert';
import * as fs from 'fs-extra';
import { exists } from 'fs-extra';
import path from 'path';

import EasCommand from '../../commandUtils/EasCommand';
import { EASEnvironmentFlag, EASNonInteractiveFlag } from '../../commandUtils/flags';
import { EnvironmentVariableFragment } from '../../graphql/generated';
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
import Log from '../../log';

const EnvLocalFile = '.env.local';
const EnvOriginalLocalFile = `${EnvLocalFile}.original`;

export default class EnvironmentVariableLoad extends EasCommand {
static override description = 'change environment variables';

static override hidden = true;

static override flags = {
...EASEnvironmentFlag,
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
...this.ContextOptions.ProjectDir,
};

async runAsync(): Promise<void> {
const {
flags: { environment, 'non-interactive': nonInteractive },
} = await this.parse(EnvironmentVariableLoad);

const {
privateProjectConfig: { projectId },
projectDir,
loggedIn: { graphqlClient },
} = await this.getContextAsync(EnvironmentVariableLoad, {
nonInteractive,
});

assert(environment, 'Environment is required');

const envLocalFile = path.resolve(projectDir, EnvLocalFile);
const envOriginalLocalFile = path.resolve(projectDir, EnvOriginalLocalFile);

if ((await exists(envLocalFile)) && !(await exists(envOriginalLocalFile))) {
await fs.rename(envLocalFile, envOriginalLocalFile);
}
Log.log('Pulling environment variables...');

const environmentVariables = await EnvironmentVariablesQuery.byAppIdWithSensitiveAsync(
graphqlClient,
{
appId: projectId,
environment,
}
);

const secretVariables = environmentVariables
.filter(({ value }) => value === null)
.map(({ name }) => name);

const envFileContent = environmentVariables
.filter((variable: EnvironmentVariableFragment) => variable.value !== null)
.map((variable: EnvironmentVariableFragment) => {
return `${variable.name}=${variable.value}`;
Comment on lines +66 to +68
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
.filter((variable: EnvironmentVariableFragment) => variable.value !== null)
.map((variable: EnvironmentVariableFragment) => {
return `${variable.name}=${variable.value}`;
.filter((variable => variable.value !== null)
.map((variable) => {
return `${variable.name}=${variable.value}`;

})
.join('\n');

if (envFileContent.length === 0) {
Log.warn(`No environment variables found for ${environment}.`);
throw new Error(`Ignoring the environment.`);
}

await fs.writeFile(envLocalFile, envFileContent);
await fs.appendFile(envLocalFile, `\nEAS_CURRENT_ENVIRONMENT=${environment}\n`);
Log.log(`Environment variables for ${environment} have been loaded.`);
if (secretVariables.length > 0) {
Log.addNewLineIfNone();
Log.warn(
`Some variables are not available for reading. You can edit them in ${envLocalFile} manually.`
);
Log.warn(`Variables that are not available for reading: ${secretVariables.join(', ')}.`);
Log.addNewLineIfNone();
}
}
}
2 changes: 1 addition & 1 deletion packages/eas-cli/src/commands/env/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export default class EnvironmentValuePull extends EasCommand {
...EASNonInteractiveFlag,
path: Flags.string({
description: 'Path to the result `.env` file',
default: '.env.local',
default: '.env.eas.local',
Copy link
Member

Choose a reason for hiding this comment

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

TBH I would stick with .env.local as default, I believe that's what the average person expects, and .env.local works as well with Expo SDK by default but .env.eas.local doesn't.

}),
};

Expand Down
35 changes: 35 additions & 0 deletions packages/eas-cli/src/commands/env/unload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as fs from 'fs-extra';
import { exists } from 'fs-extra';
import path from 'path';

import EasCommand from '../../commandUtils/EasCommand';
import Log from '../../log';

const EnvLocalFile = '.env.local';
const EnvOriginalLocalFile = `${EnvLocalFile}.original`;

export default class EnvironmentVariableUnload extends EasCommand {
static override description = 'unload environment variables';

static override hidden = true;

static override contextDefinition = {
...this.ContextOptions.ProjectDir,
};

async runAsync(): Promise<void> {
const { projectDir } = await this.getContextAsync(EnvironmentVariableUnload, {
nonInteractive: true,
});

const envLocalFile = path.resolve(projectDir, EnvLocalFile);
const envOriginalLocalFile = path.resolve(projectDir, EnvOriginalLocalFile);

if (await exists(envOriginalLocalFile)) {
await fs.rename(envOriginalLocalFile, envLocalFile);
} else {
await fs.remove(envLocalFile);
}
Log.log(`Unloaded environment variables.`);
}
}
Loading