Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support loading TypeScript files #399

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/linux-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:

strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
node-version: [18.x, 20.x, 22.x, 23.x]

steps:
- name: Checkout Project
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/windows-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:

strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
node-version: [18.x, 20.x, 22.x, 23.x]

steps:
- name: Checkout Project
Expand Down
7 changes: 6 additions & 1 deletion src/get-env-vars.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { GetEnvVarOptions, Environment } from './types.ts'
import { getRCFileVars } from './parse-rc-file.js'
import { getEnvFileVars } from './parse-env-file.js'
import { isLoaderError } from './utils.js'

const RC_FILE_DEFAULT_LOCATIONS = ['./.env-cmdrc', './.env-cmdrc.js', './.env-cmdrc.json']
const ENV_FILE_DEFAULT_LOCATIONS = ['./.env', './.env.js', './.env.json']
Expand Down Expand Up @@ -34,7 +35,11 @@ export async function getEnvFile(
}
return env
}
catch {
catch (error) {
if (isLoaderError(error)) {
throw error
}

if (verbose === true) {
console.info(`Failed to find .env file at path: ${filePath}`)
}
Expand Down
11 changes: 11 additions & 0 deletions src/loaders/typescript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export function checkIfTypescriptSupported() {
if (!process.features.typescript) {
const error = new Error(
'To load typescript files with env-cmd, you need to enable ' +
'node’s --experimental-strip-types option, or upgrade to node ' +
'v23.6 or later. See https://nodejs.org/en/learn/typescript/run-natively',
);
Object.assign(error, { code: 'ERR_UNKNOWN_FILE_EXTENSION' });
throw error;
}
}
3 changes: 3 additions & 0 deletions src/parse-env-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { extname } from 'node:path'
import { pathToFileURL } from 'node:url'
import { resolveEnvFilePath, IMPORT_HOOK_EXTENSIONS, isPromise, importAttributesKeyword } from './utils.js'
import type { Environment } from './types.ts'
import { checkIfTypescriptSupported } from './loaders/typescript.js'

/**
* Gets the environment vars from an env file
Expand All @@ -19,6 +20,8 @@ export async function getEnvFileVars(envFilePath: string): Promise<Environment>
const ext = extname(absolutePath).toLowerCase()
let env: Environment = {}
if (IMPORT_HOOK_EXTENSIONS.includes(ext)) {
if (/tsx?$/.test(ext)) checkIfTypescriptSupported();

// For some reason in ES Modules, only JSON file types need to be specifically delinated when importing them
let attributeTypes = {}
if (ext === '.json') {
Expand Down
3 changes: 3 additions & 0 deletions src/parse-rc-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { extname } from 'node:path'
import { pathToFileURL } from 'node:url'
import { resolveEnvFilePath, IMPORT_HOOK_EXTENSIONS, isPromise, importAttributesKeyword } from './utils.js'
import type { Environment, RCEnvironment } from './types.ts'
import { checkIfTypescriptSupported } from './loaders/typescript.js'

const statAsync = promisify(stat)
const readFileAsync = promisify(readFile)
Expand All @@ -30,6 +31,8 @@ export async function getRCFileVars(
let parsedData: Partial<RCEnvironment> = {}
try {
if (IMPORT_HOOK_EXTENSIONS.includes(ext)) {
if (/tsx?$/.test(ext)) checkIfTypescriptSupported()

// For some reason in ES Modules, only JSON file types need to be specifically delinated when importing them
let attributeTypes = {}
if (ext === '.json') {
Expand Down
20 changes: 19 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,16 @@ import { homedir } from 'node:os'
import { cwd } from 'node:process'

// Special file extensions that node can natively import
export const IMPORT_HOOK_EXTENSIONS = ['.json', '.js', '.cjs', '.mjs']
export const IMPORT_HOOK_EXTENSIONS = [
'.json',
'.js',
'.cjs',
'.mjs',
'.ts',
'.mts',
'.cts',
'.tsx',
];

/**
* A simple function for resolving the path the user entered
Expand Down Expand Up @@ -33,6 +42,15 @@ export function isPromise<T>(value?: T | PromiseLike<T>): value is PromiseLike<T
&& typeof value.then === 'function'
}

/** @returns true if the error is `ERR_UNKNOWN_FILE_EXTENSION` */
export function isLoaderError(error: unknown): error is Error {
return (
error instanceof Error &&
'code' in error &&
error.code === 'ERR_UNKNOWN_FILE_EXTENSION'
);
}


// "Import Attributes" are only supported since node v18.20 and v20.10.
// For older node versions, we have to use "Import Assertions".
Expand Down
26 changes: 26 additions & 0 deletions test/parse-env-file.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,32 @@ describe('getEnvFileVars', (): void => {
THANKS: 'FOR ALL THE FISH',
ANSWER: 0,
})
});

(process.features.typescript ? describe : describe.skip)('TS', () => {
it('should parse a .ts file', async () => {
const env = await getEnvFileVars('./test/test-files/ts-test.ts');
assert.deepEqual(env, {
THANKS: 'FOR ALL THE FISH',
ANSWER: 1,
});
});

it('should parse a .cts file', async () => {
const env = await getEnvFileVars('./test/test-files/cts-test.cts');
assert.deepEqual(env, {
THANKS: 'FOR ALL THE FISH',
ANSWER: 0,
});
});

it('should parse a .tsx file', async () => {
const env = await getEnvFileVars('./test/test-files/tsx-test.tsx');
assert.deepEqual(env, {
THANKS: 'FOR ALL THE FISH',
ANSWER: 2,
});
});
})

it('should parse an env file', async (): Promise<void> => {
Expand Down
5 changes: 5 additions & 0 deletions test/test-files/cts-test.cts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const env: unknown = {
THANKS: 'FOR ALL THE FISH',
ANSWER: 0,
};
export default env;
7 changes: 7 additions & 0 deletions test/test-files/ts-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { Environment } from '../../src/types.js';

const env: Environment = {
THANKS: 'FOR ALL THE FISH',
ANSWER: 1,
};
export default env;
8 changes: 8 additions & 0 deletions test/test-files/tsx-test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { Environment } from '../../src/types.js';

const env: Environment = {
THANKS: 'FOR ALL THE FISH',
ANSWER: 2,
};

export default env;
Loading