Skip to content

Commit

Permalink
feat!: support monorepo (#190)
Browse files Browse the repository at this point in the history
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
  • Loading branch information
exKAZUu and renovate[bot] authored Oct 31, 2023
1 parent 7ad881b commit 039f0aa
Show file tree
Hide file tree
Showing 36 changed files with 1,190 additions and 1,023 deletions.
2 changes: 1 addition & 1 deletion packages/shared-lib-blitz-next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"@willbooster/eslint-config-ts": "10.5.1",
"@willbooster/prettier-config": "9.1.2",
"blitz": "2.0.0-beta.34",
"build-ts": "11.0.6",
"build-ts": "11.0.9",
"eslint": "8.52.0",
"eslint-config-prettier": "9.0.0",
"eslint-import-resolver-typescript": "3.6.1",
Expand Down
2 changes: 1 addition & 1 deletion packages/shared-lib-node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"@typescript-eslint/parser": "6.9.0",
"@willbooster/eslint-config-ts": "10.5.1",
"@willbooster/prettier-config": "9.1.2",
"build-ts": "11.0.6",
"build-ts": "11.0.9",
"eslint": "8.52.0",
"eslint-config-prettier": "9.0.0",
"eslint-import-resolver-typescript": "3.6.1",
Expand Down
77 changes: 60 additions & 17 deletions packages/shared-lib-node/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,8 @@
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 { ArgumentsCamelCase, InferredOptionTypes } from 'yargs';

export const yargsOptionsBuilderForEnv = {
env: {
Expand All @@ -30,18 +23,34 @@ 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: Options, cwd: string, orgCwd?: string): Record<string, string> {
let envPaths = (argv.env ?? []).map((envPath) => path.resolve(orgCwd ?? cwd, envPath.toString()));
export function readEnvironmentVariables(
argv: EnvReaderOptions,
cwd: string,
cacheEnabled = true
): Record<string, string> {
let envPaths = (argv.env ?? []).map((envPath) => path.resolve(cwd, envPath.toString()));
const cascade =
argv.cascadeEnv ??
(argv.cascadeNodeEnv
Expand All @@ -50,7 +59,15 @@ export function loadEnvironmentVariables(argv: Options, cwd: string, orgCwd?: st
? 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 +77,13 @@ export function loadEnvironmentVariables(argv: Options, cwd: string, orgCwd?: st
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), cacheEnabled), ...envVars };
let count = 0;
for (const [key, value] of Object.entries(envVars)) {
if (orgEnvVars[key] !== value) {
Expand All @@ -75,12 +92,12 @@ export function loadEnvironmentVariables(argv: Options, cwd: string, orgCwd?: st
}
}
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), cacheEnabled) || {});
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 +106,32 @@ export function loadEnvironmentVariables(argv: Options, cwd: string, orgCwd?: st
return envVars;
}

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

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

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

const parsed = config({ path: path.resolve(filePath), processEnv: {} }).parsed ?? {};
if (cacheEnabled) {
cachedEnvVars.set(filePath, parsed);
}
return parsed;
}

/**
* This function removes environment variables related to npm and yarn from the given environment variables.
* */
Expand Down
8 changes: 7 additions & 1 deletion packages/shared-lib-node/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
export { loadEnvironmentVariables, removeNpmAndYarnEnvironmentVariables, yargsOptionsBuilderForEnv } from './env.js';
export {
readEnvironmentVariables,
readAndApplyEnvironmentVariables,
removeNpmAndYarnEnvironmentVariables,
yargsOptionsBuilderForEnv,
} from './env.js';
export type { EnvReaderOptions } from './env.js';
export { existsAsync } from './exists.js';
export { calculateHashFromFiles, canSkipSeed, updateHashFromFiles } from './hash.js';
export { spawnAsync } from './spawn.js';
21 changes: 10 additions & 11 deletions packages/shared-lib-node/tests/env.test.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,46 @@
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: ['.env'] },
'test-fixtures/app1',
'test-fixtures/app2'
const envVars = readAndApplyEnvironmentVariables(
{ autoCascadeEnv: true, env: ['../app2/.env'] },
'test-fixtures/app1'
);
expect(envVars).toEqual({ NAME: 'app2', ENV: 'test2' });
});
Expand Down
14 changes: 7 additions & 7 deletions packages/shared-lib-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,14 @@
"devDependencies": {
"@babel/core": "7.23.2",
"@mdx-js/react": "3.0.0",
"@storybook/addon-actions": "7.5.1",
"@storybook/addon-docs": "7.5.1",
"@storybook/addon-essentials": "7.5.1",
"@storybook/addon-interactions": "7.5.1",
"@storybook/addon-links": "7.5.1",
"@storybook/addon-actions": "7.5.2",
"@storybook/addon-docs": "7.5.2",
"@storybook/addon-essentials": "7.5.2",
"@storybook/addon-interactions": "7.5.2",
"@storybook/addon-links": "7.5.2",
"@storybook/builder-webpack4": "6.5.16",
"@storybook/manager-webpack4": "6.5.16",
"@storybook/react": "7.5.1",
"@storybook/react": "7.5.2",
"@storybook/testing-library": "0.2.2",
"@types/eslint": "8.44.6",
"@types/micromatch": "4.0.4",
Expand All @@ -53,7 +53,7 @@
"@willbooster/eslint-config-ts-react": "10.1.9",
"@willbooster/prettier-config": "9.1.2",
"babel-loader": "9.1.3",
"build-ts": "11.0.6",
"build-ts": "11.0.9",
"eslint": "8.52.0",
"eslint-config-prettier": "9.0.0",
"eslint-import-resolver-typescript": "3.6.1",
Expand Down
2 changes: 1 addition & 1 deletion packages/shared-lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"@typescript-eslint/parser": "6.9.0",
"@willbooster/eslint-config-ts": "10.5.1",
"@willbooster/prettier-config": "9.1.2",
"build-ts": "11.0.6",
"build-ts": "11.0.9",
"eslint": "8.52.0",
"eslint-config-prettier": "9.0.0",
"eslint-import-resolver-typescript": "3.6.1",
Expand Down
3 changes: 2 additions & 1 deletion packages/wb/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
"@typescript-eslint/parser": "6.9.0",
"@willbooster/eslint-config-ts": "10.5.1",
"@willbooster/prettier-config": "9.1.2",
"build-ts": "11.0.6",
"at-decorators": "1.2.2",
"build-ts": "11.0.9",
"eslint": "8.52.0",
"eslint-config-prettier": "9.0.0",
"eslint-import-resolver-typescript": "3.6.1",
Expand Down
56 changes: 32 additions & 24 deletions packages/wb/src/commands/buildIfNeeded.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import { ignoreEnoentAsync } from '@willbooster/shared-lib/src';
import chalk from 'chalk';
import type { ArgumentsCamelCase, CommandModule, InferredOptionTypes } from 'yargs';

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

const builder = {
command: {
Expand All @@ -28,48 +30,53 @@ export const buildIfNeededCommand: CommandModule<unknown, InferredOptionTypes<ty
},
};

function build(project: Project, argv: Partial<ArgumentsCamelCase<InferredOptionTypes<typeof builder>>>): boolean {
console.info(chalk.green(`Run '${argv.command}'`));
if (!argv.dryRun) {
const ret = child_process.spawnSync(argv.command ?? '', {
cwd: project.dirPath,
shell: true,
stdio: 'inherit',
});
if (ret.status !== 0) {
process.exitCode = ret.status ?? 1;
return false;
}
}
return true;
}

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

if (!fs.existsSync(path.join(project.rootDirPath, '.git'))) {
build(argv);
build(project, argv);
return true;
}

const [canSkip, cacheFilePath, contentHash] = await canSkipBuild(argv);
const [canSkip, cacheFilePath, contentHash] = await canSkipBuild(project, argv);
if (canSkip) {
console.info(chalk.green(`Skip to run '${argv.command}' 💫`));
return false;
}

if (!build(argv)) return;
if (!build(project, argv)) return;

if (!argv.dryRun) {
await fs.promises.writeFile(cacheFilePath, contentHash, 'utf8');
}
return true;
}

function build(argv: Partial<ArgumentsCamelCase<InferredOptionTypes<typeof builder>>>): boolean {
console.info(chalk.green(`Run '${argv.command}'`));
if (!argv.dryRun) {
const ret = child_process.spawnSync(argv.command ?? '', {
cwd: project.dirPath,
shell: true,
stdio: 'inherit',
});
if (ret.status !== 0) {
process.exitCode = ret.status ?? 1;
return false;
}
}
return true;
}

const ignoringEnvVarNames = new Set(['CI', 'PWDEBUG', 'TMPDIR']);

export async function canSkipBuild(
project: Project,
argv: Partial<ArgumentsCamelCase<InferredOptionTypes<typeof builder>>>
): Promise<[boolean, string, string]> {
const cacheDirectoryPath = path.resolve(project.dirPath, 'node_modules', '.cache', 'build');
Expand All @@ -82,13 +89,13 @@ 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))
);
hash.update(environmentJson);

await updateHashWithDiffResult(argv, hash);
await updateHashWithDiffResult(project, argv, hash);
const contentHash = hash.digest('hex');

const cachedContentHash = await ignoreEnoentAsync(() => fs.promises.readFile(cacheFilePath, 'utf8'));
Expand All @@ -112,6 +119,7 @@ const includeSuffix = [
const excludePatterns = ['test/', 'tests/', '__tests__/', 'test-fixtures/', 'package.json'];

async function updateHashWithDiffResult(
project: Project,
argv: Partial<ArgumentsCamelCase<InferredOptionTypes<typeof builder>>>,
hash: Hash
): Promise<void> {
Expand All @@ -125,7 +133,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
Loading

0 comments on commit 039f0aa

Please sign in to comment.