From 38e9aca5365e693020041121aef3a399d4bdf73b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 15:19:37 -0800 Subject: [PATCH 1/2] fix(sim-setup): quote env values containing $ so dotenv, Bun, and Compose don't expand them --- packages/sim-setup/src/env-files.test.ts | 4 ++++ packages/sim-setup/src/env-files.ts | 11 ++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/sim-setup/src/env-files.test.ts b/packages/sim-setup/src/env-files.test.ts index 5872a23356a..da4226e3ef2 100644 --- a/packages/sim-setup/src/env-files.test.ts +++ b/packages/sim-setup/src/env-files.test.ts @@ -28,6 +28,10 @@ describe('upsertEnv', () => { } ) + it('single-quotes a value with $ so dotenv, Bun, and Compose do not expand it', () => { + expect(upsertEnv('', 'SMTP_PASSWORD', 'pa$$word$HOME')).toBe("SMTP_PASSWORD='pa$$word$HOME'\n") + }) + it('writes an ordinary value without quotes', () => { const dsn = 'postgresql://postgres:p%23w@127.0.0.1:5432/simstudio' expect(upsertEnv('', 'DATABASE_URL', dsn)).toBe(`DATABASE_URL=${dsn}\n`) diff --git a/packages/sim-setup/src/env-files.ts b/packages/sim-setup/src/env-files.ts index 331afb90092..dded1d1cee6 100644 --- a/packages/sim-setup/src/env-files.ts +++ b/packages/sim-setup/src/env-files.ts @@ -89,19 +89,20 @@ export function readEnvFile(target: EnvTarget): EnvFile { } /** - * Quotes a value only when `#`, edge whitespace, or a leading quote would change - * it for `parseValue`, Next's dotenv, Bun, or Docker Compose. Single quotes are - * preferred because all four read them literally. + * Quotes a value only when `#`, `$`, edge whitespace, or a leading quote would + * change it for `parseValue`, Next's dotenv, Bun, or Docker Compose (the last three + * expand `$NAME` in unquoted values). Single quotes are preferred because all four + * read them literally. */ function formatEnvValue(key: string, value: string): string { if (/[\r\n]/.test(value)) { throw new Error(`${key} cannot be written to an environment file: it contains a line break`) } - if (value === value.trim() && !value.includes('#') && !/^["'`]/.test(value)) return value + if (value === value.trim() && !/[#$]/.test(value) && !/^["'`]/.test(value)) return value if (!value.includes("'")) return `'${value}'` if (!/["\\$`]/.test(value)) return `"${value}"` throw new Error( - `${key} cannot be written to an environment file: it mixes quotes with a # or edge whitespace` + `${key} cannot be written to an environment file: it mixes quotes with a #, $, or edge whitespace` ) } From 3590acce8fa15c72ddbc00d9c1feb369e80a5592 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 15:31:09 -0800 Subject: [PATCH 2/2] fix(sim-setup): write $ as \$ in double quotes, the one form Next, Bun, and Compose all read literally --- packages/sim-setup/src/env-files.test.ts | 21 +++++++++++++++++---- packages/sim-setup/src/env-files.ts | 24 +++++++++++++++++------- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/packages/sim-setup/src/env-files.test.ts b/packages/sim-setup/src/env-files.test.ts index da4226e3ef2..b59370dae90 100644 --- a/packages/sim-setup/src/env-files.test.ts +++ b/packages/sim-setup/src/env-files.test.ts @@ -1,6 +1,7 @@ import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' +import { loadEnvConfig } from '@next/env' import { afterEach, describe, expect, it } from 'vitest' import { parseEnv, reconcileEnvContent, upsertEnv, writeEnvFile } from './env-files' @@ -21,16 +22,28 @@ describe('upsertEnv', () => { ).toThrow('Duplicate active RESEND_API_KEY entries') }) - it.each(['ab #cd', 'a#b', ' padded ', "'starts-with-quote", "it's #1"])( + it.each(['ab #cd', 'a#b', ' padded ', "'starts-with-quote", "it's #1", "it's $5 #1"])( 'reads back %j exactly as it was written', (value) => { expect(parseEnv(upsertEnv('', 'SECRET', value)).get('SECRET')).toBe(value) } ) - it('single-quotes a value with $ so dotenv, Bun, and Compose do not expand it', () => { - expect(upsertEnv('', 'SMTP_PASSWORD', 'pa$$word$HOME')).toBe("SMTP_PASSWORD='pa$$word$HOME'\n") - }) + it.each(['pa$$word', 'key$HOME', `key\${HOME}suffix`, "it's $5"])( + 'Next loads %j back exactly as it was written', + (value) => { + const root = mkdtempSync(path.join(tmpdir(), 'sim-setup-env-expand-')) + roots.push(root) + writeFileSync(path.join(root, '.env'), upsertEnv('', 'SIM_SETUP_EXPAND_PROBE', value)) + const silent = { info: () => {}, error: () => {} } + try { + const { parsedEnv } = loadEnvConfig(root, false, silent, true) + expect(parsedEnv?.SIM_SETUP_EXPAND_PROBE).toBe(value) + } finally { + Reflect.deleteProperty(process.env, 'SIM_SETUP_EXPAND_PROBE') + } + } + ) it('writes an ordinary value without quotes', () => { const dsn = 'postgresql://postgres:p%23w@127.0.0.1:5432/simstudio' diff --git a/packages/sim-setup/src/env-files.ts b/packages/sim-setup/src/env-files.ts index dded1d1cee6..e07f89746e8 100644 --- a/packages/sim-setup/src/env-files.ts +++ b/packages/sim-setup/src/env-files.ts @@ -67,7 +67,8 @@ function parseValue(raw: string): string { if (trimmed.startsWith('"') || trimmed.startsWith("'")) { const quote = trimmed[0] const end = trimmed.indexOf(quote, 1) - return end === -1 ? trimmed.slice(1) : trimmed.slice(1, end) + const inner = end === -1 ? trimmed.slice(1) : trimmed.slice(1, end) + return quote === '"' ? inner.replaceAll('\\$', '$') : inner } return trimmed.replace(/\s+#.*$/, '').trim() } @@ -90,19 +91,28 @@ export function readEnvFile(target: EnvTarget): EnvFile { /** * Quotes a value only when `#`, `$`, edge whitespace, or a leading quote would - * change it for `parseValue`, Next's dotenv, Bun, or Docker Compose (the last three - * expand `$NAME` in unquoted values). Single quotes are preferred because all four - * read them literally. + * change it for `parseValue`, Next's dotenv, Bun, or Docker Compose. Single quotes + * are preferred because all four read them literally, except `$`: Next and Bun + * expand `$NAME` even inside single quotes, and the one spelling all four read as a + * literal `$` is `\$` inside double quotes. */ function formatEnvValue(key: string, value: string): string { if (/[\r\n]/.test(value)) { throw new Error(`${key} cannot be written to an environment file: it contains a line break`) } - if (value === value.trim() && !/[#$]/.test(value) && !/^["'`]/.test(value)) return value + if (value.includes('$')) { + if (/["\\`]/.test(value)) { + throw new Error( + `${key} cannot be written to an environment file: it mixes $ with a double quote, backslash, or backtick` + ) + } + return `"${value.replaceAll('$', '\\$')}"` + } + if (value === value.trim() && !value.includes('#') && !/^["'`]/.test(value)) return value if (!value.includes("'")) return `'${value}'` - if (!/["\\$`]/.test(value)) return `"${value}"` + if (!/["\\`]/.test(value)) return `"${value}"` throw new Error( - `${key} cannot be written to an environment file: it mixes quotes with a #, $, or edge whitespace` + `${key} cannot be written to an environment file: it mixes quotes with a # or edge whitespace` ) }