diff --git a/packages/void/src/cli/secret.ts b/packages/void/src/cli/secret.ts index d8d182aec..ba5d84c27 100644 --- a/packages/void/src/cli/secret.ts +++ b/packages/void/src/cli/secret.ts @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs'; +import { parseEnv } from 'node:util'; import { outro, log, cancel, isCancel, password, confirm } from '@voidzero-dev/vite-plus-prompts'; import { getToken } from './auth.ts'; import { PlatformClient } from './client.ts'; @@ -155,32 +156,21 @@ async function resolveCloudflareSecretBackend( } const SECRET_KEY_RE = /^[A-Z][A-Z0-9_]*$/; +const UTF8_BOM = '\uFEFF'; +// Reads the file with Node's dotenv grammar, the same one `.env` loading uses, +// but calls `parseEnv` directly: shared/dotenv.ts would expand `$VAR`, and a +// `$` inside a secret value must stay literal. export function parseDotenvFile(filePath: string): Array<{ name: string; value: string }> { - const content = readFileSync(filePath, 'utf-8'); const secrets: Array<{ name: string; value: string }> = []; - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) { + const raw = readFileSync(filePath, 'utf-8'); + // parseEnv keeps a leading BOM as part of the first key name. + const text = raw.startsWith(UTF8_BOM) ? raw.slice(UTF8_BOM.length) : raw; + for (const [key, value] of Object.entries(parseEnv(text))) { + if (value === undefined) { continue; } - - const eqIndex = trimmed.indexOf('='); - if (eqIndex === -1) { - throw new Error(`secret: Invalid dotenv line '${trimmed}'. Add KEY=value.`); - } - - const key = trimmed.slice(0, eqIndex).trim(); - let value = trimmed.slice(eqIndex + 1).trim(); - - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - if (!SECRET_KEY_RE.test(key)) { throw new Error( `secret: Invalid secret name '${key}'. Use uppercase letters, digits, and underscores.`, diff --git a/packages/void/test/unit/secret-sync.test.ts b/packages/void/test/unit/secret-sync.test.ts index 41be8fe3f..a1cb93c62 100644 --- a/packages/void/test/unit/secret-sync.test.ts +++ b/packages/void/test/unit/secret-sync.test.ts @@ -68,11 +68,49 @@ describe('parseDotenvFile', () => { expect(() => parseDotenvFile(file)).toThrow("secret: Invalid secret name '1KEY'"); }); - it('errors on lines without =', () => { - const file = writeTemp('NOTAVALIDLINE'); - expect(() => parseDotenvFile(file)).toThrow( - "secret: Invalid dotenv line 'NOTAVALIDLINE'. Add KEY=value", + it('skips lines without =, like .env loading', () => { + const file = writeTemp('NOTAVALIDLINE\nAPI_KEY=abc'); + expect(parseDotenvFile(file)).toEqual([{ name: 'API_KEY', value: 'abc' }]); + }); + + it('drops inline comments after unquoted and quoted values', () => { + const file = writeTemp('STRIPE_KEY=sk_live_abc # live key\nTOKEN="abc" # note\nHASH=\'a#b\''); + expect(parseDotenvFile(file)).toEqual([ + { name: 'HASH', value: 'a#b' }, + { name: 'STRIPE_KEY', value: 'sk_live_abc' }, + { name: 'TOKEN', value: 'abc' }, + ]); + }); + + it('accepts the export prefix', () => { + const file = writeTemp('export API_KEY=abc'); + expect(parseDotenvFile(file)).toEqual([{ name: 'API_KEY', value: 'abc' }]); + }); + + it('reads multi-line quoted values and \\n in double quotes', () => { + const file = writeTemp( + 'PEM="-----BEGIN KEY-----\nAAAA\n-----END KEY-----"\nCERT="line1\\nline2"', ); + expect(parseDotenvFile(file)).toEqual([ + { name: 'CERT', value: 'line1\nline2' }, + { name: 'PEM', value: '-----BEGIN KEY-----\nAAAA\n-----END KEY-----' }, + ]); + }); + + it('keeps $VAR references literal', () => { + const file = writeTemp('BASE=x\nPASSWORD=p$BASE${BASE}'); + expect(parseDotenvFile(file)).toEqual([ + { name: 'BASE', value: 'x' }, + { name: 'PASSWORD', value: 'p$BASE${BASE}' }, + ]); + }); + + it('ignores a UTF-8 byte order mark at the start of the file', () => { + const file = writeTemp('API_KEY=abc\nSECRET=x'); + expect(parseDotenvFile(file)).toEqual([ + { name: 'API_KEY', value: 'abc' }, + { name: 'SECRET', value: 'x' }, + ]); }); it('returns empty array for empty file', () => {