diff --git a/src/env-cmd.ts b/src/env-cmd.ts index d29f587..fe24739 100644 --- a/src/env-cmd.ts +++ b/src/env-cmd.ts @@ -56,7 +56,7 @@ export async function EnvCmd( const proc = spawn(command, commandArgs, { stdio: 'inherit', shell: options.useShell, - env: env as Record, + env, }) // Handle any termination signals for parent and child proceses diff --git a/src/parse-env-file.ts b/src/parse-env-file.ts index 7e44d88..d2a7b00 100644 --- a/src/parse-env-file.ts +++ b/src/parse-env-file.ts @@ -17,16 +17,16 @@ export async function getEnvFileVars(envFilePath: string): Promise // Get the file extension const ext = extname(absolutePath).toLowerCase() - let env: Environment = {} + let env: unknown; if (IMPORT_HOOK_EXTENSIONS.includes(ext)) { // For some reason in ES Modules, only JSON file types need to be specifically delinated when importing them let attributeTypes = {} if (ext === '.json') { attributeTypes = { [importAttributesKeyword]: { type: 'json' } } } - const res = await import(pathToFileURL(absolutePath).href, attributeTypes) as Environment | { default: Environment } - if ('default' in res) { - env = res.default as Environment + const res: unknown = await import(pathToFileURL(absolutePath).href, attributeTypes) + if (typeof res === 'object' && res && 'default' in res) { + env = res.default } else { env = res } @@ -35,7 +35,7 @@ export async function getEnvFileVars(envFilePath: string): Promise env = await env } - return env; + return normalizeEnvObject(env, absolutePath) } const file = readFileSync(absolutePath, { encoding: 'utf8' }) @@ -83,22 +83,10 @@ export function parseEnvVars(envString: string): Environment { // inline comments. value = value.split('#')[0].trim(); } - + value = value.replace(/\\n/g, '\n'); - // Convert string to JS type if appropriate - if (value !== '' && !isNaN(+value)) { - matches[key] = +value - } - else if (value === 'true') { - matches[key] = true - } - else if (value === 'false') { - matches[key] = false - } - else { - matches[key] = value - } + matches[key] = value } return JSON.parse(JSON.stringify(matches)) as Environment } @@ -118,3 +106,27 @@ export function stripEmptyLines(envString: string): string { const emptyLinesRegex = /(^\n)/gim return envString.replace(emptyLinesRegex, '') } + +/** + * If we load data from a file like .js, the user + * might export something which is not an object. + * + * This function ensures that the input is valid, + * and converts the object's values to strings, for + * consistincy. See issue #125 for details. + */ +export function normalizeEnvObject(input: unknown, absolutePath: string): Environment { + if (typeof input !== 'object' || !input) { + throw new Error(`env-cmd cannot load “${absolutePath}” because it does not export an object.`) + } + + const env: Environment = {}; + for (const [key, value] of Object.entries(input)) { + // we're intentionally stringifying the value here, to + // match what `child_process.spawn` does when loading + // env variables. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + env[key] = `${value}` + } + return env +} diff --git a/src/types.ts b/src/types.ts index 93d40a1..dfad531 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { Command } from '@commander-js/extra-typings' // Define an export type -export type Environment = Partial> +export type Environment = Partial> export type RCEnvironment = Partial> diff --git a/test/parse-env-file.spec.ts b/test/parse-env-file.spec.ts index cb01827..7b3efae 100644 --- a/test/parse-env-file.spec.ts +++ b/test/parse-env-file.spec.ts @@ -29,8 +29,8 @@ describe('parseEnvVars', (): void => { assert(envVars.BOB === 'COOL') assert(envVars.NODE_ENV === 'dev') assert(envVars.ANSWER === '42 AND COUNTING') - assert(envVars.NUMBER === 42) - assert(envVars.BOOLEAN === true) + assert(envVars.NUMBER === '42') + assert(envVars.BOOLEAN === 'true') }) it('should parse out all env vars in string with format \'key=value\'', (): void => { @@ -133,7 +133,7 @@ describe('parseEnvString', (): void => { const env = parseEnvString('BOB=COOL\nNODE_ENV=dev\nANSWER=42\n') assert(env.BOB === 'COOL') assert(env.NODE_ENV === 'dev') - assert(env.ANSWER === 42) + assert(env.ANSWER === '42') }) }) @@ -142,10 +142,10 @@ describe('getEnvFileVars', (): void => { const env = await getEnvFileVars('./test/test-files/test.json') assert.deepEqual(env, { THANKS: 'FOR WHAT?!', - ANSWER: 42, + ANSWER: '42', ONLY: 'IN PRODUCTION', GALAXY: 'hitch\nhiking', - BRINGATOWEL: true, + BRINGATOWEL: 'true', }) }) @@ -153,10 +153,10 @@ describe('getEnvFileVars', (): void => { const env = await getEnvFileVars('./test/test-files/test-newlines.json') assert.deepEqual(env, { THANKS: 'FOR WHAT?!', - ANSWER: 42, + ANSWER: '42', ONLY: 'IN\n PRODUCTION', GALAXY: 'hitch\nhiking\n\n', - BRINGATOWEL: true, + BRINGATOWEL: 'true', }) }) @@ -164,7 +164,7 @@ describe('getEnvFileVars', (): void => { const env = await getEnvFileVars('./test/test-files/test.cjs') assert.deepEqual(env, { THANKS: 'FOR ALL THE FISH', - ANSWER: 0, + ANSWER: '0', GALAXY: 'hitch\nhiking', }) }) @@ -173,7 +173,7 @@ describe('getEnvFileVars', (): void => { const env = await getEnvFileVars('./test/test-files/test-async.cjs') assert.deepEqual(env, { THANKS: 'FOR ALL THE FISH', - ANSWER: 0, + ANSWER: '0', }) }) @@ -181,7 +181,7 @@ describe('getEnvFileVars', (): void => { const env = await getEnvFileVars('./test/test-files/test.mjs') assert.deepEqual(env, { THANKS: 'FOR ALL THE FISH', - ANSWER: 0, + ANSWER: '0', GALAXY: 'hitch\nhiking', }) }) @@ -190,7 +190,7 @@ describe('getEnvFileVars', (): void => { const env = await getEnvFileVars('./test/test-files/test-async.mjs') assert.deepEqual(env, { THANKS: 'FOR ALL THE FISH', - ANSWER: 0, + ANSWER: '0', }) }) @@ -198,13 +198,13 @@ describe('getEnvFileVars', (): void => { const env = await getEnvFileVars('./test/test-files/test') assert.deepEqual(env, { THANKS: 'FOR WHAT?!', - ANSWER: 42, + ANSWER: '42', ONLY: 'IN=PRODUCTION', GALAXY: 'hitch\nhiking', - BRINGATOWEL: true, - a: 1, - b: 2, - c: 3, + BRINGATOWEL: 'true', + a: '1', + b: '2', + c: '3', d: "=", e: "equals symbol = = ", json_no_quotes: "{\"foo\": \"bar\"}", @@ -223,4 +223,16 @@ describe('getEnvFileVars', (): void => { assert.match(e.message, /file path/gi) } }) + + for (const fileExt of ['cjs', 'mjs', 'json']) { + it(`should throw an error when importing a ${fileExt} file with an invalid export`, async () => { + try { + await getEnvFileVars(`./test/test-files/invalid.${fileExt}`) + assert.fail('Should not get here!') + } catch (e) { + assert.instanceOf(e, Error) + assert.match(e.message, /does not export an object/gi) + } + }) + } }) diff --git a/test/test-files/invalid.cjs b/test/test-files/invalid.cjs new file mode 100644 index 0000000..d0f5f3a --- /dev/null +++ b/test/test-files/invalid.cjs @@ -0,0 +1 @@ +module.exports = undefined; diff --git a/test/test-files/invalid.json b/test/test-files/invalid.json new file mode 100644 index 0000000..190a180 --- /dev/null +++ b/test/test-files/invalid.json @@ -0,0 +1 @@ +123 diff --git a/test/test-files/invalid.mjs b/test/test-files/invalid.mjs new file mode 100644 index 0000000..ea35516 --- /dev/null +++ b/test/test-files/invalid.mjs @@ -0,0 +1 @@ +export default "this is invalid; it's not an object";