Skip to content

Commit d549334

Browse files
toto6038autofix-ci[bot]sxzz
authored
feat: add envFile & envPrefix option (#664)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Kevin Deng <sxzz@sxzz.moe>
1 parent 3f031af commit d549334

File tree

9 files changed

+196
-3
lines changed

9 files changed

+196
-3
lines changed

docs/reference/cli.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,28 @@ tsdown --env.NODE_ENV=production
181181

182182
Note that environment variables defined with `--env.VAR_NAME` can only be accessed as `import.meta.env.VAR_NAME` or `process.env.VAR_NAME`.
183183

184+
## `--env-file <file>`
185+
186+
Load environment variables from a file. When used together with `--env`, variables in `--env` take precedence.
187+
188+
:::tip
189+
To prevent accidental exposure of sensitive information, only environment variables prefixed with `TSDOWN_` are injected by default. You can customize this behavior using the [`--env-prefix`](#env-prefix) flag.
190+
:::
191+
192+
```bash
193+
tsdown --env-file .env.production
194+
```
195+
196+
## `--env-prefix <prefix>` {#env-prefix}
197+
198+
When loading environment variables from a file via `--env-file`, only include variables that start with these prefixes.
199+
200+
- **Default:** `TSDOWN_`
201+
202+
```bash
203+
tsdown --env-file .env --env-prefix APP_ --env-prefix TSDOWN_
204+
```
205+
184206
## `--debug-logs [feat]`
185207

186208
Show debug logs.

docs/zh-CN/reference/cli.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,28 @@ tsdown --env.NODE_ENV=production
181181

182182
注意,通过 `--env.VAR_NAME` 定义的环境变量只能通过 `import.meta.env.VAR_NAME``process.env.VAR_NAME` 访问。
183183

184+
## `--env-file <file>`
185+
186+
从文件加载环境变量。当与 `--env` 一起使用时,`--env` 中的变量优先生效。
187+
188+
:::tip
189+
为防止敏感信息意外暴露,默认仅注入以 `TSDOWN_` 前缀开头的环境变量。您可以通过 [`--env-prefix`](#env-prefix) 标志自定义此行为。
190+
:::
191+
192+
```bash
193+
tsdown --env-file .env.production
194+
```
195+
196+
## `--env-prefix <prefix>` {#env-prefix}
197+
198+
通过 `--env-file` 加载环境变量时,仅包含以这些前缀开头的变量。
199+
200+
- **默认值:** `TSDOWN_`
201+
202+
```bash
203+
tsdown --env-file .env --env-prefix APP_ --env-prefix TSDOWN_
204+
```
205+
184206
## `--debug-logs [feat]`
185207

186208
显示调试日志。

dts.snapshot.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,10 @@
122122
"NoExternalFn": "type NoExternalFn = (_: string, _: string | undefined) => boolean | null | undefined | void",
123123
"CIOption": "type CIOption = 'ci-only' | 'local-only'",
124124
"WithEnabled": "type WithEnabled<T> = boolean | undefined | CIOption | (T & { enabled?: boolean | CIOption })",
125-
"UserConfig": "interface UserConfig {\n entry?: TsdownInputOption\n external?: ExternalOption\n noExternal?: Arrayable<string | RegExp> | NoExternalFn\n inlineOnly?: Arrayable<string | RegExp>\n skipNodeModulesBundle?: boolean\n alias?: Record<string, string>\n tsconfig?: string | boolean\n platform?: 'node' | 'neutral' | 'browser'\n target?: string | string[] | false\n env?: Record<string, any>\n define?: Record<string, string>\n shims?: boolean\n treeshake?: boolean | TreeshakingOptions\n loader?: ModuleTypes\n removeNodeProtocol?: boolean\n nodeProtocol?: 'strip' | boolean\n plugins?: InputOptions['plugins']\n inputOptions?: InputOptions | ((_: InputOptions, _: NormalizedFormat, _: { cjsDts: boolean }) => Awaitable<InputOptions | void | null>)\n format?: Format | Format[] | Partial<Record<Format, Partial<ResolvedConfig>>>\n globalName?: string\n outDir?: string\n write?: boolean\n sourcemap?: Sourcemap\n clean?: boolean | string[]\n minify?: boolean | 'dce-only' | MinifyOptions\n footer?: ChunkAddon\n banner?: ChunkAddon\n unbundle?: boolean\n bundle?: boolean\n fixedExtension?: boolean\n outExtensions?: OutExtensionFactory\n hash?: boolean\n cjsDefault?: boolean\n outputOptions?: OutputOptions | ((_: OutputOptions, _: NormalizedFormat, _: { cjsDts: boolean }) => Awaitable<OutputOptions | void | null>)\n cwd?: string\n name?: string\n silent?: boolean\n logLevel?: LogLevel\n failOnWarn?: boolean | CIOption\n customLogger?: Logger\n fromVite?: boolean | 'vitest'\n watch?: boolean | Arrayable<string>\n ignoreWatch?: Arrayable<string | RegExp>\n debug?: WithEnabled<DebugOptions>\n onSuccess?: string | ((_: ResolvedConfig, _: AbortSignal) => void | Promise<void>)\n dts?: WithEnabled<DtsOptions>\n unused?: WithEnabled<UnusedOptions>\n publint?: WithEnabled<PublintOptions>\n attw?: WithEnabled<AttwOptions>\n report?: WithEnabled<ReportOptions>\n globImport?: boolean\n exports?: WithEnabled<ExportsOptions>\n publicDir?: CopyOptions | CopyOptionsFn\n copy?: CopyOptions | CopyOptionsFn\n hooks?: Partial<TsdownHooks> | ((_: Hookable<TsdownHooks>) => Awaitable<void>)\n workspace?: Workspace | Arrayable<string> | true\n}",
125+
"UserConfig": "interface UserConfig {\n entry?: TsdownInputOption\n external?: ExternalOption\n noExternal?: Arrayable<string | RegExp> | NoExternalFn\n inlineOnly?: Arrayable<string | RegExp>\n skipNodeModulesBundle?: boolean\n alias?: Record<string, string>\n tsconfig?: string | boolean\n platform?: 'node' | 'neutral' | 'browser'\n target?: string | string[] | false\n env?: Record<string, any>\n envFile?: string\n envPrefix?: string | string[]\n define?: Record<string, string>\n shims?: boolean\n treeshake?: boolean | TreeshakingOptions\n loader?: ModuleTypes\n removeNodeProtocol?: boolean\n nodeProtocol?: 'strip' | boolean\n plugins?: InputOptions['plugins']\n inputOptions?: InputOptions | ((_: InputOptions, _: NormalizedFormat, _: { cjsDts: boolean }) => Awaitable<InputOptions | void | null>)\n format?: Format | Format[] | Partial<Record<Format, Partial<ResolvedConfig>>>\n globalName?: string\n outDir?: string\n write?: boolean\n sourcemap?: Sourcemap\n clean?: boolean | string[]\n minify?: boolean | 'dce-only' | MinifyOptions\n footer?: ChunkAddon\n banner?: ChunkAddon\n unbundle?: boolean\n bundle?: boolean\n fixedExtension?: boolean\n outExtensions?: OutExtensionFactory\n hash?: boolean\n cjsDefault?: boolean\n outputOptions?: OutputOptions | ((_: OutputOptions, _: NormalizedFormat, _: { cjsDts: boolean }) => Awaitable<OutputOptions | void | null>)\n cwd?: string\n name?: string\n silent?: boolean\n logLevel?: LogLevel\n failOnWarn?: boolean | CIOption\n customLogger?: Logger\n fromVite?: boolean | 'vitest'\n watch?: boolean | Arrayable<string>\n ignoreWatch?: Arrayable<string | RegExp>\n debug?: WithEnabled<DebugOptions>\n onSuccess?: string | ((_: ResolvedConfig, _: AbortSignal) => void | Promise<void>)\n dts?: WithEnabled<DtsOptions>\n unused?: WithEnabled<UnusedOptions>\n publint?: WithEnabled<PublintOptions>\n attw?: WithEnabled<AttwOptions>\n report?: WithEnabled<ReportOptions>\n globImport?: boolean\n exports?: WithEnabled<ExportsOptions>\n publicDir?: CopyOptions | CopyOptionsFn\n copy?: CopyOptions | CopyOptionsFn\n hooks?: Partial<TsdownHooks> | ((_: Hookable<TsdownHooks>) => Awaitable<void>)\n workspace?: Workspace | Arrayable<string> | true\n}",
126126
"InlineConfig": "interface InlineConfig extends UserConfig {\n config?: boolean | string\n configLoader?: 'auto' | 'native' | 'unrun'\n filter?: RegExp | Arrayable<string>\n}",
127127
"UserConfigFn": "type UserConfigFn = (_: InlineConfig, _: { ci: boolean }) => Awaitable<Arrayable<UserConfig>>",
128128
"UserConfigExport": "type UserConfigExport = Awaitable<Arrayable<UserConfig> | UserConfigFn>",
129-
"ResolvedConfig": "type ResolvedConfig = Overwrite<MarkPartial<Omit<UserConfig, 'workspace' | 'fromVite' | 'publicDir' | 'silent' | 'bundle' | 'removeNodeProtocol' | 'logLevel' | 'failOnWarn' | 'customLogger'>, 'globalName' | 'inputOptions' | 'outputOptions' | 'minify' | 'define' | 'alias' | 'external' | 'onSuccess' | 'outExtensions' | 'hooks' | 'copy' | 'loader' | 'name' | 'banner' | 'footer'>, { entry: Record<string, string>; nameLabel: string | undefined; format: NormalizedFormat; target?: string[]; clean: string[]; pkg?: PackageJsonWithPath; nodeProtocol: 'strip' | boolean; logger: Logger; ignoreWatch: Array<string | RegExp>; noExternal?: NoExternalFn; inlineOnly?: Array<string | RegExp>; dts: false | DtsOptions; report: false | ReportOptions; tsconfig: false | string; exports: false | ExportsOptions; debug: false | DebugOptions; publint: false | PublintOptions; attw: false | AttwOptions; unused: false | UnusedOptions }>"
129+
"ResolvedConfig": "type ResolvedConfig = Overwrite<MarkPartial<Omit<UserConfig, 'workspace' | 'fromVite' | 'publicDir' | 'silent' | 'bundle' | 'removeNodeProtocol' | 'logLevel' | 'failOnWarn' | 'customLogger' | 'envFile' | 'envPrefix'>, 'globalName' | 'inputOptions' | 'outputOptions' | 'minify' | 'define' | 'alias' | 'external' | 'onSuccess' | 'outExtensions' | 'hooks' | 'copy' | 'loader' | 'name' | 'banner' | 'footer'>, { entry: Record<string, string>; nameLabel: string | undefined; format: NormalizedFormat; target?: string[]; clean: string[]; pkg?: PackageJsonWithPath; nodeProtocol: 'strip' | boolean; logger: Logger; ignoreWatch: Array<string | RegExp>; noExternal?: NoExternalFn; inlineOnly?: Array<string | RegExp>; dts: false | DtsOptions; report: false | ReportOptions; tsconfig: false | string; exports: false | ExportsOptions; debug: false | DebugOptions; publint: false | PublintOptions; attw: false | AttwOptions; unused: false | UnusedOptions }>"
130130
}
131131
}

src/cli.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ cli
5656
.option('--from-vite [vitest]', 'Reuse config from Vite or Vitest')
5757
.option('--report', 'Size report', { default: true })
5858
.option('--env.* <value>', 'Define compile-time env variables')
59+
.option(
60+
'--env-file <file>',
61+
'Load environment variables from a file, when used together with --env, variables in --env take precedence',
62+
)
63+
.option(
64+
'--env-prefix <prefix>',
65+
'Prefix for env variables to inject into the bundle',
66+
{ default: 'TSDOWN_' },
67+
)
5968
.option('--on-success <command>', 'Command to run on success')
6069
.option('--copy <dir>', 'Copy files to output dir')
6170
.option('--public-dir <dir>', 'Alias for --copy, deprecated')

src/config/options.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { readFile } from 'node:fs/promises'
12
import path from 'node:path'
23
import process from 'node:process'
4+
import { parseEnv } from 'node:util'
35
import { blue } from 'ansis'
46
import { createDefu } from 'defu'
57
import isInCi from 'is-in-ci'
@@ -62,6 +64,8 @@ export async function resolveUserConfig(
6264
report = true,
6365
target,
6466
env = {},
67+
envFile,
68+
envPrefix = 'TSDOWN_',
6569
copy,
6670
publicDir,
6771
hash = true,
@@ -164,6 +168,28 @@ export async function resolveUserConfig(
164168
}
165169
}
166170

171+
envPrefix = toArray(envPrefix)
172+
if (envPrefix.includes('')) {
173+
logger.warn(
174+
'`envPrefix` includes an empty string; filtering is disabled. All environment variables from the env file and process.env will be injected into the build. Ensure this is intended to avoid accidental leakage of sensitive information.',
175+
)
176+
}
177+
const envFromProcess = filterEnv(process.env, envPrefix)
178+
if (envFile) {
179+
const resolvedPath = path.resolve(cwd, envFile)
180+
logger.info(nameLabel, `env file: ${color(resolvedPath)}`)
181+
182+
const parsed = parseEnv(await readFile(resolvedPath, 'utf8'))
183+
const envFromFile = filterEnv(parsed, envPrefix)
184+
185+
// precedence: env file < process.env < tsdown option
186+
env = { ...envFromFile, ...envFromProcess, ...env }
187+
} else {
188+
// precedence: process.env < tsdown option
189+
env = { ...envFromProcess, ...env }
190+
}
191+
debugLog(`Environment variables: %O`, env)
192+
167193
if (fromVite) {
168194
const viteUserConfig = await loadViteConfig(
169195
fromVite === true ? 'vite' : fromVite,
@@ -278,6 +304,23 @@ export async function resolveUserConfig(
278304
})
279305
}
280306

307+
/** filter env variables by prefixes */
308+
function filterEnv(
309+
envDict: Record<string, string | undefined>,
310+
envPrefixes: string[],
311+
) {
312+
const env: Record<string, string> = {}
313+
for (const [key, value] of Object.entries(envDict)) {
314+
if (
315+
envPrefixes.some((prefix) => key.startsWith(prefix)) &&
316+
value !== undefined
317+
) {
318+
env[key] = value
319+
}
320+
}
321+
return env
322+
}
323+
281324
const defu = createDefu((obj, key, value) => {
282325
if (Array.isArray(obj[key]) && Array.isArray(value)) {
283326
obj[key] = value

src/config/types.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ export interface UserConfig {
208208
target?: string | string[] | false
209209

210210
/**
211-
* Compile-time env variables.
211+
* Compile-time env variables, which can be accessed via `import.meta.env` or `process.env`.
212212
* @example
213213
* ```json
214214
* {
@@ -218,6 +218,17 @@ export interface UserConfig {
218218
* ```
219219
*/
220220
env?: Record<string, any>
221+
/**
222+
* Path to env file providing compile-time env variables.
223+
* @example
224+
* `.env`, `.env.production`, etc.
225+
*/
226+
envFile?: string
227+
/**
228+
* When loading env variables from `envFile`, only include variables with these prefixes.
229+
* @default 'TSDOWN_'
230+
*/
231+
envPrefix?: string | string[]
221232
define?: Record<string, string>
222233

223234
/** @default false */
@@ -565,6 +576,8 @@ export type ResolvedConfig = Overwrite<
565576
| 'logLevel' // merge to `logger`
566577
| 'failOnWarn' // merge to `logger`
567578
| 'customLogger' // merge to `logger`
579+
| 'envFile' // merged to `env`
580+
| 'envPrefix' // merged to `env`
568581
>,
569582
| 'globalName'
570583
| 'inputOptions'
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
## index.mjs
2+
3+
```mjs
4+
//#region index.ts
5+
const foo = "bar";
6+
const bar = "override";
7+
const custom = "tsdown";
8+
const debug = true;
9+
10+
//#endregion
11+
export { bar, custom, debug, foo };
12+
```
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
## index.mjs
2+
3+
```mjs
4+
//#region index.ts
5+
const foo = "foo";
6+
const bar = "bar";
7+
const custom = import.meta.env.CUSTOM;
8+
9+
//#endregion
10+
export { bar, custom, foo };
11+
```

tests/index.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,67 @@ test('env flag', async (context) => {
333333
expect(snapshot).contains('const debug = true')
334334
})
335335

336+
test('env-file flag', async (context) => {
337+
const files = {
338+
'index.ts': `export const foo = import.meta.env.TSDOWN_FOO
339+
export const bar = import.meta.env.TSDOWN_BAR
340+
export const custom = import.meta.env.CUSTOM
341+
export const debug = process.env.DEBUG
342+
`,
343+
'.env': `TSDOWN_FOO=bar
344+
TSDOWN_BAR=baz`,
345+
}
346+
const { snapshot } = await testBuild({
347+
context,
348+
files,
349+
options: {
350+
env: {
351+
CUSTOM: 'tsdown',
352+
DEBUG: true,
353+
TSDOWN_BAR: 'override',
354+
},
355+
envFile: '.env',
356+
},
357+
})
358+
expect(snapshot).contains('const foo = "bar"')
359+
expect(snapshot).contains(
360+
'const bar = "override"',
361+
'Env var from --env should override .env file',
362+
)
363+
expect(snapshot).contains('const custom = "tsdown"')
364+
expect(snapshot).contains('const debug = true')
365+
})
366+
367+
test('env-prefix flag', async (context) => {
368+
const files = {
369+
'index.ts': `export const foo = import.meta.env.MYAPP_FOO
370+
export const bar = import.meta.env.TSDOWN_BAR
371+
export const custom = import.meta.env.CUSTOM
372+
`,
373+
'.env': `MYAPP_FOO=foo
374+
TSDOWN_BAR=bar
375+
`,
376+
}
377+
const { snapshot } = await testBuild({
378+
context,
379+
files,
380+
options: {
381+
env: {
382+
MYAPP_FOO: 'foo',
383+
TSDOWN_BAR: 'bar',
384+
},
385+
envFile: '.env',
386+
envPrefix: ['MYAPP_', 'TSDOWN_'],
387+
},
388+
})
389+
expect(snapshot).contains('const foo = "foo"')
390+
expect(snapshot).contains('const bar = "bar"')
391+
expect(snapshot).contains(
392+
'const custom = import.meta.env.CUSTOM',
393+
'Unmatched prefix env var should not be replaced',
394+
)
395+
})
396+
336397
test('minify', async (context) => {
337398
const files = { 'index.ts': `export const foo = true` }
338399
const { snapshot } = await testBuild({

0 commit comments

Comments
 (0)