Skip to content

Commit e213b5d

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 0ae6e29 commit e213b5d

File tree

12 files changed

+213
-10
lines changed

12 files changed

+213
-10
lines changed

.pnp.cjs

+8
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Binary file not shown.

.yarn/versions/8ccfe176.yml

+39
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"
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

+1-1
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

+3-3
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

+1-1
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

+1
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

+34-3
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

+4-2
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

packages/yarnpkg-fslib/sources/path.ts

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export const Filename = {
3232
pnpData: `.pnp.data.json` as Filename,
3333
pnpEsmLoader: `.pnp.loader.mjs` as Filename,
3434
rc: `.yarnrc.yml` as Filename,
35+
env: `.env` as Filename,
3536
};
3637

3738
export type TolerateLiterals<T> = {

yarn.lock

+8
Original file line numberDiff line numberDiff line change
@@ -7081,6 +7081,7 @@ __metadata:
70817081
comment-json: "npm:^2.2.0"
70827082
cross-spawn: "npm:7.0.3"
70837083
diff: "npm:^5.1.0"
7084+
dotenv: "npm:^16.3.1"
70847085
esbuild: "npm:esbuild-wasm@^0.15.15"
70857086
globby: "npm:^11.0.1"
70867087
got: "npm:^11.7.0"
@@ -12537,6 +12538,13 @@ __metadata:
1253712538
languageName: node
1253812539
linkType: hard
1253912540

12541+
"dotenv@npm:^16.3.1":
12542+
version: 16.3.1
12543+
resolution: "dotenv@npm:16.3.1"
12544+
checksum: dbb778237ef8750e9e3cd1473d3c8eaa9cc3600e33a75c0e36415d0fa0848197f56c3800f77924c70e7828f0b03896818cd52f785b07b9ad4d88dba73fbba83f
12545+
languageName: node
12546+
linkType: hard
12547+
1254012548
"dotenv@npm:^8.2.0":
1254112549
version: 8.2.0
1254212550
resolution: "dotenv@npm:8.2.0"

0 commit comments

Comments
 (0)