Skip to content

Commit

Permalink
.
Browse files Browse the repository at this point in the history
  • Loading branch information
exKAZUu committed Oct 30, 2023
1 parent a0005e9 commit 43d0d3d
Show file tree
Hide file tree
Showing 26 changed files with 398 additions and 382 deletions.
101 changes: 57 additions & 44 deletions packages/shared-lib-node/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
import fs from 'node:fs';
import path from 'node:path';

import { config } from 'dotenv';

interface Options {
env: (string | number)[];
cascadeEnv: string;
cascadeNodeEnv: boolean;
autoCascadeEnv: boolean;
checkEnv: string;
verbose: boolean;
}
import type { DotenvPopulateInput } from 'dotenv';
import { parse, populate } from 'dotenv';
import type { ArgumentsCamelCase, InferredOptionTypes } from 'yargs';

export const yargsOptionsBuilderForEnv = {
env: {
Expand All @@ -30,17 +24,29 @@ export const yargsOptionsBuilderForEnv = {
type: 'boolean',
default: true,
},
'include-root-env': {
description: 'Include .env files in root directory if the project is in a monorepo and --env option is not used.',
type: 'boolean',
default: true,
},
'check-env': {
description: 'Check whether the keys of the loaded .env files are same with the given .env file.',
type: 'string',
default: '.env.example',
},
verbose: {
description: 'Whether to show verbose information',
type: 'boolean',
alias: 'v',
},
} as const;

export type EnvReaderOptions = Partial<ArgumentsCamelCase<InferredOptionTypes<typeof yargsOptionsBuilderForEnv>>>;

/**
* This function loads environment variables from `.env` files.
* This function reads environment variables from `.env` files. Note it does not assign them in `process.env`.
* */
export function loadEnvironmentVariables(argv: Partial<Options>, cwd: string): Record<string, string> {
export function readEnvironmentVariables(argv: EnvReaderOptions, cwd: string, cache = true): Record<string, string> {
let envPaths = (argv.env ?? []).map((envPath) => path.resolve(cwd, envPath.toString()));
const cascade =
argv.cascadeEnv ??
Expand All @@ -50,7 +56,15 @@ export function loadEnvironmentVariables(argv: Partial<Options>, cwd: string): R
? process.env.WB_ENV || process.env.NODE_ENV || 'development'
: undefined);
if (typeof cascade === 'string') {
if (envPaths.length === 0) envPaths.push(path.join(cwd, '.env'));
if (envPaths.length === 0) {
envPaths.push(path.join(cwd, '.env'));
if (argv.includeRootEnv) {
const rootPath = path.resolve(cwd, '..', '..');
if (fs.existsSync(path.join(rootPath, 'package.json'))) {
envPaths.push(path.join(rootPath, '.env'));
}
}
}
envPaths = envPaths.flatMap((envPath) =>
cascade
? [`${envPath}.${cascade}.local`, `${envPath}.local`, `${envPath}.${cascade}`, envPath]
Expand All @@ -60,13 +74,13 @@ export function loadEnvironmentVariables(argv: Partial<Options>, cwd: string): R
envPaths = envPaths.map((envPath) => path.relative(cwd, envPath));
if (argv.verbose) {
console.info(`WB_ENV: ${process.env.WB_ENV}, NODE_ENV: ${process.env.NODE_ENV}`);
console.info('Loading env files:', envPaths);
console.info('Reading env files:', envPaths);
}

let envVars: Record<string, string> = {};
const orgEnvVars = { ...process.env };
for (const envPath of envPaths) {
envVars = { ...config({ path: path.join(cwd, envPath) }).parsed, ...envVars };
envVars = { ...readEnvFile(path.join(cwd, envPath), cache), ...envVars };
let count = 0;
for (const [key, value] of Object.entries(envVars)) {
if (orgEnvVars[key] !== value) {
Expand All @@ -75,12 +89,12 @@ export function loadEnvironmentVariables(argv: Partial<Options>, cwd: string): R
}
}
if (count > 0) {
console.info(`Loaded ${count} environment variables:`, envPath);
console.info(`Read ${count} environment variables:`, envPath);
}
}

if (argv.checkEnv) {
const exampleKeys = Object.keys(config({ path: path.join(cwd, argv.checkEnv) }).parsed || {});
const exampleKeys = Object.keys(readEnvFile(path.join(cwd, argv.checkEnv), cache) || {});
const missingKeys = exampleKeys.filter((key) => !(key in envVars));
if (missingKeys.length > 0) {
throw new Error(`Missing environment variables in [${envPaths.join(', ')}]: [${missingKeys.join(', ')}]`);
Expand All @@ -89,6 +103,32 @@ export function loadEnvironmentVariables(argv: Partial<Options>, cwd: string): R
return envVars;
}

/**
* This function read environment variables from `.env` files and assign them in `process.env`.
* */
export function readAndApplyEnvironmentVariables(
argv: EnvReaderOptions,
cwd: string,
cache = true
): Record<string, string | undefined> {
const envVars = readEnvironmentVariables(argv, cwd, cache);
populate(process.env as DotenvPopulateInput, envVars);
return process.env;
}

const cachedEnvVars = new Map<string, Record<string, string>>();

function readEnvFile(filePath: string, cache = true): Record<string, string> {
const cached = cache && cachedEnvVars.get(filePath);
if (cached) return cached;

const parsed = parse(filePath);
if (cache) {
cachedEnvVars.set(filePath, parsed);
}
return parsed;
}

/**
* This function removes environment variables related to npm and yarn from the given environment variables.
* */
Expand All @@ -115,30 +155,3 @@ export function removeNpmAndYarnEnvironmentVariables(envVars: Record<string, str
}
}
}

const savedEnvVars: Map<string, string | undefined> = new Map();

/**
* This function saves the current state of environment variables.
* It can be used to restore the environment variables to this state later.
*/
export function saveEnvironmentVariables(): void {
savedEnvVars.clear();
for (const [key, value] of Object.entries(process.env)) {
savedEnvVars.set(key, value);
}
}

/**
* Restores the environment variables to the state saved by `saveEnvironmentVariables`.
* If a variable was not saved, it will be deleted.
*/
export function restoreEnvironmentVariables(): void {
for (const [key, value] of savedEnvVars.entries()) {
if (savedEnvVars.has(key)) {
process.env[key] = value;
} else {
delete process.env[key];
}
}
}
6 changes: 3 additions & 3 deletions packages/shared-lib-node/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
export {
loadEnvironmentVariables,
readEnvironmentVariables,
readAndApplyEnvironmentVariables,
removeNpmAndYarnEnvironmentVariables,
saveEnvironmentVariables,
restoreEnvironmentVariables,
yargsOptionsBuilderForEnv,
EnvReaderOptions,
} from './env.js';
export { existsAsync } from './exists.js';
export { calculateHashFromFiles, canSkipSeed, updateHashFromFiles } from './hash.js';
Expand Down
19 changes: 11 additions & 8 deletions packages/shared-lib-node/tests/env.test.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,47 @@
import { beforeEach, describe, expect, it } from 'vitest';

import { loadEnvironmentVariables } from '../src/env.js';
import { readAndApplyEnvironmentVariables } from '../src/env.js';

describe('loadEnvironmentVariables()', () => {
describe('readAndApplyEnvironmentVariables()', () => {
beforeEach(() => {
process.env.WB_ENV = '';
process.env.NODE_ENV = '';
});

it('should load no env vars with empty options', () => {
const envVars = loadEnvironmentVariables({}, 'test-fixtures/app1');
const envVars = readAndApplyEnvironmentVariables({}, 'test-fixtures/app1');
expect(envVars).toEqual({});
});

it('should load env vars with --auto-cascade-env', () => {
const envVars = loadEnvironmentVariables({ autoCascadeEnv: true }, 'test-fixtures/app1');
const envVars = readAndApplyEnvironmentVariables({ autoCascadeEnv: true }, 'test-fixtures/app1');
expect(envVars).toEqual({ NAME: 'app1', ENV: 'development1' });
});

it('should load env vars with --cascade-env=production', () => {
const envVars = loadEnvironmentVariables({ cascadeEnv: 'production', env: ['.env'] }, 'test-fixtures/app1');
const envVars = readAndApplyEnvironmentVariables({ cascadeEnv: 'production', env: ['.env'] }, 'test-fixtures/app1');
expect(envVars).toEqual({ NAME: 'app1', ENV: 'production1' });
});

it('should load env vars with --cascade-node-env and NODE_ENV=""', () => {
process.env.NODE_ENV = '';
const envVars = loadEnvironmentVariables({ cascadeNodeEnv: true, env: ['.env'] }, 'test-fixtures/app1');
const envVars = readAndApplyEnvironmentVariables({ cascadeNodeEnv: true, env: ['.env'] }, 'test-fixtures/app1');
expect(envVars).toEqual({ NAME: 'app1', ENV: 'development1' });
});

it('should load env vars with --cascade-node-env and NODE_ENV=test', () => {
process.env.NODE_ENV = 'test';
const envVars = loadEnvironmentVariables({ cascadeNodeEnv: true, env: ['.env'] }, 'test-fixtures/app1');
const envVars = readAndApplyEnvironmentVariables({ cascadeNodeEnv: true, env: ['.env'] }, 'test-fixtures/app1');
expect(envVars).toEqual({ NAME: 'app1', ENV: 'test1' });
});

it('should load env vars with --env=test-fixtures/app2/.env --auto-cascade-env, WB_ENV=test and NODE_ENV=production', () => {
process.env.WB_ENV = 'test';
process.env.NODE_ENV = 'production';
const envVars = loadEnvironmentVariables({ autoCascadeEnv: true, env: ['../app2/.env'] }, 'test-fixtures/app1');
const envVars = readAndApplyEnvironmentVariables(
{ autoCascadeEnv: true, env: ['../app2/.env'] },
'test-fixtures/app1'
);
expect(envVars).toEqual({ NAME: 'app2', ENV: 'test2' });
});
});
2 changes: 1 addition & 1 deletion packages/wb/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
},
"prettier": "@willbooster/prettier-config",
"dependencies": {
"at-decorators": "1.2.0",
"chalk": "5.3.0",
"dotenv": "16.3.1",
"kill-port": "2.0.1",
Expand All @@ -40,6 +39,7 @@
"@typescript-eslint/parser": "6.9.0",
"@willbooster/eslint-config-ts": "10.5.1",
"@willbooster/prettier-config": "9.1.2",
"at-decorators": "1.2.0",
"build-ts": "11.0.8",
"eslint": "8.52.0",
"eslint-config-prettier": "9.0.0",
Expand Down
12 changes: 6 additions & 6 deletions packages/wb/src/commands/buildIfNeeded.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,16 @@ export const buildIfNeededCommand: CommandModule<unknown, InferredOptionTypes<ty

export async function buildIfNeeded(
// Test code requires Partial<...>
argv: Partial<ArgumentsCamelCase<InferredOptionTypes<typeof builder & typeof sharedOptionsBuilder>>>
argv: Partial<ArgumentsCamelCase<InferredOptionTypes<typeof builder & typeof sharedOptionsBuilder>>>,
dirPath?: string
): Promise<boolean | undefined> {
const projects = await findAllProjects();
const projects = await findAllProjects(argv, dirPath);
if (!projects) return true;

const isGitRepo = fs.existsSync(path.join(projects.root.dirPath, '.git'));

let built = false;

for (const project of prepareForRunningCommand('buildIfNeeded', projects.root, projects.all, argv)) {
for (const project of prepareForRunningCommand('buildIfNeeded', projects.all)) {
if (!isGitRepo) {
if (!build(project, argv)) return;
built = true;
Expand Down Expand Up @@ -98,7 +98,7 @@ export async function canSkipBuild(
hash.update(commitHash);

const environmentJson = JSON.stringify(
Object.entries(process.env)
Object.entries(project.env)
.filter(([key]) => !ignoringEnvVarNames.has(key))
.sort(([key1], [key2]) => key1.localeCompare(key2))
);
Expand Down Expand Up @@ -142,7 +142,7 @@ async function updateHashWithDiffResult(
.trim()
.split('\n')
.map((filePath) =>
process.env.WB_ENV === 'test' ? filePath.replace(/packages\/scripts\/test-fixtures\/[^/]+\//, '') : filePath
project.env.WB_ENV === 'test' ? filePath.replace(/packages\/scripts\/test-fixtures\/[^/]+\//, '') : filePath
);
const filteredFilePaths = filePaths.filter(
(filePath) =>
Expand Down
22 changes: 1 addition & 21 deletions packages/wb/src/commands/commandUtils.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,9 @@
import { loadEnvironmentVariables, restoreEnvironmentVariables } from '@willbooster/shared-lib-node/src';
import type { ArgumentsCamelCase, InferredOptionTypes } from 'yargs';

import type { Project } from '../project.js';
import type { sharedOptionsBuilder } from '../sharedOptionsBuilder.js';

export function* prepareForRunningCommand(
commandName: string,
rootProject: Project,
projects: Project[],
argv: Partial<ArgumentsCamelCase<InferredOptionTypes<typeof sharedOptionsBuilder>>>,
loadingEnvironmentVariables = true
): Generator<Project, void, unknown> {
export function* prepareForRunningCommand(commandName: string, projects: Project[]): Generator<Project, void, unknown> {
for (const project of projects) {
console.info(`Running "${commandName}" for ${project.name} ...`);

if (project !== rootProject) {
restoreEnvironmentVariables();
}
if (loadingEnvironmentVariables) {
loadEnvironmentVariables(argv, project.dirPath);
if (project !== rootProject) {
loadEnvironmentVariables(argv, rootProject.dirPath);
}
}

yield project;
}
}
4 changes: 2 additions & 2 deletions packages/wb/src/commands/optimizeForDockerBuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ export const optimizeForDockerBuildCommand: CommandModule<unknown, InferredOptio
describe: 'Optimize configuration when building a Docker image',
builder,
async handler(argv) {
const projects = await findAllProjects();
const projects = await findAllProjects(argv);
if (!projects) return;

const opts = {
stdio: 'inherit',
} as const;

for (const project of prepareForRunningCommand('optimizeForDockerBuild', projects.root, projects.all, argv)) {
for (const project of prepareForRunningCommand('optimizeForDockerBuild', projects.all)) {
const packageJson: PackageJson = project.packageJson;
const keys = ['dependencies', 'devDependencies'] as const;
for (const key of keys) {
Expand Down
Loading

0 comments on commit 43d0d3d

Please sign in to comment.