From d37e84ef8c1ed41e74aa8db033351358fe9ac916 Mon Sep 17 00:00:00 2001 From: GiveMe-A-Name Date: Wed, 25 Mar 2026 16:50:53 +0800 Subject: [PATCH 01/10] feat(app-tools): support --env-dir for env loading Load env files from a custom CLI directory while preserving default root behavior. Keep env handling consistent in dev, build, and serve, with integration tests added. --- .../core/src/adapters/node/helper/loadEnv.ts | 7 +- .../core/tests/adapters/loadEnv.test.ts | 15 +++ .../core/tests/fixtures/serverEnvDir/env/.env | 1 + .../tests/fixtures/serverEnvDir/env/.env.prod | 2 + .../solutions/app-tools/src/commands/build.ts | 30 ++++- .../solutions/app-tools/src/commands/index.ts | 8 +- .../solutions/app-tools/src/commands/serve.ts | 3 + packages/solutions/app-tools/src/locale/en.ts | 1 + packages/solutions/app-tools/src/locale/zh.ts | 1 + .../solutions/app-tools/src/utils/types.ts | 3 + packages/toolkit/plugin/src/cli/run/create.ts | 6 +- packages/toolkit/plugin/src/cli/run/run.ts | 34 +++++ .../toolkit/plugin/src/server/run/types.ts | 1 + tests/integration/env-dir/.browserslistrc | 4 + tests/integration/env-dir/env/.env | 1 + .../integration/env-dir/env/.env.development | 1 + tests/integration/env-dir/env/.env.local | 1 + tests/integration/env-dir/env/.env.production | 1 + tests/integration/env-dir/modern.config.ts | 3 + tests/integration/env-dir/package.json | 24 ++++ tests/integration/env-dir/src/App.css | 121 ++++++++++++++++++ tests/integration/env-dir/src/App.tsx | 47 +++++++ .../env-dir/src/modern-app-env.d.ts | 1 + tests/integration/env-dir/tests/index.test.ts | 117 +++++++++++++++++ tests/integration/env-dir/tests/tsconfig.json | 12 ++ tests/integration/env-dir/tsconfig.json | 13 ++ 26 files changed, 448 insertions(+), 10 deletions(-) create mode 100644 packages/server/core/tests/fixtures/serverEnvDir/env/.env create mode 100644 packages/server/core/tests/fixtures/serverEnvDir/env/.env.prod create mode 100644 tests/integration/env-dir/.browserslistrc create mode 100644 tests/integration/env-dir/env/.env create mode 100644 tests/integration/env-dir/env/.env.development create mode 100644 tests/integration/env-dir/env/.env.local create mode 100644 tests/integration/env-dir/env/.env.production create mode 100644 tests/integration/env-dir/modern.config.ts create mode 100644 tests/integration/env-dir/package.json create mode 100644 tests/integration/env-dir/src/App.css create mode 100644 tests/integration/env-dir/src/App.tsx create mode 100644 tests/integration/env-dir/src/modern-app-env.d.ts create mode 100644 tests/integration/env-dir/tests/index.test.ts create mode 100644 tests/integration/env-dir/tests/tsconfig.json create mode 100644 tests/integration/env-dir/tsconfig.json diff --git a/packages/server/core/src/adapters/node/helper/loadEnv.ts b/packages/server/core/src/adapters/node/helper/loadEnv.ts index 80acf8142aed..3de22a966e61 100644 --- a/packages/server/core/src/adapters/node/helper/loadEnv.ts +++ b/packages/server/core/src/adapters/node/helper/loadEnv.ts @@ -4,10 +4,11 @@ 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 = envDir ? path.resolve(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..1330b46cd764 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,18 @@ 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; + }); }); 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..b9520fc2df25 100644 --- a/packages/solutions/app-tools/src/commands/build.ts +++ b/packages/solutions/app-tools/src/commands/build.ts @@ -11,9 +11,25 @@ import type { BuildOptions } from '../utils/types'; async function copyEnvFiles( appDirectory: string, distDirectory: string, + envDir?: string, ): Promise { try { - const files = await fs.readdir(appDirectory); + const envDirectory = envDir + ? path.resolve(appDirectory, envDir) + : appDirectory; + + 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 +40,10 @@ 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 = envDir + ? path.resolve(distDirectory, envDir, envFile) + : path.resolve(distDirectory, envFile); try { const stat = await fs.stat(sourcePath); @@ -113,7 +131,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..63f296635252 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); }); }; 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/utils/types.ts b/packages/solutions/app-tools/src/utils/types.ts index 7e33914abb44..fa28d9c004c5 100644 --- a/packages/solutions/app-tools/src/utils/types.ts +++ b/packages/solutions/app-tools/src/utils/types.ts @@ -3,12 +3,14 @@ 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 = { @@ -18,6 +20,7 @@ export type DeployOptions = { 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..873de70b4df4 100644 --- a/packages/toolkit/plugin/src/cli/run/create.ts +++ b/packages/toolkit/plugin/src/cli/run/create.ts @@ -1,3 +1,4 @@ +import path from 'path'; import { createDebugger, logger } from '@modern-js/utils'; import { program } from '@modern-js/utils/commander'; import { loadEnv } from '@rsbuild/core'; @@ -71,8 +72,11 @@ export const createCli = () => { setProgramVersion(version); const envName = metaName === 'modern-js' ? 'MODERN' : metaName; + const envDir = process.env.MODERN_ENV_DIR; + const envCwd = envDir ? path.resolve(appDirectory, envDir) : appDirectory; + loadEnv({ - cwd: appDirectory, + cwd: envCwd, 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..06b735b9f478 100644 --- a/packages/toolkit/plugin/src/cli/run/run.ts +++ b/packages/toolkit/plugin/src/cli/run/run.ts @@ -2,6 +2,35 @@ 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}=`; + + 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); + return value || undefined; + } + + if (arg === ENV_DIR_OPTION) { + const value = argv[i + 1]; + if (!value || value.startsWith('-')) { + return undefined; + } + return value; + } + } + + return undefined; +} + export const run = async (options: CLIOptions) => { const { initialLog, version, cwd, configFile, ...params } = options; @@ -10,6 +39,11 @@ export const run = async (options: CLIOptions) => { } const command = process.argv[2]; + const envDir = parseEnvDir(process.argv); + + if (envDir) { + process.env.MODERN_ENV_DIR = envDir; + } if (!process.env.NODE_ENV) { if (['build', 'serve', 'deploy', 'analyze'].includes(command)) { 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/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..54191ee4ada8 --- /dev/null +++ b/tests/integration/env-dir/tests/index.test.ts @@ -0,0 +1,117 @@ +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).toContain('modern_dev_dir_value'); + + const modernLocalVar = await page.$eval( + '[data-testid="modern-local-var"]', + el => el.textContent, + ); + expect(modernLocalVar).toContain('local_dir_value'); + }); +}); + +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).toContain('modern_prod_dir_value'); + + const modernLocalVar = await page.$eval( + '[data-testid="modern-local-var"]', + el => el.textContent, + ); + expect(modernLocalVar).toContain('local_dir_value'); + }); +}); 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"] +} From 71da6fa607665384b94cde3ad5fea5d6c11dad8a Mon Sep 17 00:00:00 2001 From: GiveMe-A-Name Date: Wed, 25 Mar 2026 16:55:05 +0800 Subject: [PATCH 02/10] chore: update lockfile for env-dir integration fixture --- pnpm-lock.yaml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) 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': From 1029f38139722d8536ccbdc278eac0eb227a75f6 Mon Sep 17 00:00:00 2001 From: GiveMe-A-Name Date: Wed, 25 Mar 2026 17:30:43 +0800 Subject: [PATCH 03/10] refactor: consolidate env-dir path checks Extract shared path containment helpers and reuse them across CLI, build, and server env loading. Keep external MODERN_ENV_DIR when no CLI override is passed and tighten integration assertions. --- .../core/src/adapters/node/helper/loadEnv.ts | 19 +++++- .../core/tests/adapters/loadEnv.test.ts | 14 +++++ .../solutions/app-tools/src/commands/build.ts | 54 +++++++++++++--- packages/toolkit/plugin/src/cli/run/create.ts | 25 ++++++-- packages/toolkit/plugin/src/cli/run/run.ts | 13 ++-- packages/toolkit/utils/src/cli/ensure.ts | 26 ++++++++ tests/integration/env-dir/tests/index.test.ts | 61 +++++++++++++++++-- 7 files changed, 190 insertions(+), 22 deletions(-) diff --git a/packages/server/core/src/adapters/node/helper/loadEnv.ts b/packages/server/core/src/adapters/node/helper/loadEnv.ts index 3de22a966e61..2fde37f1ea3c 100644 --- a/packages/server/core/src/adapters/node/helper/loadEnv.ts +++ b/packages/server/core/src/adapters/node/helper/loadEnv.ts @@ -1,12 +1,27 @@ import path from 'path'; -import { fs, dotenv, dotenvExpand } from '@modern-js/utils'; +import { + fs, + dotenv, + dotenvExpand, + isPathInside, + resolveInsideOrFallback, +} from '@modern-js/utils'; import type { ServerBaseOptions } from '../../../serverBase'; +function getSafeEnvDirectory(pwd: string, envDir?: string): string { + const envDirectory = resolveInsideOrFallback(pwd, envDir, pwd); + if (envDir && !isPathInside(pwd, path.resolve(pwd, envDir))) { + return pwd; + } + + return envDirectory; +} + /** 读取 .env.{process.env.MODERN_ENV} 文件,加载环境变量 */ export async function loadServerEnv(options: ServerBaseOptions) { const { pwd, envDir } = options; const serverEnv = process.env.MODERN_ENV; - const envDirectory = envDir ? path.resolve(pwd, envDir) : pwd; + const envDirectory = getSafeEnvDirectory(pwd, envDir); const defaultEnvPath = path.resolve(envDirectory, `.env`); const serverEnvPath = path.resolve(envDirectory, `.env.${serverEnv}`); diff --git a/packages/server/core/tests/adapters/loadEnv.test.ts b/packages/server/core/tests/adapters/loadEnv.test.ts index 1330b46cd764..0d96bc4923f0 100644 --- a/packages/server/core/tests/adapters/loadEnv.test.ts +++ b/packages/server/core/tests/adapters/loadEnv.test.ts @@ -52,4 +52,18 @@ describe('test load serve env file', () => { 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/solutions/app-tools/src/commands/build.ts b/packages/solutions/app-tools/src/commands/build.ts index b9520fc2df25..eb977944a9f8 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,15 +14,51 @@ import { setupTsRuntime } from '../utils/register'; import { generateRoutes } from '../utils/routes'; import type { BuildOptions } from '../utils/types'; +function getSafeEnvDirectory(appDirectory: string, envDir?: string): string { + 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`, + ); + } + + return envDirectory; +} + +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 envDirectory = envDir - ? path.resolve(appDirectory, envDir) - : appDirectory; + const envDirectory = getSafeEnvDirectory(appDirectory, envDir); if (!(await fs.pathExists(envDirectory))) { logger.debug(`Env directory does not exist: ${envDirectory}`); @@ -41,9 +83,7 @@ async function copyEnvFiles( const copyPromises = envFiles.map(async envFile => { const sourcePath = path.resolve(envDirectory, envFile); - const targetPath = envDir - ? path.resolve(distDirectory, envDir, envFile) - : path.resolve(distDirectory, envFile); + const targetPath = getSafeDistTarget(distDirectory, envDir, envFile); try { const stat = await fs.stat(sourcePath); diff --git a/packages/toolkit/plugin/src/cli/run/create.ts b/packages/toolkit/plugin/src/cli/run/create.ts index 873de70b4df4..9905a8a43096 100644 --- a/packages/toolkit/plugin/src/cli/run/create.ts +++ b/packages/toolkit/plugin/src/cli/run/create.ts @@ -1,5 +1,10 @@ -import path from 'path'; -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'; @@ -73,10 +78,22 @@ export const createCli = () => { const envName = metaName === 'modern-js' ? 'MODERN' : metaName; const envDir = process.env.MODERN_ENV_DIR; - const envCwd = envDir ? path.resolve(appDirectory, envDir) : appDirectory; + 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: envCwd, + 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 06b735b9f478..cd56a978d6b7 100644 --- a/packages/toolkit/plugin/src/cli/run/run.ts +++ b/packages/toolkit/plugin/src/cli/run/run.ts @@ -6,6 +6,7 @@ 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]; @@ -16,19 +17,21 @@ function parseEnvDir(argv: string[]): string | undefined { if (arg.startsWith(optionWithValue)) { const value = arg.slice(optionWithValue.length); - return value || undefined; + if (value) { + lastEnvDir = value; + } + continue; } if (arg === ENV_DIR_OPTION) { const value = argv[i + 1]; - if (!value || value.startsWith('-')) { - return undefined; + if (value && !value.startsWith('-')) { + lastEnvDir = value; } - return value; } } - return undefined; + return lastEnvDir; } export const run = async (options: CLIOptions) => { 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/tests/integration/env-dir/tests/index.test.ts b/tests/integration/env-dir/tests/index.test.ts index 54191ee4ada8..6415e36d5308 100644 --- a/tests/integration/env-dir/tests/index.test.ts +++ b/tests/integration/env-dir/tests/index.test.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'fs'; import path from 'path'; import puppeteer, { type Browser, type Page } from 'puppeteer'; import { @@ -44,13 +45,31 @@ describe('test env-dir dev', () => { '[data-testid="modern-test-var"]', el => el.textContent, ); - expect(modernTestVar).toContain('modern_dev_dir_value'); + 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).toContain('local_dir_value'); + 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); }); }); @@ -106,12 +125,46 @@ describe('test env-dir build and serve', () => { '[data-testid="modern-test-var"]', el => el.textContent, ); - expect(modernTestVar).toContain('modern_prod_dir_value'); + 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).toContain('local_dir_value'); + expect(modernLocalVar).toBe('MODERN_LOCAL_VAR: local_dir_value'); + }); +}); + +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); }); }); From 624054758268aa88589245236e6a6055aa4bc16c Mon Sep 17 00:00:00 2001 From: GiveMe-A-Name Date: Wed, 25 Mar 2026 17:42:08 +0800 Subject: [PATCH 04/10] refactor: deduplicate env-dir resolver usage Remove local safe env-dir helpers in build and server load paths. Use shared resolver utility directly to keep logic centralized. --- .../core/src/adapters/node/helper/loadEnv.ts | 12 +------ .../solutions/app-tools/src/commands/build.ts | 32 ++++++++----------- 2 files changed, 14 insertions(+), 30 deletions(-) diff --git a/packages/server/core/src/adapters/node/helper/loadEnv.ts b/packages/server/core/src/adapters/node/helper/loadEnv.ts index 2fde37f1ea3c..190a71cd1b43 100644 --- a/packages/server/core/src/adapters/node/helper/loadEnv.ts +++ b/packages/server/core/src/adapters/node/helper/loadEnv.ts @@ -3,25 +3,15 @@ import { fs, dotenv, dotenvExpand, - isPathInside, resolveInsideOrFallback, } from '@modern-js/utils'; import type { ServerBaseOptions } from '../../../serverBase'; -function getSafeEnvDirectory(pwd: string, envDir?: string): string { - const envDirectory = resolveInsideOrFallback(pwd, envDir, pwd); - if (envDir && !isPathInside(pwd, path.resolve(pwd, envDir))) { - return pwd; - } - - return envDirectory; -} - /** 读取 .env.{process.env.MODERN_ENV} 文件,加载环境变量 */ export async function loadServerEnv(options: ServerBaseOptions) { const { pwd, envDir } = options; const serverEnv = process.env.MODERN_ENV; - const envDirectory = getSafeEnvDirectory(pwd, envDir); + const envDirectory = resolveInsideOrFallback(pwd, envDir, pwd); const defaultEnvPath = path.resolve(envDirectory, `.env`); const serverEnvPath = path.resolve(envDirectory, `.env.${serverEnv}`); diff --git a/packages/solutions/app-tools/src/commands/build.ts b/packages/solutions/app-tools/src/commands/build.ts index eb977944a9f8..6277fcb06bc5 100644 --- a/packages/solutions/app-tools/src/commands/build.ts +++ b/packages/solutions/app-tools/src/commands/build.ts @@ -14,24 +14,6 @@ import { setupTsRuntime } from '../utils/register'; import { generateRoutes } from '../utils/routes'; import type { BuildOptions } from '../utils/types'; -function getSafeEnvDirectory(appDirectory: string, envDir?: string): string { - 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`, - ); - } - - return envDirectory; -} - function getSafeDistTarget( distDirectory: string, envDir: string | undefined, @@ -58,7 +40,19 @@ async function copyEnvFiles( envDir?: string, ): Promise { try { - const envDirectory = getSafeEnvDirectory(appDirectory, envDir); + 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}`); From 9aa095feee2dc77c32f690e9bc4698bc1b838dfd Mon Sep 17 00:00:00 2001 From: GiveMe-A-Name Date: Wed, 25 Mar 2026 17:48:13 +0800 Subject: [PATCH 05/10] docs: add --env-dir command docs Document --env-dir for modern dev, build, and serve. Update both English and Chinese app command documentation. --- .../document/docs/en/apis/app/commands.mdx | 21 +++++++++++++++++++ .../document/docs/zh/apis/app/commands.mdx | 21 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/packages/document/docs/en/apis/app/commands.mdx b/packages/document/docs/en/apis/app/commands.mdx index 2997db7fc4d8..eb511eb15d33 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,28 @@ 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 +``` + +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 +152,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/zh/apis/app/commands.mdx b/packages/document/docs/zh/apis/app/commands.mdx index 07c22943adcb..697828b76e9f 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,28 @@ 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.js 仍会默认从项目根目录加载 `.env*`。 + +`--env-dir` 的路径相对于项目根目录解析。 + ## modern new `modern new` 命令用于在已有项目中添加项目元素。 @@ -133,6 +152,8 @@ import ServeCommand from '@site-docs/components/serve-command'; +`modern serve` 同样支持 `--env-dir `,用于从指定目录加载 `.env*` 文件。 + ## modern upgrade 在项目根目录下执行命令 `npx modern upgrade`,会默认将当前执行命令项目的 `package.json` 中的 Modern.js 相关依赖更新至最新版本。 From e18ae8da59fcb2cbdb757ec30ed9bdb80703dfba Mon Sep 17 00:00:00 2001 From: GiveMe-A-Name Date: Wed, 25 Mar 2026 17:53:36 +0800 Subject: [PATCH 06/10] chore: add changeset --- .changeset/icy-ravens-draw.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/icy-ravens-draw.md 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 From 0c92d23b5676114952470ae66a292c80bf020215 Mon Sep 17 00:00:00 2001 From: GiveMe-A-Name Date: Fri, 27 Mar 2026 10:39:20 +0800 Subject: [PATCH 07/10] feat(env-dir): support deploy and runtime env-dir propagation Co-Authored-By: Aiden --- .../document/docs/en/apis/app/commands.mdx | 1 + .../docs/en/components/deploy-command.mdx | 1 + .../document/docs/zh/apis/app/commands.mdx | 1 + .../docs/zh/components/deploy-command.mdx | 1 + .../core/src/adapters/node/helper/loadEnv.ts | 3 ++- .../server/core/tests/adapters/loadEnv.test.ts | 15 +++++++++++++++ .../solutions/app-tools/src/commands/index.ts | 3 ++- .../solutions/app-tools/src/utils/types.ts | 1 + packages/toolkit/plugin/src/cli/run/create.ts | 2 +- packages/toolkit/plugin/src/cli/run/run.ts | 5 +---- packages/toolkit/plugin/src/cli/run/types.ts | 1 + tests/integration/env-dir/tests/index.test.ts | 18 ++++++++++++++++++ 12 files changed, 45 insertions(+), 7 deletions(-) diff --git a/packages/document/docs/en/apis/app/commands.mdx b/packages/document/docs/en/apis/app/commands.mdx index eb511eb15d33..7339d7ae0696 100644 --- a/packages/document/docs/en/apis/app/commands.mdx +++ b/packages/document/docs/en/apis/app/commands.mdx @@ -93,6 +93,7 @@ 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. 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 697828b76e9f..186815a4cfe0 100644 --- a/packages/document/docs/zh/apis/app/commands.mdx +++ b/packages/document/docs/zh/apis/app/commands.mdx @@ -93,6 +93,7 @@ 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*`。 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 190a71cd1b43..d50484a9ba14 100644 --- a/packages/server/core/src/adapters/node/helper/loadEnv.ts +++ b/packages/server/core/src/adapters/node/helper/loadEnv.ts @@ -11,7 +11,8 @@ import type { ServerBaseOptions } from '../../../serverBase'; export async function loadServerEnv(options: ServerBaseOptions) { const { pwd, envDir } = options; const serverEnv = process.env.MODERN_ENV; - const envDirectory = resolveInsideOrFallback(pwd, envDir, pwd); + const resolvedEnvDir = envDir ?? process.env.MODERN_ENV_DIR; + const envDirectory = resolveInsideOrFallback(pwd, resolvedEnvDir, pwd); const defaultEnvPath = path.resolve(envDirectory, `.env`); const serverEnvPath = path.resolve(envDirectory, `.env.${serverEnv}`); diff --git a/packages/server/core/tests/adapters/loadEnv.test.ts b/packages/server/core/tests/adapters/loadEnv.test.ts index 0d96bc4923f0..2652dbef2b2c 100644 --- a/packages/server/core/tests/adapters/loadEnv.test.ts +++ b/packages/server/core/tests/adapters/loadEnv.test.ts @@ -66,4 +66,19 @@ describe('test load serve env file', () => { delete process.env.USER_NAME; delete process.env.ENV; }); + + it('should load env from MODERN_ENV_DIR when envDir is not provided', async () => { + process.env.MODERN_ENV = 'prod'; + process.env.MODERN_ENV_DIR = 'env'; + await loadServerEnv({ + pwd: envPwd, + } 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; + delete process.env.MODERN_ENV_DIR; + }); }); diff --git a/packages/solutions/app-tools/src/commands/index.ts b/packages/solutions/app-tools/src/commands/index.ts index 63f296635252..c3750d6a1a63 100644 --- a/packages/solutions/app-tools/src/commands/index.ts +++ b/packages/solutions/app-tools/src/commands/index.ts @@ -75,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/utils/types.ts b/packages/solutions/app-tools/src/utils/types.ts index fa28d9c004c5..5266ee27775a 100644 --- a/packages/solutions/app-tools/src/utils/types.ts +++ b/packages/solutions/app-tools/src/utils/types.ts @@ -16,6 +16,7 @@ export type BuildOptions = { export type DeployOptions = { config?: string; skipBuild?: boolean; + envDir?: string; }; export type StartOptions = { diff --git a/packages/toolkit/plugin/src/cli/run/create.ts b/packages/toolkit/plugin/src/cli/run/create.ts index 9905a8a43096..8f7c44b1653d 100644 --- a/packages/toolkit/plugin/src/cli/run/create.ts +++ b/packages/toolkit/plugin/src/cli/run/create.ts @@ -77,7 +77,7 @@ export const createCli = () => { setProgramVersion(version); const envName = metaName === 'modern-js' ? 'MODERN' : metaName; - const envDir = process.env.MODERN_ENV_DIR; + const envDir = options.envDir; const envCwd = ensureAbsolutePath(appDirectory, envDir || '.'); if (!isPathInside(appDirectory, envCwd)) { diff --git a/packages/toolkit/plugin/src/cli/run/run.ts b/packages/toolkit/plugin/src/cli/run/run.ts index cd56a978d6b7..08230c848fe2 100644 --- a/packages/toolkit/plugin/src/cli/run/run.ts +++ b/packages/toolkit/plugin/src/cli/run/run.ts @@ -44,10 +44,6 @@ export const run = async (options: CLIOptions) => { const command = process.argv[2]; const envDir = parseEnvDir(process.argv); - if (envDir) { - process.env.MODERN_ENV_DIR = envDir; - } - if (!process.env.NODE_ENV) { if (['build', 'serve', 'deploy', 'analyze'].includes(command)) { process.env.NODE_ENV = 'production'; @@ -63,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/tests/integration/env-dir/tests/index.test.ts b/tests/integration/env-dir/tests/index.test.ts index 6415e36d5308..b7f2a21bec4f 100644 --- a/tests/integration/env-dir/tests/index.test.ts +++ b/tests/integration/env-dir/tests/index.test.ts @@ -135,6 +135,24 @@ describe('test env-dir build and serve', () => { }); }); +describe('test env-dir deploy', () => { + test('should pass --env-dir to build in deploy flow', async () => { + const deployRes = await runModernCommand(['deploy', '--env-dir', './env'], { + cwd: appDir, + stdout: true, + stderr: true, + env: { + NODE_ENV: 'production', + }, + }); + + expect(deployRes.code).toBe(0); + + const envDirEnvPath = path.join(appDir, 'dist', 'env', '.env.production'); + expect(existsSync(envDirEnvPath)).toBe(true); + }); +}); + describe('test env-dir option precedence', () => { test('should respect the last --env-dir value for build flow', async () => { const buildRes = await runModernCommand( From 2c60c772da34fbfb5e403b4e1f9b334d510b4218 Mon Sep 17 00:00:00 2001 From: GiveMe-A-Name Date: Fri, 27 Mar 2026 10:54:11 +0800 Subject: [PATCH 08/10] fix(env-dir): restore MODERN_ENV_DIR fallback and harden deploy test Co-Authored-By: Aiden --- packages/toolkit/plugin/src/cli/run/create.ts | 2 +- tests/integration/env-dir/tests/index.test.ts | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/toolkit/plugin/src/cli/run/create.ts b/packages/toolkit/plugin/src/cli/run/create.ts index 8f7c44b1653d..a3e8b997b22c 100644 --- a/packages/toolkit/plugin/src/cli/run/create.ts +++ b/packages/toolkit/plugin/src/cli/run/create.ts @@ -77,7 +77,7 @@ export const createCli = () => { setProgramVersion(version); const envName = metaName === 'modern-js' ? 'MODERN' : metaName; - const envDir = options.envDir; + const envDir = options.envDir ?? process.env.MODERN_ENV_DIR; const envCwd = ensureAbsolutePath(appDirectory, envDir || '.'); if (!isPathInside(appDirectory, envCwd)) { diff --git a/tests/integration/env-dir/tests/index.test.ts b/tests/integration/env-dir/tests/index.test.ts index b7f2a21bec4f..0e49f5f6722a 100644 --- a/tests/integration/env-dir/tests/index.test.ts +++ b/tests/integration/env-dir/tests/index.test.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'fs'; +import { existsSync, rmSync } from 'fs'; import path from 'path'; import puppeteer, { type Browser, type Page } from 'puppeteer'; import { @@ -137,6 +137,14 @@ describe('test env-dir build and serve', () => { 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, @@ -147,8 +155,6 @@ describe('test env-dir deploy', () => { }); expect(deployRes.code).toBe(0); - - const envDirEnvPath = path.join(appDir, 'dist', 'env', '.env.production'); expect(existsSync(envDirEnvPath)).toBe(true); }); }); From 3b8737c5f58bc4355d0ca5ed6dfcf20f48c0b696 Mon Sep 17 00:00:00 2001 From: GiveMe-A-Name Date: Fri, 27 Mar 2026 11:10:53 +0800 Subject: [PATCH 09/10] fix(env-dir): remove MODERN_ENV_DIR fallback behavior Co-Authored-By: Aiden --- .../core/src/adapters/node/helper/loadEnv.ts | 3 +-- .../server/core/tests/adapters/loadEnv.test.ts | 15 --------------- packages/toolkit/plugin/src/cli/run/create.ts | 2 +- 3 files changed, 2 insertions(+), 18 deletions(-) diff --git a/packages/server/core/src/adapters/node/helper/loadEnv.ts b/packages/server/core/src/adapters/node/helper/loadEnv.ts index d50484a9ba14..190a71cd1b43 100644 --- a/packages/server/core/src/adapters/node/helper/loadEnv.ts +++ b/packages/server/core/src/adapters/node/helper/loadEnv.ts @@ -11,8 +11,7 @@ import type { ServerBaseOptions } from '../../../serverBase'; export async function loadServerEnv(options: ServerBaseOptions) { const { pwd, envDir } = options; const serverEnv = process.env.MODERN_ENV; - const resolvedEnvDir = envDir ?? process.env.MODERN_ENV_DIR; - const envDirectory = resolveInsideOrFallback(pwd, resolvedEnvDir, pwd); + const envDirectory = resolveInsideOrFallback(pwd, envDir, pwd); const defaultEnvPath = path.resolve(envDirectory, `.env`); const serverEnvPath = path.resolve(envDirectory, `.env.${serverEnv}`); diff --git a/packages/server/core/tests/adapters/loadEnv.test.ts b/packages/server/core/tests/adapters/loadEnv.test.ts index 2652dbef2b2c..0d96bc4923f0 100644 --- a/packages/server/core/tests/adapters/loadEnv.test.ts +++ b/packages/server/core/tests/adapters/loadEnv.test.ts @@ -66,19 +66,4 @@ describe('test load serve env file', () => { delete process.env.USER_NAME; delete process.env.ENV; }); - - it('should load env from MODERN_ENV_DIR when envDir is not provided', async () => { - process.env.MODERN_ENV = 'prod'; - process.env.MODERN_ENV_DIR = 'env'; - await loadServerEnv({ - pwd: envPwd, - } 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; - delete process.env.MODERN_ENV_DIR; - }); }); diff --git a/packages/toolkit/plugin/src/cli/run/create.ts b/packages/toolkit/plugin/src/cli/run/create.ts index a3e8b997b22c..8f7c44b1653d 100644 --- a/packages/toolkit/plugin/src/cli/run/create.ts +++ b/packages/toolkit/plugin/src/cli/run/create.ts @@ -77,7 +77,7 @@ export const createCli = () => { setProgramVersion(version); const envName = metaName === 'modern-js' ? 'MODERN' : metaName; - const envDir = options.envDir ?? process.env.MODERN_ENV_DIR; + const envDir = options.envDir; const envCwd = ensureAbsolutePath(appDirectory, envDir || '.'); if (!isPathInside(appDirectory, envCwd)) { From 79ed288eaa462d5984e4cf40fc2b429f574b625f Mon Sep 17 00:00:00 2001 From: GiveMe-A-Name Date: Mon, 30 Mar 2026 17:21:34 +0800 Subject: [PATCH 10/10] fix(env-dir): propagate deploy env-dir into runtime entry Co-Authored-By: Aiden --- .../app-tools/src/plugins/deploy/index.ts | 16 ++++++++++++- .../src/plugins/deploy/platforms/netlify.ts | 2 ++ .../src/plugins/deploy/platforms/node.ts | 2 ++ .../src/plugins/deploy/platforms/platform.ts | 1 + .../src/plugins/deploy/platforms/vercel.ts | 2 ++ .../src/plugins/deploy/utils/generator.ts | 4 ++++ tests/integration/env-dir/tests/index.test.ts | 23 ++++++++++++++++++- 7 files changed, 48 insertions(+), 2 deletions(-) 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/tests/integration/env-dir/tests/index.test.ts b/tests/integration/env-dir/tests/index.test.ts index 0e49f5f6722a..c73b00c507c3 100644 --- a/tests/integration/env-dir/tests/index.test.ts +++ b/tests/integration/env-dir/tests/index.test.ts @@ -1,4 +1,4 @@ -import { existsSync, rmSync } from 'fs'; +import { existsSync, readFileSync, rmSync } from 'fs'; import path from 'path'; import puppeteer, { type Browser, type Page } from 'puppeteer'; import { @@ -151,12 +151,33 @@ describe('test env-dir deploy', () => { 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', () => {