diff --git a/.changeset/icy-ravens-draw.md b/.changeset/icy-ravens-draw.md new file mode 100644 index 000000000000..322ad57043c4 --- /dev/null +++ b/.changeset/icy-ravens-draw.md @@ -0,0 +1,9 @@ +--- +'@modern-js/app-tools': patch +'@modern-js/plugin': patch +'@modern-js/utils': patch +'@modern-js/server-core': patch +--- + +feat: add --env-dir support across dev/build/serve +feat: 给 dev/build/serve 支持 --env-dir diff --git a/packages/document/docs/en/apis/app/commands.mdx b/packages/document/docs/en/apis/app/commands.mdx index 2997db7fc4d8..7339d7ae0696 100644 --- a/packages/document/docs/en/apis/app/commands.mdx +++ b/packages/document/docs/en/apis/app/commands.mdx @@ -18,6 +18,7 @@ Usage: modern dev [options] Options: -e --entry compiler by entry -c --config specify the configuration file, which can be a relative or absolute path + --env-dir specify the directory containing .env files -h, --help show command help --web-only only start web service --api-only only start API service @@ -76,10 +77,29 @@ Usage: modern build [options] Options: -c --config specify the configuration file, which can be a relative or absolute path + --env-dir specify the directory containing .env files -h, --help show command help -w --watch turn on watch mode, watch for changes and rebuild ``` +### Specify Environment Directory + +You can use `--env-dir` to load `.env` files from a custom directory instead of the project root. + +```bash +# Load from /env/.env and .env.development +modern dev --env-dir ./env + +# Load from /env/.env and .env.production +modern build --env-dir ./env +modern serve --env-dir ./env +modern deploy --env-dir ./env +``` + +By default, Modern.js still loads `.env*` from the project root when this option is not provided. + +The path of `--env-dir` is resolved relative to the project root. + ## modern new The `modern new` command is used to enable features in an existing project. @@ -133,6 +153,8 @@ import ServeCommand from '@site-docs-en/components/serve-command'; +`modern serve` also supports `--env-dir ` to load `.env*` from a custom directory. + ## modern upgrade Execute the command `npx modern upgrade` in the project, by default, dependencies in the `package.json` are updated to the latest version. diff --git a/packages/document/docs/en/components/deploy-command.mdx b/packages/document/docs/en/components/deploy-command.mdx index 69dbaf6a6d06..1bd19553273b 100644 --- a/packages/document/docs/en/components/deploy-command.mdx +++ b/packages/document/docs/en/components/deploy-command.mdx @@ -7,6 +7,7 @@ Usage: modern deploy [options] Options: -c --config Specify configuration file path, either relative or absolute + --env-dir Specify the directory containing .env files -s --skip-build Skip the build stage -h, --help Display command help ``` diff --git a/packages/document/docs/zh/apis/app/commands.mdx b/packages/document/docs/zh/apis/app/commands.mdx index 07c22943adcb..186815a4cfe0 100644 --- a/packages/document/docs/zh/apis/app/commands.mdx +++ b/packages/document/docs/zh/apis/app/commands.mdx @@ -18,6 +18,7 @@ Usage: modern dev [options] Options: -e --entry 指定入口,只编译特定的页面 -c --config 指定配置文件路径,可以为相对路径或绝对路径 + --env-dir 指定 .env 文件所在目录 -h, --help 显示命令帮助 --web-only 仅启动 Web 服务 --api-only 仅启动 API 接口服务 @@ -76,10 +77,29 @@ Usage: modern build [options] Options: -c --config 指定配置文件路径,可以为相对路径或绝对路径 + --env-dir 指定 .env 文件所在目录 -h, --help 显示命令帮助 -w --watch 开启 watch 模式, 监听文件变更并重新构建 ``` +### 指定环境变量目录 + +你可以通过 `--env-dir` 指定 `.env` 文件目录,而不是默认从项目根目录读取。 + +```bash +# 从 /env/.env 和 .env.development 加载 +modern dev --env-dir ./env + +# 从 /env/.env 和 .env.production 加载 +modern build --env-dir ./env +modern serve --env-dir ./env +modern deploy --env-dir ./env +``` + +当未传入该参数时,Modern.js 仍会默认从项目根目录加载 `.env*`。 + +`--env-dir` 的路径相对于项目根目录解析。 + ## modern new `modern new` 命令用于在已有项目中添加项目元素。 @@ -133,6 +153,8 @@ import ServeCommand from '@site-docs/components/serve-command'; +`modern serve` 同样支持 `--env-dir `,用于从指定目录加载 `.env*` 文件。 + ## modern upgrade 在项目根目录下执行命令 `npx modern upgrade`,会默认将当前执行命令项目的 `package.json` 中的 Modern.js 相关依赖更新至最新版本。 diff --git a/packages/document/docs/zh/components/deploy-command.mdx b/packages/document/docs/zh/components/deploy-command.mdx index 2217ae352ed3..3b6ef43548a1 100644 --- a/packages/document/docs/zh/components/deploy-command.mdx +++ b/packages/document/docs/zh/components/deploy-command.mdx @@ -7,6 +7,7 @@ Usage: modern deploy [options] Options: -c --config 指定配置文件路径,可以为相对路径或绝对路径 + --env-dir 指定 .env 文件所在目录 -s --skip-build 跳过构建阶段 -h, --help 显示命令帮助 ``` diff --git a/packages/server/core/src/adapters/node/helper/loadEnv.ts b/packages/server/core/src/adapters/node/helper/loadEnv.ts index 80acf8142aed..190a71cd1b43 100644 --- a/packages/server/core/src/adapters/node/helper/loadEnv.ts +++ b/packages/server/core/src/adapters/node/helper/loadEnv.ts @@ -1,13 +1,19 @@ import path from 'path'; -import { fs, dotenv, dotenvExpand } from '@modern-js/utils'; +import { + fs, + dotenv, + dotenvExpand, + resolveInsideOrFallback, +} from '@modern-js/utils'; import type { ServerBaseOptions } from '../../../serverBase'; /** 读取 .env.{process.env.MODERN_ENV} 文件,加载环境变量 */ export async function loadServerEnv(options: ServerBaseOptions) { - const { pwd } = options; + const { pwd, envDir } = options; const serverEnv = process.env.MODERN_ENV; - const defaultEnvPath = path.resolve(pwd, `.env`); - const serverEnvPath = path.resolve(pwd, `.env.${serverEnv}`); + const envDirectory = resolveInsideOrFallback(pwd, envDir, pwd); + const defaultEnvPath = path.resolve(envDirectory, `.env`); + const serverEnvPath = path.resolve(envDirectory, `.env.${serverEnv}`); if ( (await fs.pathExists(defaultEnvPath)) && diff --git a/packages/server/core/tests/adapters/loadEnv.test.ts b/packages/server/core/tests/adapters/loadEnv.test.ts index cb62acb7dad1..0d96bc4923f0 100644 --- a/packages/server/core/tests/adapters/loadEnv.test.ts +++ b/packages/server/core/tests/adapters/loadEnv.test.ts @@ -3,6 +3,7 @@ import { loadServerEnv } from '../../src/adapters/node'; describe('test load serve env file', () => { const pwd = path.resolve(__dirname, '../fixtures', 'serverEnv'); + const envPwd = path.resolve(__dirname, '../fixtures', 'serverEnvDir'); it('should load env correctly', async () => { await loadServerEnv({ @@ -37,4 +38,32 @@ describe('test load serve env file', () => { delete process.env.USER_NAME; delete process.env.ENV; }); + + it('should load env from custom envDir', async () => { + process.env.MODERN_ENV = 'prod'; + await loadServerEnv({ + pwd: envPwd, + envDir: 'env', + } as any); + + expect(process.env.USER_NAME).toBe('dir_prod_root'); + expect(process.env.ENV).toBe('dir_prod'); + + delete process.env.USER_NAME; + delete process.env.ENV; + }); + + it('should fallback to pwd when envDir escapes root', async () => { + process.env.MODERN_ENV = 'prod'; + await loadServerEnv({ + pwd, + envDir: '../serverEnvDir/env', + } as any); + + expect(process.env.USER_NAME).toBe('prod_root'); + expect(process.env.ENV).toBe('prod'); + + delete process.env.USER_NAME; + delete process.env.ENV; + }); }); diff --git a/packages/server/core/tests/fixtures/serverEnvDir/env/.env b/packages/server/core/tests/fixtures/serverEnvDir/env/.env new file mode 100644 index 000000000000..bf8775d4eca5 --- /dev/null +++ b/packages/server/core/tests/fixtures/serverEnvDir/env/.env @@ -0,0 +1 @@ +USER_NAME=dir_root diff --git a/packages/server/core/tests/fixtures/serverEnvDir/env/.env.prod b/packages/server/core/tests/fixtures/serverEnvDir/env/.env.prod new file mode 100644 index 000000000000..a32e9fea9c8c --- /dev/null +++ b/packages/server/core/tests/fixtures/serverEnvDir/env/.env.prod @@ -0,0 +1,2 @@ +USER_NAME=dir_prod_root +ENV=dir_prod diff --git a/packages/solutions/app-tools/src/commands/build.ts b/packages/solutions/app-tools/src/commands/build.ts index b044332a6bee..6277fcb06bc5 100644 --- a/packages/solutions/app-tools/src/commands/build.ts +++ b/packages/solutions/app-tools/src/commands/build.ts @@ -1,6 +1,12 @@ import path from 'node:path'; import type { CLIPluginAPI } from '@modern-js/plugin'; -import { fs, type Alias, logger } from '@modern-js/utils'; +import { + fs, + type Alias, + isPathInside, + logger, + resolveInsideOrFallback, +} from '@modern-js/utils'; import type { ConfigChain } from '@rsbuild/core'; import type { AppTools } from '../types'; import { loadServerPlugins } from '../utils/loadPlugins'; @@ -8,12 +14,58 @@ import { setupTsRuntime } from '../utils/register'; import { generateRoutes } from '../utils/routes'; import type { BuildOptions } from '../utils/types'; +function getSafeDistTarget( + distDirectory: string, + envDir: string | undefined, + envFile: string, +): string { + if (!envDir) { + return path.resolve(distDirectory, envFile); + } + + const resolvedTargetPath = path.resolve(distDirectory, envDir, envFile); + if (!isPathInside(distDirectory, resolvedTargetPath)) { + logger.warn( + `The env directory ${envDir} is outside dist directory, fallback to dist root`, + ); + return path.resolve(distDirectory, envFile); + } + + return resolvedTargetPath; +} + async function copyEnvFiles( appDirectory: string, distDirectory: string, + envDir?: string, ): Promise { try { - const files = await fs.readdir(appDirectory); + const envDirectory = resolveInsideOrFallback( + appDirectory, + envDir, + appDirectory, + ); + if ( + envDir && + !isPathInside(appDirectory, path.resolve(appDirectory, envDir)) + ) { + logger.warn( + `The env directory ${envDir} is outside project root, fallback to project root`, + ); + } + + if (!(await fs.pathExists(envDirectory))) { + logger.debug(`Env directory does not exist: ${envDirectory}`); + return; + } + + const envDirectoryStat = await fs.stat(envDirectory); + if (!envDirectoryStat.isDirectory()) { + logger.debug(`Env path is not a directory: ${envDirectory}`); + return; + } + + const files = await fs.readdir(envDirectory); const envFileRegex = /^\.env(\.[a-zA-Z0-9_-]+)*$/; const envFiles = files.filter(file => envFileRegex.test(file)); @@ -24,8 +76,8 @@ async function copyEnvFiles( } const copyPromises = envFiles.map(async envFile => { - const sourcePath = path.resolve(appDirectory, envFile); - const targetPath = path.resolve(distDirectory, envFile); + const sourcePath = path.resolve(envDirectory, envFile); + const targetPath = getSafeDistTarget(distDirectory, envDir, envFile); try { const stat = await fs.stat(sourcePath); @@ -113,7 +165,11 @@ export const build = async ( ); } await appContext.builder.onAfterBuild(async () => { - return copyEnvFiles(appContext.appDirectory, appContext.distDirectory); + return copyEnvFiles( + appContext.appDirectory, + appContext.distDirectory, + options?.envDir, + ); }); await appContext.builder.build({ watch: options?.watch, diff --git a/packages/solutions/app-tools/src/commands/index.ts b/packages/solutions/app-tools/src/commands/index.ts index 91f4304faf77..c3750d6a1a63 100644 --- a/packages/solutions/app-tools/src/commands/index.ts +++ b/packages/solutions/app-tools/src/commands/index.ts @@ -8,6 +8,7 @@ import type { DevOptions, InfoOptions, InspectOptions, + StartOptions, } from '../utils/types'; export const devCommand = async ( @@ -20,6 +21,7 @@ export const devCommand = async ( .usage('[options]') .description(i18n.t(localeKeys.command.dev.describe)) .option('-c --config ', i18n.t(localeKeys.command.shared.config)) + .option('--env-dir ', i18n.t(localeKeys.command.shared.envDir)) .option('-e --entry [entry...]', i18n.t(localeKeys.command.dev.entry)) .option('--analyze', i18n.t(localeKeys.command.shared.analyze)) .option('--api-only', i18n.t(localeKeys.command.dev.apiOnly)) @@ -39,6 +41,7 @@ export const buildCommand = async ( .usage('[options]') .description(i18n.t(localeKeys.command.build.describe)) .option('-c --config ', i18n.t(localeKeys.command.shared.config)) + .option('--env-dir ', i18n.t(localeKeys.command.shared.envDir)) .option('--analyze', i18n.t(localeKeys.command.shared.analyze)) .option('-w --watch', i18n.t(localeKeys.command.build.watch)) .action(async (options: BuildOptions) => { @@ -57,9 +60,10 @@ export const serverCommand = ( .description(i18n.t(localeKeys.command.serve.describe)) .option('--api-only', i18n.t(localeKeys.command.dev.apiOnly)) .option('-c --config ', i18n.t(localeKeys.command.shared.config)) - .action(async () => { + .option('--env-dir ', i18n.t(localeKeys.command.shared.envDir)) + .action(async (options: StartOptions) => { const { serve } = await import('./serve.js'); - await serve(api); + await serve(api, options); }); }; @@ -71,12 +75,13 @@ export const deployCommand = ( .command('deploy') .usage('[options]') .option('-c --config ', i18n.t(localeKeys.command.shared.config)) + .option('--env-dir ', i18n.t(localeKeys.command.shared.envDir)) .option('-s --skip-build', i18n.t(localeKeys.command.shared.skipBuild)) .description(i18n.t(localeKeys.command.deploy.describe)) .action(async (options: DeployOptions) => { if (!options.skipBuild) { const { build } = await import('./build.js'); - await build(api); + await build(api, options); } const { deploy } = await import('./deploy.js'); diff --git a/packages/solutions/app-tools/src/commands/serve.ts b/packages/solutions/app-tools/src/commands/serve.ts index 9a6a727d8747..e3196d851d76 100644 --- a/packages/solutions/app-tools/src/commands/serve.ts +++ b/packages/solutions/app-tools/src/commands/serve.ts @@ -11,6 +11,7 @@ import { import type { AppNormalizedConfig, AppTools } from '../types'; import { loadServerPlugins } from '../utils/loadPlugins'; import { printInstructions } from '../utils/printInstructions'; +import type { StartOptions } from '../utils/types'; type ExtraServerOptions = { launcher?: typeof createProdServer; @@ -18,6 +19,7 @@ type ExtraServerOptions = { export const serve = async ( api: CLIPluginAPI, + options?: StartOptions, serverOptions?: ExtraServerOptions, ) => { const appContext = api.getAppContext(); @@ -96,6 +98,7 @@ export const serve = async ( ), bffRuntimeFramework: appContext.bffRuntimeFramework, }, + envDir: options?.envDir, runMode, }); diff --git a/packages/solutions/app-tools/src/locale/en.ts b/packages/solutions/app-tools/src/locale/en.ts index 0832a35ee0f7..a0661ffaa628 100644 --- a/packages/solutions/app-tools/src/locale/en.ts +++ b/packages/solutions/app-tools/src/locale/en.ts @@ -6,6 +6,7 @@ export const EN_LOCALE = { 'specify the configuration file, which can be a relative or absolute path', skipBuild: 'skip the build phase', noNeedInstall: 'not run install command', + envDir: 'specify the directory containing .env files', }, dev: { describe: 'starting the dev server', diff --git a/packages/solutions/app-tools/src/locale/zh.ts b/packages/solutions/app-tools/src/locale/zh.ts index 9a59da7f4885..a2e9aef69715 100644 --- a/packages/solutions/app-tools/src/locale/zh.ts +++ b/packages/solutions/app-tools/src/locale/zh.ts @@ -5,6 +5,7 @@ export const ZH_LOCALE = { config: '指定配置文件路径,可以为相对路径或绝对路径', skipBuild: '跳过构建阶段', noNeedInstall: '无需安装依赖', + envDir: '指定 .env 文件所在目录', }, dev: { describe: '启动开发服务器', diff --git a/packages/solutions/app-tools/src/plugins/deploy/index.ts b/packages/solutions/app-tools/src/plugins/deploy/index.ts index e07527b3f1b1..143654e303cd 100644 --- a/packages/solutions/app-tools/src/plugins/deploy/index.ts +++ b/packages/solutions/app-tools/src/plugins/deploy/index.ts @@ -11,6 +11,7 @@ import { createNodePreset } from './platforms/node'; import { createVercelPreset } from './platforms/vercel'; import type { PluginAPI } from './types'; import { getProjectUsage } from './utils'; + type DeployPresetCreators = { node: typeof createNodePreset; vercel: typeof createVercelPreset; @@ -32,6 +33,7 @@ async function getDeployPreset( modernConfig: AppToolsNormalizedConfig, deployTarget: DeployTarget, api: PluginAPI, + envDir?: string, ) { const { appDirectory, distDirectory, metaName } = appContext; const { useSSR, useAPI, useWebServer } = getProjectUsage( @@ -49,13 +51,24 @@ async function getDeployPreset( ); } - return createPreset({ appContext, modernConfig, needModernServer, api }); + return createPreset({ + appContext, + modernConfig, + needModernServer, + api, + envDir, + }); } export default (): CliPlugin => ({ name: '@modern-js/plugin-deploy', setup: api => { const deployTarget = process.env.MODERNJS_DEPLOY || provider || 'node'; + let deployEnvDir: string | undefined; + + api.onBeforeDeploy(options => { + deployEnvDir = options?.envDir; + }); api.deploy(async () => { const appContext = api.getAppContext(); @@ -69,6 +82,7 @@ export default (): CliPlugin => ({ modernConfig, deployTarget as DeployTarget, api, + deployEnvDir, ); deployPreset?.prepare && (await deployPreset?.prepare()); diff --git a/packages/solutions/app-tools/src/plugins/deploy/platforms/netlify.ts b/packages/solutions/app-tools/src/plugins/deploy/platforms/netlify.ts index 26391727d8e7..5fbcce1bbf67 100644 --- a/packages/solutions/app-tools/src/plugins/deploy/platforms/netlify.ts +++ b/packages/solutions/app-tools/src/plugins/deploy/platforms/netlify.ts @@ -24,6 +24,7 @@ async function cleanDistDirectory(dir: string) { export const createNetlifyPreset: CreatePreset = ({ appContext, modernConfig, + envDir, needModernServer, }) => { const { appDirectory, distDirectory, entrypoints, moduleType } = appContext; @@ -102,6 +103,7 @@ export const createNetlifyPreset: CreatePreset = ({ template, appContext, config: modernConfig, + envDir, isESM: isEsmProject, }); diff --git a/packages/solutions/app-tools/src/plugins/deploy/platforms/node.ts b/packages/solutions/app-tools/src/plugins/deploy/platforms/node.ts index 5b228006b5b9..40ebac51f783 100644 --- a/packages/solutions/app-tools/src/plugins/deploy/platforms/node.ts +++ b/packages/solutions/app-tools/src/plugins/deploy/platforms/node.ts @@ -13,6 +13,7 @@ export const createNodePreset: CreatePreset = ({ appContext, modernConfig, api, + envDir, }) => { const { appDirectory, distDirectory, moduleType } = appContext; const isEsmProject = moduleType === 'module'; @@ -36,6 +37,7 @@ export const createNodePreset: CreatePreset = ({ template, appContext, config: modernConfig, + envDir, isESM: isEsmProject, }); diff --git a/packages/solutions/app-tools/src/plugins/deploy/platforms/platform.ts b/packages/solutions/app-tools/src/plugins/deploy/platforms/platform.ts index 24b6028363b5..e3d39bccb2af 100644 --- a/packages/solutions/app-tools/src/plugins/deploy/platforms/platform.ts +++ b/packages/solutions/app-tools/src/plugins/deploy/platforms/platform.ts @@ -6,6 +6,7 @@ interface CreatePresetParams { appContext: AppToolsContext; modernConfig: AppToolsNormalizedConfig; api: PluginAPI; + envDir?: string; needModernServer?: boolean; } diff --git a/packages/solutions/app-tools/src/plugins/deploy/platforms/vercel.ts b/packages/solutions/app-tools/src/plugins/deploy/platforms/vercel.ts index b974b65c0e93..8398b2cd1760 100644 --- a/packages/solutions/app-tools/src/plugins/deploy/platforms/vercel.ts +++ b/packages/solutions/app-tools/src/plugins/deploy/platforms/vercel.ts @@ -9,6 +9,7 @@ import type { CreatePreset } from './platform'; export const createVercelPreset: CreatePreset = ({ appContext, modernConfig, + envDir, needModernServer, }) => { const { appDirectory, distDirectory, entrypoints, moduleType } = appContext; @@ -110,6 +111,7 @@ export const createVercelPreset: CreatePreset = ({ template, appContext, config: modernConfig, + envDir, isESM: isEsmProject, }); diff --git a/packages/solutions/app-tools/src/plugins/deploy/utils/generator.ts b/packages/solutions/app-tools/src/plugins/deploy/utils/generator.ts index 5ed859caa177..3dfec836a0cd 100644 --- a/packages/solutions/app-tools/src/plugins/deploy/utils/generator.ts +++ b/packages/solutions/app-tools/src/plugins/deploy/utils/generator.ts @@ -64,15 +64,18 @@ export interface GenerateHandlerOptions { template: string; appContext: AppToolsContext; config: AppToolsNormalizedConfig; + envDir?: string; serverConfig?: Partial; genAppContextTemplate?: typeof serverAppContextTemplate; genPluginImports?: typeof genPluginImportsCode; isESM?: boolean; } + export const generateHandler = async ({ template, appContext, config, + envDir, serverConfig: modifyServerConfig, genAppContextTemplate = serverAppContextTemplate, genPluginImports = genPluginImportsCode, @@ -104,6 +107,7 @@ export const generateHandler = async ({ const pluginImportCode = genPluginImports(plugins || [], Boolean(isESM)); const dynamicProdOptions = { config: serverConfig, + envDir, }; const serverConfigPath = getServerConfigPath(meta); diff --git a/packages/solutions/app-tools/src/utils/types.ts b/packages/solutions/app-tools/src/utils/types.ts index 7e33914abb44..5266ee27775a 100644 --- a/packages/solutions/app-tools/src/utils/types.ts +++ b/packages/solutions/app-tools/src/utils/types.ts @@ -3,21 +3,25 @@ export type DevOptions = { config?: string; apiOnly?: boolean; analyze?: boolean; + envDir?: string; }; export type BuildOptions = { config?: string; analyze?: boolean; watch?: boolean; + envDir?: string; }; export type DeployOptions = { config?: string; skipBuild?: boolean; + envDir?: string; }; export type StartOptions = { apiOnly?: boolean; + envDir?: string; }; export type InspectOptions = { diff --git a/packages/toolkit/plugin/src/cli/run/create.ts b/packages/toolkit/plugin/src/cli/run/create.ts index ff92db5a9230..8f7c44b1653d 100644 --- a/packages/toolkit/plugin/src/cli/run/create.ts +++ b/packages/toolkit/plugin/src/cli/run/create.ts @@ -1,4 +1,10 @@ -import { createDebugger, logger } from '@modern-js/utils'; +import { + createDebugger, + ensureAbsolutePath, + isPathInside, + logger, + resolveInsideOrFallback, +} from '@modern-js/utils'; import { program } from '@modern-js/utils/commander'; import { loadEnv } from '@rsbuild/core'; import { createPluginManager } from '../../manager'; @@ -71,8 +77,23 @@ export const createCli = () => { setProgramVersion(version); const envName = metaName === 'modern-js' ? 'MODERN' : metaName; + const envDir = options.envDir; + const envCwd = ensureAbsolutePath(appDirectory, envDir || '.'); + + if (!isPathInside(appDirectory, envCwd)) { + logger.warn( + `The env directory ${envDir} is outside project root, fallback to project root`, + ); + } + + const envLoadCwd = resolveInsideOrFallback( + appDirectory, + envDir, + appDirectory, + ); + loadEnv({ - cwd: appDirectory, + cwd: envLoadCwd, mode: process.env[`${envName.toUpperCase()}_ENV`] || process.env.NODE_ENV, prefixes: [`${envName.toUpperCase()}_`], }); diff --git a/packages/toolkit/plugin/src/cli/run/run.ts b/packages/toolkit/plugin/src/cli/run/run.ts index b526084ec729..08230c848fe2 100644 --- a/packages/toolkit/plugin/src/cli/run/run.ts +++ b/packages/toolkit/plugin/src/cli/run/run.ts @@ -2,6 +2,38 @@ import { logger } from '@modern-js/utils'; import { cli } from '.'; import type { CLIOptions } from './types'; +const ENV_DIR_OPTION = '--env-dir'; + +function parseEnvDir(argv: string[]): string | undefined { + const optionWithValue = `${ENV_DIR_OPTION}=`; + let lastEnvDir: string | undefined; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + + if (!arg) { + continue; + } + + if (arg.startsWith(optionWithValue)) { + const value = arg.slice(optionWithValue.length); + if (value) { + lastEnvDir = value; + } + continue; + } + + if (arg === ENV_DIR_OPTION) { + const value = argv[i + 1]; + if (value && !value.startsWith('-')) { + lastEnvDir = value; + } + } + } + + return lastEnvDir; +} + export const run = async (options: CLIOptions) => { const { initialLog, version, cwd, configFile, ...params } = options; @@ -10,6 +42,7 @@ export const run = async (options: CLIOptions) => { } const command = process.argv[2]; + const envDir = parseEnvDir(process.argv); if (!process.env.NODE_ENV) { if (['build', 'serve', 'deploy', 'analyze'].includes(command)) { @@ -26,6 +59,7 @@ export const run = async (options: CLIOptions) => { cwd, command, configFile, + envDir, ...params, }); }; diff --git a/packages/toolkit/plugin/src/cli/run/types.ts b/packages/toolkit/plugin/src/cli/run/types.ts index d7b7a2446d2d..2dc7b89e11a4 100644 --- a/packages/toolkit/plugin/src/cli/run/types.ts +++ b/packages/toolkit/plugin/src/cli/run/types.ts @@ -32,4 +32,5 @@ export interface CLIRunOptions< Extends extends CLIPluginExtends = { config: {} }, > extends CLIOptions { command: string; + envDir?: string; } diff --git a/packages/toolkit/plugin/src/server/run/types.ts b/packages/toolkit/plugin/src/server/run/types.ts index 46a520d38ccf..b5f25f37bc08 100644 --- a/packages/toolkit/plugin/src/server/run/types.ts +++ b/packages/toolkit/plugin/src/server/run/types.ts @@ -4,6 +4,7 @@ import type { Plugin } from '../../types/plugin'; export type ServerCreateOptions = { /** server working directory, and then also dist directory */ pwd: string; + envDir?: string; metaName?: string; routes?: ServerRoute[]; appContext: { diff --git a/packages/toolkit/utils/src/cli/ensure.ts b/packages/toolkit/utils/src/cli/ensure.ts index 050125eecc3d..68aea6660566 100644 --- a/packages/toolkit/utils/src/cli/ensure.ts +++ b/packages/toolkit/utils/src/cli/ensure.ts @@ -15,3 +15,29 @@ export const ensureArray = (params: T | T[]): T[] => { } return [params]; }; + +export const isPathInside = (parent: string, child: string): boolean => { + const relativePath = path.relative(parent, child); + + return ( + relativePath === '' || + (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) + ); +}; + +export const resolveInsideOrFallback = ( + base: string, + target: string | undefined, + fallback?: string, +): string => { + if (!target) { + return fallback ?? base; + } + + const resolvedTarget = path.resolve(base, target); + if (!isPathInside(base, resolvedTarget)) { + return fallback ?? base; + } + + return resolvedTarget; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ef801abf37e..f5af13bcd873 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2933,6 +2933,37 @@ importers: specifier: ^19.2.4 version: 19.2.4(react@19.2.4) + tests/integration/env-dir: + dependencies: + '@modern-js/runtime': + specifier: workspace:* + version: link:../../../packages/runtime/plugin-runtime + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + devDependencies: + '@modern-js/app-tools': + specifier: workspace:* + version: link:../../../packages/solutions/app-tools + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + '@types/node': + specifier: ^20 + version: 20.19.27 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + typescript: + specifier: ^5 + version: 5.9.3 + tests/integration/i18n/app-csr: dependencies: '@modern-js/plugin-i18n': diff --git a/tests/integration/env-dir/.browserslistrc b/tests/integration/env-dir/.browserslistrc new file mode 100644 index 000000000000..15b7c26176b2 --- /dev/null +++ b/tests/integration/env-dir/.browserslistrc @@ -0,0 +1,4 @@ +chrome >= 87 +edge >= 88 +firefox >= 78 +safari >= 14 diff --git a/tests/integration/env-dir/env/.env b/tests/integration/env-dir/env/.env new file mode 100644 index 000000000000..7d5f71364b2c --- /dev/null +++ b/tests/integration/env-dir/env/.env @@ -0,0 +1 @@ +MODERN_TEST_VAR=modern_base_value diff --git a/tests/integration/env-dir/env/.env.development b/tests/integration/env-dir/env/.env.development new file mode 100644 index 000000000000..5e7a74f6df1a --- /dev/null +++ b/tests/integration/env-dir/env/.env.development @@ -0,0 +1 @@ +MODERN_TEST_VAR=modern_dev_dir_value diff --git a/tests/integration/env-dir/env/.env.local b/tests/integration/env-dir/env/.env.local new file mode 100644 index 000000000000..4506584e8d71 --- /dev/null +++ b/tests/integration/env-dir/env/.env.local @@ -0,0 +1 @@ +MODERN_LOCAL_VAR=local_dir_value diff --git a/tests/integration/env-dir/env/.env.production b/tests/integration/env-dir/env/.env.production new file mode 100644 index 000000000000..176a9c97be92 --- /dev/null +++ b/tests/integration/env-dir/env/.env.production @@ -0,0 +1 @@ +MODERN_TEST_VAR=modern_prod_dir_value diff --git a/tests/integration/env-dir/modern.config.ts b/tests/integration/env-dir/modern.config.ts new file mode 100644 index 000000000000..c0e7fed94e46 --- /dev/null +++ b/tests/integration/env-dir/modern.config.ts @@ -0,0 +1,3 @@ +import { applyBaseConfig } from '../../utils/applyBaseConfig'; + +export default applyBaseConfig({}); diff --git a/tests/integration/env-dir/package.json b/tests/integration/env-dir/package.json new file mode 100644 index 000000000000..5dd99f995395 --- /dev/null +++ b/tests/integration/env-dir/package.json @@ -0,0 +1,24 @@ +{ + "private": true, + "name": "tmp-env-dir", + "version": "2.66.0", + "scripts": { + "dev": "modern dev", + "build": "modern build", + "serve": "modern serve", + "new": "modern new" + }, + "dependencies": { + "@modern-js/runtime": "workspace:*", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@modern-js/app-tools": "workspace:*", + "@types/jest": "^29.5.14", + "@types/node": "^20", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "typescript": "^5" + } +} diff --git a/tests/integration/env-dir/src/App.css b/tests/integration/env-dir/src/App.css new file mode 100644 index 000000000000..113a23887cad --- /dev/null +++ b/tests/integration/env-dir/src/App.css @@ -0,0 +1,121 @@ +html, +body { + padding: 0; + margin: 0; + font-family: + nunito_for_arco, Helvetica Neue, Helvetica, PingFang SC, + Hiragino Sans GB, Microsoft YaHei, 微软雅黑, Arial, sans-serif; +} + +* { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + box-sizing: border-box; +} + +.container { + min-height: 100vh; + max-width: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +main { + padding: 5rem 0; + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.footer { + width: 100%; + height: 80px; + border-top: 1px solid #eaeaea; + display: flex; + justify-content: center; + align-items: center; + background-color: #470000; +} + +.footer a { + display: flex; + justify-content: center; + align-items: center; + flex-grow: 1; + color: #f4f4f4; + text-decoration: none; + font-size: 1.1rem; +} + +.logo { + margin-bottom: 2rem; +} + +.logo svg { + width: 450px; + height: 132px; +} + +.description { + text-align: center; + line-height: 1.5; + font-size: 1.5rem; +} + +.code { + background: #fafafa; + border-radius: 5px; + padding: 0.75rem; + font-size: 1.1rem; + font-family: + Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, + Bitstream Vera Sans Mono, Courier New, monospace; +} + +@media (max-width: 600px) { + .grid { + width: 100%; + flex-direction: column; + } +} + +.grid { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + width: 800px; + margin-top: 3rem; +} + +.card { + margin: 1rem; + padding: 1.5rem; + display: flex; + align-items: center; + justify-content: center; + height: 100px; + color: inherit; + text-decoration: none; + border: 1px solid #470000; + color: #470000; + transition: color 0.15s ease, border-color 0.15s ease; + width: 45%; +} + +.card:hover, +.card:focus, +.card:active { + transform: scale(1.05); + transition: 0.1s ease-in-out; +} + +.card h2 { + font-size: 1.5rem; + margin: 0; + padding: 0; +} diff --git a/tests/integration/env-dir/src/App.tsx b/tests/integration/env-dir/src/App.tsx new file mode 100644 index 000000000000..163343850769 --- /dev/null +++ b/tests/integration/env-dir/src/App.tsx @@ -0,0 +1,47 @@ +import './App.css'; + +const App = () => ( +
+
+
+ Modern.js Logo +
+

+ Get started by editing src/App.tsx +

+ +
+
+
+ MODERN_TEST_VAR: {process.env.MODERN_TEST_VAR || 'undefined'} +
+
+ NODE_ENV: {process.env.NODE_ENV || 'undefined'} +
+
+ MODERN_LOCAL_VAR: {process.env.MODERN_LOCAL_VAR || 'undefined'} +
+
+ +
+); + +export default App; diff --git a/tests/integration/env-dir/src/modern-app-env.d.ts b/tests/integration/env-dir/src/modern-app-env.d.ts new file mode 100644 index 000000000000..1e851dcf7213 --- /dev/null +++ b/tests/integration/env-dir/src/modern-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tests/integration/env-dir/tests/index.test.ts b/tests/integration/env-dir/tests/index.test.ts new file mode 100644 index 000000000000..c73b00c507c3 --- /dev/null +++ b/tests/integration/env-dir/tests/index.test.ts @@ -0,0 +1,215 @@ +import { existsSync, readFileSync, rmSync } from 'fs'; +import path from 'path'; +import puppeteer, { type Browser, type Page } from 'puppeteer'; +import { + getPort, + killApp, + launchOptions, + runModernCommand, + runModernCommandDev, +} from '../../../utils/modernTestUtils'; + +const appDir = path.resolve(__dirname, '../'); + +describe('test env-dir dev', () => { + let app: unknown; + let page: Page; + let browser: Browser; + let appPort: number; + + beforeAll(async () => { + appPort = await getPort(); + app = await runModernCommandDev(['dev', '--env-dir', './env'], undefined, { + cwd: appDir, + env: { + PORT: appPort, + NODE_ENV: 'development', + }, + }); + browser = await puppeteer.launch(launchOptions as any); + page = await browser.newPage(); + }); + + afterAll(async () => { + await killApp(app); + await page.close(); + await browser.close(); + }); + + test('should load env variables from env-dir in development', async () => { + await page.goto(`http://localhost:${appPort}`, { + waitUntil: ['networkidle0'], + }); + + const modernTestVar = await page.$eval( + '[data-testid="modern-test-var"]', + el => el.textContent, + ); + expect(modernTestVar).toBe('MODERN_TEST_VAR: modern_dev_dir_value'); + + const modernLocalVar = await page.$eval( + '[data-testid="modern-local-var"]', + el => el.textContent, + ); + expect(modernLocalVar).toBe('MODERN_LOCAL_VAR: local_dir_value'); + }); +}); + +describe('test without env-dir option', () => { + test('should not copy env-dir files when option is absent', async () => { + const buildRes = await runModernCommand(['build'], { + cwd: appDir, + stdout: true, + stderr: true, + env: { + NODE_ENV: 'production', + }, + }); + + expect(buildRes.code).toBe(0); + + const envDirEnvPath = path.join(appDir, 'dist', 'env', '.env.production'); + expect(existsSync(envDirEnvPath)).toBe(false); + }); +}); + +describe('test env-dir build and serve', () => { + let app: unknown; + let page: Page; + let browser: Browser; + let appPort: number; + const host = `http://localhost`; + + beforeAll(async () => { + appPort = await getPort(); + + const buildRes = await runModernCommand(['build', '--env-dir', './env'], { + cwd: appDir, + stdout: true, + stderr: true, + env: { + NODE_ENV: 'production', + }, + }); + expect(buildRes.code).toBe(0); + + browser = await puppeteer.launch(launchOptions as any); + page = await browser.newPage(); + + app = await runModernCommandDev( + ['serve', '--env-dir', './env'], + undefined, + { + cwd: appDir, + env: { + PORT: appPort, + NODE_ENV: 'production', + }, + modernServe: true, + }, + ); + }); + + afterAll(async () => { + await page.close(); + await browser.close(); + await killApp(app); + }); + + test('should load env variables from env-dir in production', async () => { + await page.goto(`${host}:${appPort}`, { + waitUntil: ['networkidle0'], + }); + + const modernTestVar = await page.$eval( + '[data-testid="modern-test-var"]', + el => el.textContent, + ); + expect(modernTestVar).toBe('MODERN_TEST_VAR: modern_prod_dir_value'); + + const modernLocalVar = await page.$eval( + '[data-testid="modern-local-var"]', + el => el.textContent, + ); + expect(modernLocalVar).toBe('MODERN_LOCAL_VAR: local_dir_value'); + }); +}); + +describe('test env-dir deploy', () => { + test('should pass --env-dir to build in deploy flow', async () => { + rmSync(path.join(appDir, 'dist'), { + recursive: true, + force: true, + }); + + const envDirEnvPath = path.join(appDir, 'dist', 'env', '.env.production'); + expect(existsSync(envDirEnvPath)).toBe(false); + + const deployRes = await runModernCommand(['deploy', '--env-dir', './env'], { + cwd: appDir, + stdout: true, + stderr: true, + env: { + NODE_ENV: 'production', + MODERNJS_DEPLOY: 'node', + }, + }); + + expect(deployRes.code).toBe(0); + expect(existsSync(envDirEnvPath)).toBe(true); + }); + + test('should inject env-dir into deployed output runtime options', async () => { + const deployRes = await runModernCommand(['deploy', '--env-dir', './env'], { + cwd: appDir, + stdout: true, + stderr: true, + env: { + NODE_ENV: 'production', + MODERNJS_DEPLOY: 'node', + }, + }); + + expect(deployRes.code).toBe(0); + + const entryCode = readFileSync( + path.join(appDir, '.output', 'index.js'), + 'utf-8', + ); + expect(entryCode.includes('"envDir":"./env"')).toBe(true); + }); +}); + +describe('test env-dir option precedence', () => { + test('should respect the last --env-dir value for build flow', async () => { + const buildRes = await runModernCommand( + ['build', '--env-dir', './invalid-env', '--env-dir', './env'], + { + cwd: appDir, + stdout: true, + stderr: true, + env: { + NODE_ENV: 'production', + }, + }, + ); + + expect(buildRes.code).toBe(0); + + const validEnvFilePath = path.join( + appDir, + 'dist', + 'env', + '.env.production', + ); + const invalidEnvFilePath = path.join( + appDir, + 'dist', + 'invalid-env', + '.env.production', + ); + + expect(existsSync(validEnvFilePath)).toBe(true); + expect(existsSync(invalidEnvFilePath)).toBe(false); + }); +}); diff --git a/tests/integration/env-dir/tests/tsconfig.json b/tests/integration/env-dir/tests/tsconfig.json new file mode 100644 index 000000000000..10f49432232c --- /dev/null +++ b/tests/integration/env-dir/tests/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@modern-js/tsconfig/base", + "compilerOptions": { + "declaration": true, + "jsx": "preserve", + "baseUrl": "./", + "emitDeclarationOnly": true, + "isolatedModules": true, + "paths": {}, + "types": ["node", "jest"] + } +} diff --git a/tests/integration/env-dir/tsconfig.json b/tests/integration/env-dir/tsconfig.json new file mode 100644 index 000000000000..123f1412debb --- /dev/null +++ b/tests/integration/env-dir/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@modern-js/tsconfig/base", + "compilerOptions": { + "declaration": false, + "jsx": "react-jsx", + "baseUrl": "./", + "paths": { + "@/*": ["./src/*"], + "@shared/*": ["./shared/*"] + } + }, + "include": ["src", "shared", "config"] +}