From 7756ebe9827e7a2218c2030d689d2fdf83b864e5 Mon Sep 17 00:00:00 2001 From: shulaoda <165626830+shulaoda@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:07:46 +0800 Subject: [PATCH] fix(cli): expand deploy env vars so shipped values match validation --- packages/void/src/cli/deploy.ts | 35 +++------------ .../void/test/unit/deploy-env-expand.test.ts | 45 +++++++++++++++++++ 2 files changed, 51 insertions(+), 29 deletions(-) create mode 100644 packages/void/test/unit/deploy-env-expand.test.ts diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 164dc1fbd..4f1e5146f 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -53,6 +53,7 @@ import { requiresSandboxPlatformImage, resolveSandboxConfig, } from '../sandbox/config.ts'; +import { mergeAndExpand, parseDotenvFile } from '../shared/dotenv.ts'; import { resolveRemoteMode, stripInternalEnvKeys } from '../shared/env.ts'; import { validateSsrEntry } from '../ssr/entry.ts'; import { getApiUrl, getToken, getTokenSource } from './auth.ts'; @@ -2303,36 +2304,12 @@ async function assertEnvSchemaValid( * Load env vars for deploy — only .env and .env.production (not .local files). * Later files win on conflict. */ -function loadDeployEnv(root: string): Record { +export function loadDeployEnv(root: string): Record { + // Same parse + merge + expand path env validation uses, so the shipped + // values match what was validated. const files = ['.env', '.env.production']; - const env: Record = {}; - for (const file of files) { - const filePath = join(root, file); - if (!existsSync(filePath)) { - continue; - } - const content = readFileSync(filePath, 'utf-8'); - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) { - continue; - } - const eqIndex = trimmed.indexOf('='); - if (eqIndex === -1) { - continue; - } - const key = trimmed.slice(0, eqIndex).trim(); - let value = trimmed.slice(eqIndex + 1).trim(); - // Strip surrounding quotes - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - env[key] = value; - } - } + const parsed = files.map((file) => parseDotenvFile(join(root, file), { expand: false })); + const env = mergeAndExpand(parsed, { onWarn: (msg) => voidWarn(msg) }); const { sanitized, stripped } = stripInternalEnvKeys(env); if (stripped.length > 0) { voidWarn( diff --git a/packages/void/test/unit/deploy-env-expand.test.ts b/packages/void/test/unit/deploy-env-expand.test.ts new file mode 100644 index 000000000..17cf1bccd --- /dev/null +++ b/packages/void/test/unit/deploy-env-expand.test.ts @@ -0,0 +1,45 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; +import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { loadDeployEnv } from '../../src/cli/deploy.ts'; + +describe('loadDeployEnv', () => { + const roots: Array = []; + + afterEach(() => { + for (const root of roots.splice(0, roots.length)) { + rmSync(root, { recursive: true, force: true }); + } + }); + + function makeRoot(files: Record): string { + const root = mkdtempSync(join(tmpdir(), 'void-deploy-env-')); + roots.push(root); + for (const [name, content] of Object.entries(files)) { + writeFileSync(join(root, name), content); + } + return root; + } + + it('expands ${VAR} references so shipped values match what was validated', () => { + const root = makeRoot({ + '.env': 'PLAIN=hello\nAPI_HOST=api.example.com\nAPI_URL=https://${API_HOST}/v1\n', + }); + + const env = loadDeployEnv(root); + + expect(env.PLAIN).toBe('hello'); + expect(env.API_HOST).toBe('api.example.com'); + expect(env.API_URL).toBe('https://api.example.com/v1'); + }); + + it('expands references across .env and .env.production', () => { + const root = makeRoot({ + '.env': 'BASE=example.com\n', + '.env.production': 'URL=https://${BASE}/prod\n', + }); + + expect(loadDeployEnv(root).URL).toBe('https://example.com/prod'); + }); +});