Skip to content

Commit c0cb6a5

Browse files
authored
Implements .env file support (#5531)
**What's the problem this PR addresses?** A common need is to provide environment values into the environment via `.env` files. There's been a couple of issues and attempts at implementation already, which all were decently upvoted. I myself could have used it once or twice 😄 Props to @jj811208 for his implementation in #4835 - I wanted to make the configuration a little more generic (allowing to have multiple environment files, and to possibly disable it altogether), but it was a appreciated start. Fixes #4718 Closes #4835 (Supercedes it) **How did you fix it?** A new setting, `injectEnvironmentFiles`, lets you define files that Yarn will load and inject into all scripts. It only affects subprocesses - Yarn itself still uses `process.env` for its checks, so you can't for example set `YARN_*` values and expect them to be applied to the current process (use the yarnrc file for that instead). The `injectEnvironmentFiles` setting has a few properties: - It defaults to `.env` - Nothing will be injected if it's set to an empty array or null - The paths inside may be suffixed by `?` - in that case, Yarn won't throw if the file doesn't exist The idea with this last property is to allow for simple user configuration (imagine, with the example below, that the project also has a gitignore with `.env.*`): ``` injectEnvironmentFiles: - .env - .env.${USER}? ``` **Checklist** <!--- Don't worry if you miss something, chores are automatically tested. --> <!--- This checklist exists to help you remember doing the chores when you submit a PR. --> <!--- Put an `x` in all the boxes that apply. --> - [x] I have read the [Contributing Guide](https://yarnpkg.com/advanced/contributing). <!-- See https://yarnpkg.com/advanced/contributing#preparing-your-pr-to-be-released for more details. --> <!-- Check with `yarn version check` and fix with `yarn version check -i` --> - [x] I have set the packages that need to be released for my changes to be effective. <!-- The "Testing chores" workflow validates that your PR follows our guidelines. --> <!-- If it doesn't pass, click on it to see details as to what your PR might be missing. --> - [x] I will check that all automated PR checks pass before the PR gets reviewed.
1 parent 30b0d09 commit c0cb6a5

File tree

12 files changed

+213
-10
lines changed

12 files changed

+213
-10
lines changed

.pnp.cjs

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
25.9 KB
Binary file not shown.

.yarn/versions/8ccfe176.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
releases:
2+
"@yarnpkg/cli": major
3+
"@yarnpkg/core": major
4+
"@yarnpkg/fslib": major
5+
"@yarnpkg/plugin-essentials": major
6+
"@yarnpkg/plugin-npm-cli": major
7+
"@yarnpkg/plugin-workspace-tools": major
8+
9+
declined:
10+
- "@yarnpkg/plugin-compat"
11+
- "@yarnpkg/plugin-constraints"
12+
- "@yarnpkg/plugin-dlx"
13+
- "@yarnpkg/plugin-exec"
14+
- "@yarnpkg/plugin-file"
15+
- "@yarnpkg/plugin-git"
16+
- "@yarnpkg/plugin-github"
17+
- "@yarnpkg/plugin-http"
18+
- "@yarnpkg/plugin-init"
19+
- "@yarnpkg/plugin-interactive-tools"
20+
- "@yarnpkg/plugin-link"
21+
- "@yarnpkg/plugin-nm"
22+
- "@yarnpkg/plugin-npm"
23+
- "@yarnpkg/plugin-pack"
24+
- "@yarnpkg/plugin-patch"
25+
- "@yarnpkg/plugin-pnp"
26+
- "@yarnpkg/plugin-pnpm"
27+
- "@yarnpkg/plugin-stage"
28+
- "@yarnpkg/plugin-typescript"
29+
- "@yarnpkg/plugin-version"
30+
- vscode-zipfs
31+
- "@yarnpkg/builder"
32+
- "@yarnpkg/doctor"
33+
- "@yarnpkg/extensions"
34+
- "@yarnpkg/libzip"
35+
- "@yarnpkg/nm"
36+
- "@yarnpkg/pnp"
37+
- "@yarnpkg/pnpify"
38+
- "@yarnpkg/sdks"
39+
- "@yarnpkg/shell"
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import {Filename, ppath, xfs} from '@yarnpkg/fslib';
2+
3+
describe(`DotEnv files`, () => {
4+
it(`should automatically inject a .env file in the environment`, makeTemporaryEnv({}, async ({path, run, source}) => {
5+
await run(`install`);
6+
7+
await xfs.writeFilePromise(ppath.join(path, `.env`), [
8+
`INJECTED_FROM_ENV_FILE=hello\n`,
9+
].join(``));
10+
11+
await expect(run(`exec`, `env`)).resolves.toMatchObject({
12+
stdout: expect.stringMatching(/^INJECTED_FROM_ENV_FILE=hello$/m),
13+
});
14+
}));
15+
16+
it(`should allow .env variables to be interpolated`, makeTemporaryEnv({}, async ({path, run, source}) => {
17+
await run(`install`);
18+
19+
await xfs.writeFilePromise(ppath.join(path, `.env`), [
20+
`INJECTED_FROM_ENV_FILE=\${FOO}\n`,
21+
].join(``));
22+
23+
await expect(run(`exec`, `env`, {env: {FOO: `foo`}})).resolves.toMatchObject({
24+
stdout: expect.stringMatching(/^INJECTED_FROM_ENV_FILE=foo$/m),
25+
});
26+
}));
27+
28+
it(`should allow .env variables to be used in the next ones`, makeTemporaryEnv({}, async ({path, run, source}) => {
29+
await run(`install`);
30+
31+
await xfs.writeFilePromise(ppath.join(path, `.env`), [
32+
`INJECTED_FROM_ENV_FILE_1=hello\n`,
33+
`INJECTED_FROM_ENV_FILE_2=\${INJECTED_FROM_ENV_FILE_1} world\n`,
34+
].join(``));
35+
36+
await expect(run(`exec`, `env`, {env: {FOO: `foo`}})).resolves.toMatchObject({
37+
stdout: expect.stringMatching(/^INJECTED_FROM_ENV_FILE_2=hello world$/m),
38+
});
39+
}));
40+
41+
it(`shouldn't read the .env if the injectEnvironmentFiles setting is defined`, makeTemporaryEnv({}, async ({path, run, source}) => {
42+
await xfs.writeJsonPromise(ppath.join(path, Filename.rc), {
43+
injectEnvironmentFiles: [],
44+
});
45+
46+
await xfs.writeFilePromise(ppath.join(path, `.my-env`), [
47+
`INJECTED_FROM_ENV_FILE=hello\n`,
48+
].join(``));
49+
50+
await run(`install`);
51+
52+
await expect(run(`exec`, `env`)).resolves.toMatchObject({
53+
stdout: expect.not.stringMatching(/^INJECTED_FROM_ENV_FILE=/m),
54+
});
55+
}));
56+
57+
it(`should allow multiple environment files to be defined`, makeTemporaryEnv({}, async ({path, run, source}) => {
58+
await xfs.writeJsonPromise(ppath.join(path, Filename.rc), {
59+
injectEnvironmentFiles: [`.my-env`, `.my-other-env`],
60+
});
61+
62+
await xfs.writeFilePromise(ppath.join(path, `.my-env`), [
63+
`INJECTED_FROM_ENV_FILE_1=hello\n`,
64+
].join(``));
65+
66+
await xfs.writeFilePromise(ppath.join(path, `.my-other-env`), [
67+
`INJECTED_FROM_ENV_FILE_2=world\n`,
68+
].join(``));
69+
70+
await run(`install`);
71+
72+
const {stdout} = await run(`exec`, `env`);
73+
74+
expect(stdout).toMatch(/^INJECTED_FROM_ENV_FILE_1=hello$/m);
75+
expect(stdout).toMatch(/^INJECTED_FROM_ENV_FILE_2=world$/m);
76+
}));
77+
78+
it(`should let the last environment file override the first`, makeTemporaryEnv({}, async ({path, run, source}) => {
79+
await xfs.writeJsonPromise(ppath.join(path, Filename.rc), {
80+
injectEnvironmentFiles: [`.my-env`, `.my-other-env`],
81+
});
82+
83+
await xfs.writeFilePromise(ppath.join(path, `.my-env`), [
84+
`INJECTED_FROM_ENV_FILE=hello\n`,
85+
].join(``));
86+
87+
await xfs.writeFilePromise(ppath.join(path, `.my-other-env`), [
88+
`INJECTED_FROM_ENV_FILE=world\n`,
89+
].join(``));
90+
91+
await run(`install`);
92+
93+
await expect(run(`exec`, `env`)).resolves.toMatchObject({
94+
stdout: expect.stringMatching(/^INJECTED_FROM_ENV_FILE=world$/m),
95+
});
96+
}));
97+
98+
it(`should throw an error if the settings reference a non-existing file`, makeTemporaryEnv({}, async ({path, run, source}) => {
99+
await xfs.writeJsonPromise(ppath.join(path, Filename.rc), {
100+
injectEnvironmentFiles: [`.my-env`],
101+
});
102+
103+
await expect(run(`install`)).rejects.toThrow();
104+
}));
105+
106+
it(`shouldn't throw an error if the settings reference a non-existing file with a ?-suffixed path`, makeTemporaryEnv({}, async ({path, run, source}) => {
107+
await xfs.writeJsonPromise(ppath.join(path, Filename.rc), {
108+
injectEnvironmentFiles: [`.my-env?`],
109+
});
110+
111+
await run(`install`);
112+
}));
113+
});

packages/plugin-essentials/sources/commands/set/version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ export async function setVersion(configuration: Configuration, bundleVersion: st
192192

193193
const {stdout} = await execUtils.execvp(process.execPath, [npath.fromPortablePath(temporaryPath), `--version`], {
194194
cwd: tmpDir,
195-
env: {...process.env, YARN_IGNORE_PATH: `1`},
195+
env: {...configuration.env, YARN_IGNORE_PATH: `1`},
196196
});
197197

198198
bundleVersion = stdout.trim();

packages/plugin-npm-cli/sources/commands/npm/login.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,10 @@ async function getCredentials({configuration, registry, report, stdin, stdout}:
140140

141141
report.reportSeparator();
142142

143-
if (process.env.YARN_IS_TEST_ENV) {
143+
if (configuration.env.YARN_IS_TEST_ENV) {
144144
return {
145-
name: process.env.YARN_INJECT_NPM_USER || ``,
146-
password: process.env.YARN_INJECT_NPM_PASSWORD || ``,
145+
name: configuration.env.YARN_INJECT_NPM_USER || ``,
146+
password: configuration.env.YARN_INJECT_NPM_PASSWORD || ``,
147147
};
148148
}
149149

packages/plugin-workspace-tools/sources/commands/foreach.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ export default class WorkspacesForeachCommand extends BaseCommand {
178178

179179
// Prevents infinite loop in the case of configuring a script as such:
180180
// "lint": "yarn workspaces foreach --all lint"
181-
if (scriptName === process.env.npm_lifecycle_event && workspace.cwd === cwdWorkspace!.cwd)
181+
if (scriptName === configuration.env.npm_lifecycle_event && workspace.cwd === cwdWorkspace!.cwd)
182182
continue;
183183

184184
if (this.include.length > 0 && !micromatch.isMatch(structUtils.stringifyIdent(workspace.locator), this.include) && !micromatch.isMatch(workspace.relativeCwd, this.include))

packages/yarnpkg-core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"clipanion": "^3.2.1",
2424
"cross-spawn": "7.0.3",
2525
"diff": "^5.1.0",
26+
"dotenv": "^16.3.1",
2627
"globby": "^11.0.1",
2728
"got": "^11.7.0",
2829
"lodash": "^4.17.15",

packages/yarnpkg-core/sources/Configuration.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {parseSyml, stringifySyml}
33
import camelcase from 'camelcase';
44
import {isCI, isPR, GITHUB_ACTIONS} from 'ci-info';
55
import {UsageError} from 'clipanion';
6+
import {parse as parseDotEnv} from 'dotenv';
67
import pLimit, {Limit} from 'p-limit';
78
import {PassThrough, Writable} from 'stream';
89

@@ -529,6 +530,14 @@ export const coreDefinitions: {[coreSettingName: string]: SettingsDefinition} =
529530
default: `throw`,
530531
},
531532

533+
// Miscellaneous settings
534+
injectEnvironmentFiles: {
535+
description: `List of all the environment files that Yarn should inject inside the process when it starts`,
536+
type: SettingsType.ABSOLUTE_PATH,
537+
default: [`.env?`],
538+
isArray: true,
539+
},
540+
532541
// Package patching - to fix incorrect definitions
533542
packageExtensions: {
534543
description: `Map of package corrections to apply on the dependency tree`,
@@ -640,6 +649,9 @@ export interface ConfigurationValueMap {
640649
enableImmutableCache: boolean;
641650
checksumBehavior: string;
642651

652+
// Miscellaneous settings
653+
injectEnvironmentFiles: Array<PortablePath>;
654+
643655
// Package patching - to fix incorrect definitions
644656
packageExtensions: Map<string, miscUtils.ToMapValue<{
645657
dependencies?: Map<string, string>;
@@ -841,7 +853,9 @@ function getDefaultValue(configuration: Configuration, definition: SettingsDefin
841853
return null;
842854

843855
if (configuration.projectCwd === null) {
844-
if (ppath.isAbsolute(definition.default)) {
856+
if (Array.isArray(definition.default)) {
857+
return definition.default.map((entry: string) => ppath.normalize(entry as PortablePath));
858+
} else if (ppath.isAbsolute(definition.default)) {
845859
return ppath.normalize(definition.default);
846860
} else if (definition.isNullable) {
847861
return null;
@@ -966,6 +980,7 @@ export class Configuration {
966980

967981
public invalid: Map<string, string> = new Map();
968982

983+
public env: Record<string, string | undefined> = {};
969984
public packageExtensions: Map<IdentHash, Array<[string, Array<PackageExtension>]>> = new Map();
970985

971986
public limits: Map<string, Limit> = new Map();
@@ -1052,8 +1067,8 @@ export class Configuration {
10521067

10531068
const allCoreFieldKeys = new Set(Object.keys(coreDefinitions));
10541069

1055-
const pickPrimaryCoreFields = ({ignoreCwd, yarnPath, ignorePath, lockfileFilename}: CoreFields) => ({ignoreCwd, yarnPath, ignorePath, lockfileFilename});
1056-
const pickSecondaryCoreFields = ({ignoreCwd, yarnPath, ignorePath, lockfileFilename, ...rest}: CoreFields) => {
1070+
const pickPrimaryCoreFields = ({ignoreCwd, yarnPath, ignorePath, lockfileFilename, injectEnvironmentFiles}: CoreFields) => ({ignoreCwd, yarnPath, ignorePath, lockfileFilename, injectEnvironmentFiles});
1071+
const pickSecondaryCoreFields = ({ignoreCwd, yarnPath, ignorePath, lockfileFilename, injectEnvironmentFiles, ...rest}: CoreFields) => {
10571072
const secondaryCoreFields: CoreFields = {};
10581073
for (const [key, value] of Object.entries(rest))
10591074
if (allCoreFieldKeys.has(key))
@@ -1120,6 +1135,22 @@ export class Configuration {
11201135
configuration.startingCwd = startingCwd;
11211136
configuration.projectCwd = projectCwd;
11221137

1138+
const env = Object.assign(Object.create(null), process.env);
1139+
configuration.env = env;
1140+
1141+
// load the environment files
1142+
const environmentFiles = await Promise.all(configuration.get(`injectEnvironmentFiles`).map(async p => {
1143+
const content = p.endsWith(`?`)
1144+
? await xfs.readFilePromise(p.slice(0, -1) as PortablePath, `utf8`).catch(() => ``)
1145+
: await xfs.readFilePromise(p as PortablePath, `utf8`);
1146+
1147+
return parseDotEnv(content);
1148+
}));
1149+
1150+
for (const environmentEntries of environmentFiles)
1151+
for (const [key, value] of Object.entries(environmentEntries))
1152+
configuration.env[key] = miscUtils.replaceEnvVariables(value, {env});
1153+
11231154
// load all fields of the core definitions
11241155
configuration.importSettings(pickSecondaryCoreFields(coreDefinitions));
11251156
configuration.useWithSource(`<environment>`, pickSecondaryCoreFields(environmentSettings), startingCwd, {strict});

packages/yarnpkg-core/sources/scriptUtils.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,11 @@ export async function detectPackageManager(location: PortablePath): Promise<Pack
107107
return null;
108108
}
109109

110-
export async function makeScriptEnv({project, locator, binFolder, ignoreCorepack, lifecycleScript}: {project?: Project, locator?: Locator, binFolder: PortablePath, ignoreCorepack?: boolean, lifecycleScript?: string}) {
110+
export async function makeScriptEnv({project, locator, binFolder, ignoreCorepack, lifecycleScript, baseEnv = project?.configuration.env ?? process.env}: {project?: Project, locator?: Locator, binFolder: PortablePath, ignoreCorepack?: boolean, lifecycleScript?: string, baseEnv?: Record<string, string | undefined>}) {
111111
const scriptEnv: {[key: string]: string} = {};
112-
for (const [key, value] of Object.entries(process.env))
112+
113+
// Ensure that the PATH environment variable is properly capitalized (Windows)
114+
for (const [key, value] of Object.entries(baseEnv))
113115
if (typeof value !== `undefined`)
114116
scriptEnv[key.toLowerCase() !== `path` ? key : `PATH`] = value;
115117

0 commit comments

Comments
 (0)