diff --git a/.pnp.cjs b/.pnp.cjs index ea036c5e7dac..6fed40bf1b03 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -13063,6 +13063,7 @@ const RAW_RUNTIME_STATE = ["comment-json", "npm:2.2.0"],\ ["cross-spawn", "npm:7.0.3"],\ ["diff", "npm:5.1.0"],\ + ["dotenv", "npm:16.3.1"],\ ["esbuild", [\ "esbuild-wasm",\ "npm:0.15.15"\ @@ -28431,6 +28432,13 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["dotenv", [\ + ["npm:16.3.1", {\ + "packageLocation": "./.yarn/cache/dotenv-npm-16.3.1-e6d380a398-dbb778237e.zip/node_modules/dotenv/",\ + "packageDependencies": [\ + ["dotenv", "npm:16.3.1"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:8.2.0", {\ "packageLocation": "./.yarn/cache/dotenv-npm-8.2.0-6b21df4d37-994ca227e1.zip/node_modules/dotenv/",\ "packageDependencies": [\ diff --git a/.yarn/cache/dotenv-npm-16.3.1-e6d380a398-dbb778237e.zip b/.yarn/cache/dotenv-npm-16.3.1-e6d380a398-dbb778237e.zip new file mode 100644 index 000000000000..592331adb3a7 Binary files /dev/null and b/.yarn/cache/dotenv-npm-16.3.1-e6d380a398-dbb778237e.zip differ diff --git a/.yarn/versions/8ccfe176.yml b/.yarn/versions/8ccfe176.yml new file mode 100644 index 000000000000..e8a794639eed --- /dev/null +++ b/.yarn/versions/8ccfe176.yml @@ -0,0 +1,39 @@ +releases: + "@yarnpkg/cli": major + "@yarnpkg/core": major + "@yarnpkg/fslib": major + "@yarnpkg/plugin-essentials": major + "@yarnpkg/plugin-npm-cli": major + "@yarnpkg/plugin-workspace-tools": major + +declined: + - "@yarnpkg/plugin-compat" + - "@yarnpkg/plugin-constraints" + - "@yarnpkg/plugin-dlx" + - "@yarnpkg/plugin-exec" + - "@yarnpkg/plugin-file" + - "@yarnpkg/plugin-git" + - "@yarnpkg/plugin-github" + - "@yarnpkg/plugin-http" + - "@yarnpkg/plugin-init" + - "@yarnpkg/plugin-interactive-tools" + - "@yarnpkg/plugin-link" + - "@yarnpkg/plugin-nm" + - "@yarnpkg/plugin-npm" + - "@yarnpkg/plugin-pack" + - "@yarnpkg/plugin-patch" + - "@yarnpkg/plugin-pnp" + - "@yarnpkg/plugin-pnpm" + - "@yarnpkg/plugin-stage" + - "@yarnpkg/plugin-typescript" + - "@yarnpkg/plugin-version" + - vscode-zipfs + - "@yarnpkg/builder" + - "@yarnpkg/doctor" + - "@yarnpkg/extensions" + - "@yarnpkg/libzip" + - "@yarnpkg/nm" + - "@yarnpkg/pnp" + - "@yarnpkg/pnpify" + - "@yarnpkg/sdks" + - "@yarnpkg/shell" diff --git a/packages/acceptance-tests/pkg-tests-specs/sources/features/dotEnvFiles.test.ts b/packages/acceptance-tests/pkg-tests-specs/sources/features/dotEnvFiles.test.ts new file mode 100644 index 000000000000..cdea0becda81 --- /dev/null +++ b/packages/acceptance-tests/pkg-tests-specs/sources/features/dotEnvFiles.test.ts @@ -0,0 +1,113 @@ +import {Filename, ppath, xfs} from '@yarnpkg/fslib'; + +describe(`DotEnv files`, () => { + it(`should automatically inject a .env file in the environment`, makeTemporaryEnv({}, async ({path, run, source}) => { + await run(`install`); + + await xfs.writeFilePromise(ppath.join(path, `.env`), [ + `INJECTED_FROM_ENV_FILE=hello\n`, + ].join(``)); + + await expect(run(`exec`, `env`)).resolves.toMatchObject({ + stdout: expect.stringMatching(/^INJECTED_FROM_ENV_FILE=hello$/m), + }); + })); + + it(`should allow .env variables to be interpolated`, makeTemporaryEnv({}, async ({path, run, source}) => { + await run(`install`); + + await xfs.writeFilePromise(ppath.join(path, `.env`), [ + `INJECTED_FROM_ENV_FILE=\${FOO}\n`, + ].join(``)); + + await expect(run(`exec`, `env`, {env: {FOO: `foo`}})).resolves.toMatchObject({ + stdout: expect.stringMatching(/^INJECTED_FROM_ENV_FILE=foo$/m), + }); + })); + + it(`should allow .env variables to be used in the next ones`, makeTemporaryEnv({}, async ({path, run, source}) => { + await run(`install`); + + await xfs.writeFilePromise(ppath.join(path, `.env`), [ + `INJECTED_FROM_ENV_FILE_1=hello\n`, + `INJECTED_FROM_ENV_FILE_2=\${INJECTED_FROM_ENV_FILE_1} world\n`, + ].join(``)); + + await expect(run(`exec`, `env`, {env: {FOO: `foo`}})).resolves.toMatchObject({ + stdout: expect.stringMatching(/^INJECTED_FROM_ENV_FILE_2=hello world$/m), + }); + })); + + it(`shouldn't read the .env if the injectEnvironmentFiles setting is defined`, makeTemporaryEnv({}, async ({path, run, source}) => { + await xfs.writeJsonPromise(ppath.join(path, Filename.rc), { + injectEnvironmentFiles: [], + }); + + await xfs.writeFilePromise(ppath.join(path, `.my-env`), [ + `INJECTED_FROM_ENV_FILE=hello\n`, + ].join(``)); + + await run(`install`); + + await expect(run(`exec`, `env`)).resolves.toMatchObject({ + stdout: expect.not.stringMatching(/^INJECTED_FROM_ENV_FILE=/m), + }); + })); + + it(`should allow multiple environment files to be defined`, makeTemporaryEnv({}, async ({path, run, source}) => { + await xfs.writeJsonPromise(ppath.join(path, Filename.rc), { + injectEnvironmentFiles: [`.my-env`, `.my-other-env`], + }); + + await xfs.writeFilePromise(ppath.join(path, `.my-env`), [ + `INJECTED_FROM_ENV_FILE_1=hello\n`, + ].join(``)); + + await xfs.writeFilePromise(ppath.join(path, `.my-other-env`), [ + `INJECTED_FROM_ENV_FILE_2=world\n`, + ].join(``)); + + await run(`install`); + + const {stdout} = await run(`exec`, `env`); + + expect(stdout).toMatch(/^INJECTED_FROM_ENV_FILE_1=hello$/m); + expect(stdout).toMatch(/^INJECTED_FROM_ENV_FILE_2=world$/m); + })); + + it(`should let the last environment file override the first`, makeTemporaryEnv({}, async ({path, run, source}) => { + await xfs.writeJsonPromise(ppath.join(path, Filename.rc), { + injectEnvironmentFiles: [`.my-env`, `.my-other-env`], + }); + + await xfs.writeFilePromise(ppath.join(path, `.my-env`), [ + `INJECTED_FROM_ENV_FILE=hello\n`, + ].join(``)); + + await xfs.writeFilePromise(ppath.join(path, `.my-other-env`), [ + `INJECTED_FROM_ENV_FILE=world\n`, + ].join(``)); + + await run(`install`); + + await expect(run(`exec`, `env`)).resolves.toMatchObject({ + stdout: expect.stringMatching(/^INJECTED_FROM_ENV_FILE=world$/m), + }); + })); + + it(`should throw an error if the settings reference a non-existing file`, makeTemporaryEnv({}, async ({path, run, source}) => { + await xfs.writeJsonPromise(ppath.join(path, Filename.rc), { + injectEnvironmentFiles: [`.my-env`], + }); + + await expect(run(`install`)).rejects.toThrow(); + })); + + it(`shouldn't throw an error if the settings reference a non-existing file with a ?-suffixed path`, makeTemporaryEnv({}, async ({path, run, source}) => { + await xfs.writeJsonPromise(ppath.join(path, Filename.rc), { + injectEnvironmentFiles: [`.my-env?`], + }); + + await run(`install`); + })); +}); diff --git a/packages/plugin-essentials/sources/commands/set/version.ts b/packages/plugin-essentials/sources/commands/set/version.ts index c0d8b96e283b..e196ca3319e0 100644 --- a/packages/plugin-essentials/sources/commands/set/version.ts +++ b/packages/plugin-essentials/sources/commands/set/version.ts @@ -192,7 +192,7 @@ export async function setVersion(configuration: Configuration, bundleVersion: st const {stdout} = await execUtils.execvp(process.execPath, [npath.fromPortablePath(temporaryPath), `--version`], { cwd: tmpDir, - env: {...process.env, YARN_IGNORE_PATH: `1`}, + env: {...configuration.env, YARN_IGNORE_PATH: `1`}, }); bundleVersion = stdout.trim(); diff --git a/packages/plugin-npm-cli/sources/commands/npm/login.ts b/packages/plugin-npm-cli/sources/commands/npm/login.ts index 5170f21ff394..b8e1b71db3d9 100644 --- a/packages/plugin-npm-cli/sources/commands/npm/login.ts +++ b/packages/plugin-npm-cli/sources/commands/npm/login.ts @@ -140,10 +140,10 @@ async function getCredentials({configuration, registry, report, stdin, stdout}: report.reportSeparator(); - if (process.env.YARN_IS_TEST_ENV) { + if (configuration.env.YARN_IS_TEST_ENV) { return { - name: process.env.YARN_INJECT_NPM_USER || ``, - password: process.env.YARN_INJECT_NPM_PASSWORD || ``, + name: configuration.env.YARN_INJECT_NPM_USER || ``, + password: configuration.env.YARN_INJECT_NPM_PASSWORD || ``, }; } diff --git a/packages/plugin-workspace-tools/sources/commands/foreach.ts b/packages/plugin-workspace-tools/sources/commands/foreach.ts index b051f9c127ea..1737cb93102d 100644 --- a/packages/plugin-workspace-tools/sources/commands/foreach.ts +++ b/packages/plugin-workspace-tools/sources/commands/foreach.ts @@ -178,7 +178,7 @@ export default class WorkspacesForeachCommand extends BaseCommand { // Prevents infinite loop in the case of configuring a script as such: // "lint": "yarn workspaces foreach --all lint" - if (scriptName === process.env.npm_lifecycle_event && workspace.cwd === cwdWorkspace!.cwd) + if (scriptName === configuration.env.npm_lifecycle_event && workspace.cwd === cwdWorkspace!.cwd) continue; if (this.include.length > 0 && !micromatch.isMatch(structUtils.stringifyIdent(workspace.locator), this.include) && !micromatch.isMatch(workspace.relativeCwd, this.include)) diff --git a/packages/yarnpkg-core/package.json b/packages/yarnpkg-core/package.json index dec11e8e5ec0..53bac6de87c6 100644 --- a/packages/yarnpkg-core/package.json +++ b/packages/yarnpkg-core/package.json @@ -23,6 +23,7 @@ "clipanion": "^3.2.1", "cross-spawn": "7.0.3", "diff": "^5.1.0", + "dotenv": "^16.3.1", "globby": "^11.0.1", "got": "^11.7.0", "lodash": "^4.17.15", diff --git a/packages/yarnpkg-core/sources/Configuration.ts b/packages/yarnpkg-core/sources/Configuration.ts index 09009257943d..57f0ff75ee41 100644 --- a/packages/yarnpkg-core/sources/Configuration.ts +++ b/packages/yarnpkg-core/sources/Configuration.ts @@ -4,6 +4,7 @@ import {parseSyml, stringifySyml} import camelcase from 'camelcase'; import {isCI, isPR, GITHUB_ACTIONS} from 'ci-info'; import {UsageError} from 'clipanion'; +import {parse as parseDotEnv} from 'dotenv'; import pLimit, {Limit} from 'p-limit'; import {PassThrough, Writable} from 'stream'; @@ -530,6 +531,14 @@ export const coreDefinitions: {[coreSettingName: string]: SettingsDefinition} = default: `throw`, }, + // Miscellaneous settings + injectEnvironmentFiles: { + description: `List of all the environment files that Yarn should inject inside the process when it starts`, + type: SettingsType.ABSOLUTE_PATH, + default: [`.env?`], + isArray: true, + }, + // Package patching - to fix incorrect definitions packageExtensions: { description: `Map of package corrections to apply on the dependency tree`, @@ -641,6 +650,9 @@ export interface ConfigurationValueMap { enableImmutableCache: boolean; checksumBehavior: string; + // Miscellaneous settings + injectEnvironmentFiles: Array; + // Package patching - to fix incorrect definitions packageExtensions: Map; @@ -842,7 +854,9 @@ function getDefaultValue(configuration: Configuration, definition: SettingsDefin return null; if (configuration.projectCwd === null) { - if (ppath.isAbsolute(definition.default)) { + if (Array.isArray(definition.default)) { + return definition.default.map((entry: string) => ppath.normalize(entry as PortablePath)); + } else if (ppath.isAbsolute(definition.default)) { return ppath.normalize(definition.default); } else if (definition.isNullable) { return null; @@ -967,6 +981,7 @@ export class Configuration { public invalid: Map = new Map(); + public env: Record = {}; public packageExtensions: Map]>> = new Map(); public limits: Map = new Map(); @@ -1053,8 +1068,8 @@ export class Configuration { const allCoreFieldKeys = new Set(Object.keys(coreDefinitions)); - const pickPrimaryCoreFields = ({ignoreCwd, yarnPath, ignorePath, lockfileFilename}: CoreFields) => ({ignoreCwd, yarnPath, ignorePath, lockfileFilename}); - const pickSecondaryCoreFields = ({ignoreCwd, yarnPath, ignorePath, lockfileFilename, ...rest}: CoreFields) => { + const pickPrimaryCoreFields = ({ignoreCwd, yarnPath, ignorePath, lockfileFilename, injectEnvironmentFiles}: CoreFields) => ({ignoreCwd, yarnPath, ignorePath, lockfileFilename, injectEnvironmentFiles}); + const pickSecondaryCoreFields = ({ignoreCwd, yarnPath, ignorePath, lockfileFilename, injectEnvironmentFiles, ...rest}: CoreFields) => { const secondaryCoreFields: CoreFields = {}; for (const [key, value] of Object.entries(rest)) if (allCoreFieldKeys.has(key)) @@ -1121,6 +1136,22 @@ export class Configuration { configuration.startingCwd = startingCwd; configuration.projectCwd = projectCwd; + const env = Object.assign(Object.create(null), process.env); + configuration.env = env; + + // load the environment files + const environmentFiles = await Promise.all(configuration.get(`injectEnvironmentFiles`).map(async p => { + const content = p.endsWith(`?`) + ? await xfs.readFilePromise(p.slice(0, -1) as PortablePath, `utf8`).catch(() => ``) + : await xfs.readFilePromise(p as PortablePath, `utf8`); + + return parseDotEnv(content); + })); + + for (const environmentEntries of environmentFiles) + for (const [key, value] of Object.entries(environmentEntries)) + configuration.env[key] = miscUtils.replaceEnvVariables(value, {env}); + // load all fields of the core definitions configuration.importSettings(pickSecondaryCoreFields(coreDefinitions)); configuration.useWithSource(``, pickSecondaryCoreFields(environmentSettings), startingCwd, {strict}); diff --git a/packages/yarnpkg-core/sources/scriptUtils.ts b/packages/yarnpkg-core/sources/scriptUtils.ts index 9c447ce81c56..bfccbf381464 100644 --- a/packages/yarnpkg-core/sources/scriptUtils.ts +++ b/packages/yarnpkg-core/sources/scriptUtils.ts @@ -107,9 +107,11 @@ export async function detectPackageManager(location: PortablePath): Promise}) { const scriptEnv: {[key: string]: string} = {}; - for (const [key, value] of Object.entries(process.env)) + + // Ensure that the PATH environment variable is properly capitalized (Windows) + for (const [key, value] of Object.entries(baseEnv)) if (typeof value !== `undefined`) scriptEnv[key.toLowerCase() !== `path` ? key : `PATH`] = value; diff --git a/packages/yarnpkg-fslib/sources/path.ts b/packages/yarnpkg-fslib/sources/path.ts index f566e70ebd36..4de18dd4c55d 100644 --- a/packages/yarnpkg-fslib/sources/path.ts +++ b/packages/yarnpkg-fslib/sources/path.ts @@ -32,6 +32,7 @@ export const Filename = { pnpData: `.pnp.data.json` as Filename, pnpEsmLoader: `.pnp.loader.mjs` as Filename, rc: `.yarnrc.yml` as Filename, + env: `.env` as Filename, }; export type TolerateLiterals = { diff --git a/yarn.lock b/yarn.lock index b3bd0e15cf53..b4cddcc46674 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7081,6 +7081,7 @@ __metadata: comment-json: "npm:^2.2.0" cross-spawn: "npm:7.0.3" diff: "npm:^5.1.0" + dotenv: "npm:^16.3.1" esbuild: "npm:esbuild-wasm@^0.15.15" globby: "npm:^11.0.1" got: "npm:^11.7.0" @@ -12537,6 +12538,13 @@ __metadata: languageName: node linkType: hard +"dotenv@npm:^16.3.1": + version: 16.3.1 + resolution: "dotenv@npm:16.3.1" + checksum: dbb778237ef8750e9e3cd1473d3c8eaa9cc3600e33a75c0e36415d0fa0848197f56c3800f77924c70e7828f0b03896818cd52f785b07b9ad4d88dba73fbba83f + languageName: node + linkType: hard + "dotenv@npm:^8.2.0": version: 8.2.0 resolution: "dotenv@npm:8.2.0"