From f6d4f1a9903f31f94e44d067f7b1563402aa053d Mon Sep 17 00:00:00 2001 From: recursivefunk Date: Thu, 19 Feb 2026 11:36:35 -0500 Subject: [PATCH 1/2] chore: convert library source from JavaScript to TypeScript Replaces src/index.js and src/lib/url-types.js with TypeScript equivalents, adds tsconfig.json targeting CommonJS/ES2020 with strict mode, and wires up build/typecheck scripts. The compiled output lands in dist/ (now the package main), replacing the hand-written src/index.d.ts with generated declarations. Test files remain JavaScript and are updated to require from dist/. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + package-lock.json | 35 ++- package.json | 26 +- src/index.d.ts | 151 ---------- src/index.js | 385 ------------------------- src/index.ts | 318 ++++++++++++++++++++ src/lib/{url-types.js => url-types.ts} | 28 +- test/test.js | 2 +- tsconfig.json | 20 ++ 9 files changed, 409 insertions(+), 557 deletions(-) delete mode 100644 src/index.d.ts delete mode 100644 src/index.js create mode 100644 src/index.ts rename src/lib/{url-types.js => url-types.ts} (54%) create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index aecacbc..5e0883e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ Thumbs.db .env test.env node_modules/ +dist/ .nyc_output/ coverage/ diff --git a/package-lock.json b/package-lock.json index 193a273..2a7e39a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,12 @@ "version": "7.6.1", "license": "MIT", "devDependencies": { + "@types/node": "^25.3.0", "dotenv": "^16.4.5", "nyc": "15.1.0", "semistandard": "*", - "tape": "5.5.3" + "tape": "5.5.3", + "typescript": "^5.9.3" }, "engines": { "node": ">=18.20.4" @@ -512,6 +514,16 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/node": { + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.1.tgz", @@ -4872,6 +4884,20 @@ "is-typedarray": "^1.0.0" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -4890,6 +4916,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", diff --git a/package.json b/package.json index 6dbd00a..c8dd657 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,15 @@ "name": "good-env", "version": "7.6.1", "description": "Better environment variable handling for Twelve-Factor node apps", - "main": "src/index.js", + "main": "dist/index.js", + "types": "dist/index.d.ts", "scripts": { - "test": "nyc --check-coverage --lines 100 node test/test.js", - "lint": "semistandard test/ src/index.js", - "ci": "semistandard test/test.js src/index.js && nyc --check-coverage --lines 100 node test/test.js", - "format": "semistandard --fix test/test.js src/*.js" + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "npm run build && nyc --check-coverage --lines 100 node test/test.js", + "lint": "semistandard test/", + "ci": "tsc --noEmit && semistandard test/test.js && npm run build && nyc --check-coverage --lines 100 node test/test.js", + "format": "semistandard --fix test/test.js" }, "keywords": [ "environment", @@ -26,9 +29,12 @@ "node": ">=18.20.4" }, "files": [ - "src/index.js", - "src/lib/url-types.js", - "src/index.d.ts" + "dist/index.js", + "dist/index.d.ts", + "dist/index.js.map", + "dist/index.d.ts.map", + "dist/lib/url-types.js", + "dist/lib/url-types.d.ts" ], "directories": { "test": "test" @@ -42,9 +48,11 @@ }, "homepage": "https://github.com/recursivefunk/good-env#readme", "devDependencies": { + "@types/node": "^25.3.0", "dotenv": "^16.4.5", "nyc": "15.1.0", + "semistandard": "*", "tape": "5.5.3", - "semistandard": "*" + "typescript": "^5.9.3" } } diff --git a/src/index.d.ts b/src/index.d.ts deleted file mode 100644 index a4760c1..0000000 --- a/src/index.d.ts +++ /dev/null @@ -1,151 +0,0 @@ -declare module "good-env" { - export const set: (key: string, value: string) => void; - /** - * @description Tell Good Env to go to secrets manager, grab the object under the specified secretId and merge it with the - * environment. - * @param {any} awsSecretsManager - An instance of AWS Secrets Manager is imported from the SDK - * @param {string} awsSecretsManager - The secret ID to use to fetch the secrets object. If not supplied, the function will - * check environment variables AWS_SECRET_ID and SECRET_ID. If neither of which are defined, the function will throw an error - */ - export const use: (awsSecretsManager: any, secretId?: string) => Promise; - /** - * @description Fetches an IP address from the environment. If the value found under the specified key is not a valid IPv4 - * or IPv6 IP and there's no default value, null is returned. If a default value is provided and it is a valid IPv4 or IPv6 - * IP, the default value is retruned. If the default value is not valid, null is returned. This function won't return a value - * that is not a valid IP address. - * @param {string} key - A unique key that points to the IP address - * @param {string} defaultVal - The default IP address to be used if the key isn't found - * @returns - */ - export const getIP: (key: string, defaultVal?: string) => any; - /** - * @description Fetches three commonly used AWS environment variables - access key id, secret access key and region. - * Note: You can only pass in a default region. No defaults for access key id or access key will be honored. This also - * function assumes the standard AWS naming convention being used. - * @param {object} defaults - * @returns {object} - */ - export const getAWS: (defaults?: object) => object; - /** - * @description Finds the URL string in the environment associated with the given key. If - * it's found, the function tries to construct a URL object. If the URL is invalid, return null. - * If the URL is valid, return the URL object. If the key is not found and a default value is - * passed, we try to construct a URL using the default value. If anything goes wrong with the - * construction of the URL, return null. - * - * @param {string} key - A unique key that points to the URL - * @param {string} defaultVal - The default URL to be used if the key isn't found - * @returns {object} - */ - export const getUrl: (key: string, defaultVal?: string) => any; - /** - * @description This is the shortcut function for the getUrl function - * @returns {object} - */ - export const url: (key: string, defaultVal?: string) => any; - /** - * @description Fetches the env var with the given key. If no env var - * with the specified key exists, the default value is returned if it is - * provided else it returns undefined - * - * @param {(string|string[])} keyObj - A unique key for an item or a list of possible keys - * @param {(string|number)} defaultVal - The default value of an item if it doesn't - * already exist - * - */ - export const get: (keyObj: any, defaultVal?: any) => any; - /** - * @description Determines whether or not all of the values given key is - * truthy - * - * @param {(string|string[])} keys - A unique key or array of keys - * - */ - export const ok: (...keys: any[]) => boolean; - /** - * @description Gets all items specified in the object. If the item is an - * array, the function will perform a standard get with no defaults. If the - * item is an object {}, the function will use the values as defaults - - * null values will be treated as no default specified - * - * @param {string[]} items - An array of keys - * - */ - export const getAll: (items: any) => any; - /** - * @description This method ensures 1 to many environment variables either - * exist, or exist and are of a designated type - * - * @example - * ensure( - * // Will ensure 'HOSTNAME' exists - * 'HOSTNAME', - * // Will ensure 'PORT' both exists and is a number - * { 'PORT': { type: 'number' }}, - * // Will ensure 'INTERVAL' exists, it's a number and its value is greater - * // than or equal to 1000 - * { 'INTERVAL': { type: 'number', ok: s => s >= 1000 }} - * // ... - * ) - * - */ - export const ensure: (...items: any[]) => boolean; - /** - * An alias for .ensure() - */ - export const assert: (...items: any[]) => boolean; - /** - * @description Fetches the value at the given key and attempts to coerce - * it into a boolean - * - * @param {string} key - A unique key - * @param {boolean} defaultVal - The default value - * - */ - export const getBool: (key: any, defaultVal?: any) => any; - /** - * @description An alias function for getBool() - * - * @param {string} key - A unique key - * @param {boolean} defaultVal - The default value if none exists - * - */ - export const bool: (key: any, defaultVal?: any) => any; - /** - * @description Fetches the value at the given key and attempts to - * coherse it into an integer - * - * @param {string} key - A unique key - * @param {number} defaultVal - The default value - * - */ - export const getNumber: (key: any, defaultVal?: any) => any; - /** - * @description An alias function for getNumber() - * - */ - export const num: (key: any, defaultVal?: any) => any; - /** - * @description Fetches the value at the given key and attempts to - * coherse it into a list of literal values - * - * @param {string} key - A unique key - * @param {object} options - * - */ - export const getList: ( - key: any, - opts?: { - dilim: string; - cast: any; - } - ) => any; - /** - * @description An alias function for getList() - * - * @param {string} key - A unique key - * @param {object} options - * - */ - export const list: (key: any, opts: any) => any; -} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 011cec7..0000000 --- a/src/index.js +++ /dev/null @@ -1,385 +0,0 @@ -'use strict'; -const { isIP } = require('node:net'); -const { URL } = require('node:url'); -const { makeGoodUrl } = require('./lib/url-types'); -const ok = x => !!x; -const isArray = Array.isArray; -const is = x => Object.prototype.toString.call(x); -const isString = x => is(x) === '[object String]'; -const isNumber = x => is(x) === '[object Number]'; -const isBoolean = x => is(x) === '[object Boolean]'; -const isObject = x => is(x) === '[object Object]'; -const isFunction = x => is(x) === '[object Function]'; -const parse = (items, converter) => items.map(t => converter(t, 10)); -const mapNums = items => parse(items, parseInt); -const validType = item => ['number', 'boolean', 'string'].includes(item); -const store = { ...process.env }; - -module.exports = Object - .create({ - set (key, value) { - process.env[key] = value; - store[key] = value; - }, - async use (awsSecretsManager, secretId) { - const { SecretsManagerClient, GetSecretValueCommand } = awsSecretsManager; - const client = new SecretsManagerClient({ - region: process.env.AWS_REGION || 'us-east-1' - }); - - if (!secretId) { - secretId = this.get(['AWS_SECRET_ID', 'SECRET_ID']); - } - - if (!secretId) { - throw new Error('\'secretId\' was not specified, and it wasn\'t found as \'AWS_SECRET_ID\' or \'SECRET_ID\' in the environment.'); - } - - const response = await client.send( - new GetSecretValueCommand({ - SecretId: secretId, - VersionStage: 'AWSCURRENT' // VersionStage defaults to AWSCURRENT if unspecified - }) - ); - const secretStr = response.SecretString; - const secret = JSON.parse(secretStr); - Object.entries(secret).forEach(([key, value]) => { - this.set(key, value); - }); - }, - /** - * @description Fetches an IP address from the environment. If the value found under the specified key is not a valid IPv4 - * or IPv6 IP and there's no default value, null is returned. If a default value is provided and it is a valid IPv4 or IPv6 - * IP, the default value is retruned. If the default value is not valid, null is returned. This function won't return a value - * that is not a valid IP address. - * @param {string} key - A unique key that points to the IP address - * @param {string} defaultVal - The default IP address to be used if the key isn't found - * @returns - */ - getIP (key, defaultVal) { - const strIP = this.get(key); - - if (!strIP) { - if (!isIP(defaultVal)) { - return null; - } else { - return defaultVal; - } - } - - if (isIP(strIP)) return strIP; - if (isIP(defaultVal)) return defaultVal; - return null; - }, - /** - * @description Fetches three commonly used AWS environment variables - access key id, secret access key and region. - * Note: You can only pass in a default region. No defaults for access key id or access key will be honored. This also - * function assumes the standard AWS naming convention being used. - * @param {object} defaults - * @returns {object} - */ - getAWS ({ region } = {}) { - const { - AWS_ACCESS_KEY_ID: awsKeyId, - AWS_SECRET_ACCESS_KEY: awsSecretAccessKey, - AWS_SESSION_TOKEN: awsSessionToken, - AWS_REGION: awsRegion - } = this.getAll({ - AWS_ACCESS_KEY_ID: null, - AWS_SECRET_ACCESS_KEY: null, - AWS_SESSION_TOKEN: null, - AWS_REGION: region - }); - - return { - awsKeyId, - awsSecretAccessKey, - awsSessionToken, - awsRegion - }; - }, - /** - * @description Finds the URL string in the environment associated with the given key. If - * it's found, the function tries to construct a URL object. If the URL is invalid, return null. - * If the URL is valid, return the URL object. If the key is not found and a default value is - * passed, we try to construct a URL using the default value. If anything goes wrong with the - * construction of the URL, return null. - * - * @param {string} key - A unique key that points to the URL - * @param {string} defaultVal - The default URL to be used if the key isn't found - * @returns {object} - */ - getUrl (key, defaultVal) { - let urlStr = this.get(key, defaultVal); - - if (urlStr === defaultVal) { - urlStr = defaultVal; - } - - try { - return makeGoodUrl({ url: new URL(urlStr) }); - } catch (e) { - return null; - } - }, - - /** - * @description This is the shortcut function for the getUrl function - * @returns {object} - */ - url (key, defaultVal) { - return this.getUrl(key, defaultVal); - }, - - /** - * @description Fetches the env var with the given key. If no env var - * with the specified key exists, the default value is returned if it is - * provided else it returns undefined - * - * @param {(string|string[])} keyObj - A unique key for an item or a list of possible keys - * @param {(string|number)} defaultVal - The default value of an item if it doesn't - * already exist - * - */ - get (keyObj, defaultVal) { - let keys; - let value; - - if (isString(keyObj)) { - keys = [keyObj]; - } else if (isArray(keyObj)) { - keys = keyObj.map(k => k.trim()); - } else { - throw Error(`Invalid key(s) ${keyObj}`); - } - - keys.some(key => { - if (ok(store[key])) { - value = store[key]; - return true; - } - return false; - }); - - if (!ok(value) && typeof ok(defaultVal)) { - value = defaultVal; - } - - return (isString(value)) ? value.trim() : value; - }, - - /** - * @description Gets all items specified in the object. If the item is an - * array, the function will perform a standard get with no defaults. If the - * item is an object {}, the function will use the values as defaults - - * null values will be treated as no default specified - * - * @param {string[]} items - An array of keys - * - */ - getAll (items) { - const objReducer = (obj, getter) => ( - Object.keys(obj).reduce((prev, next, index) => { - prev[next] = getter(next, obj[next]); - return prev; - }, {}) - ); - - const arrMapper = (keys, getter) => items.map(key => getter(key)); - - if (isArray(items)) { - return arrMapper(items, this.get); - } else if (isObject(items)) { - return objReducer(items, this.get); - } else { - throw Error(`Invalid arg ${items}`); - } - }, - - /** - * @description Determines whether or not all of the values given key is - * truthy - * - * @param {(string|string[])} keys - A unique key or array of keys - * - */ - ok: (...keys) => keys.every(key => ok(store[key])), - - /** - * @description This method ensures 1 to many environment variables either - * exist, or exist and are of a designated type - * - * @example - * ensure( - * // Will ensure 'HOSTNAME' exists - * 'HOSTNAME', - * // Will ensure 'PORT' both exists and is a number - * { 'PORT': { type: 'number' }}, - * // Will ensure 'INTERVAL' exists, it's a number and its value is greater - * // than or equal to 1000 - * { 'INTERVAL': { type: 'number', ok: s => s >= 1000 }} - * // ... - * ) - * - */ - ensure (...items) { - const self = this; - const getKit = item => { - switch (item) { - case 'string': - return { validator: isString, getter: self.get.bind(self) }; - case 'number': - return { validator: isNumber, getter: self.getNumber.bind(self) }; - case 'boolean': - return { validator: isBoolean, getter: self.getBool.bind(self) }; - /* istanbul ignore next */ - default: - throw Error(`Invalid type "${item}"`); - } - }; - - return items.every(item => { - if (isString(item)) { - if (this.ok(item)) { - return true; - } else { - throw Error(`No environment configuration for var "${item}"`); - } - } else if (isObject(item)) { - const key = Object.keys(item)[0]; - const type = item[key].type; - const validator = item[key].ok; - - /* istanbul ignore if */ - if (type && !validType(type)) { - throw Error(`Invalid expected type "${type}"`); - } else { - const kit = getKit(type); - const val = kit.getter(key); - const result = kit.validator(val); - if (!result) { - throw Error(`Unexpected result for key="${key}". It may not exist or may not be a valid "${type}"`); - } - - if (validator && isFunction(validator)) { - const valid = validator(val); - if (!valid) { - throw Error(`Value ${val} did not pass validator function for key "${key}"`); - } - } - return true; - } - } else { - throw Error(`Invalid key ${item}`); - } - }); - }, - - /** - * An alias for .ensure() - */ - assert (...items) { - return this.ensure(...items); - }, - - /** - * @description Fetches the value at the given key and attempts to coerce - * it into a boolean - * - * @param {string} key - A unique key - * @param {boolean} defaultVal - The default value - * - */ - getBool (key, defaultVal) { - let value; - - value = store[key]; - - if (ok(value)) { - value = value.toLowerCase().trim(); - if (value === 'true') { - return true; - } else if (value === 'false') { - return false; - } - throw new Error(`${value} is not a boolean`); - } else if (defaultVal === true || defaultVal === false) { - return defaultVal; - } - - return false; - }, - - /** - * @description An alias function for getBool() - * - * @param {string} key - A unique key - * @param {boolean} defaultVal - The default value if none exists - * - */ - bool (key, defaultVal) { - return this.getBool(key, defaultVal); - }, - - /** - * @description Fetches the value at the given key and attempts to - * coerce it into an integer - * - * @param {string} key - A unique key - * @param {number} defaultVal - The default value - * - */ - getNumber (key, defaultVal) { - const value = this.get(key, defaultVal); - const intVal = parseInt(value, 10); - const valIsInt = Number.isInteger(intVal); - - if (value === defaultVal) { - return value; - } else if (valIsInt) { - return intVal; - } - }, - - /** - * @description An alias function for getNumber() - * - */ - num (key, defaultVal) { - return this.getNumber(key, defaultVal); - }, - - /** - * @description Fetches the value at the given key and attempts to - * coerce it into a list of literal values - * - * @param {string} key - A unique key - * @param {object} options - * - */ - getList (key, opts = { dilim: ',', cast: null }) { - const { dilim, cast } = opts; - const value = this.get(key, []); - - if (!isArray(value)) { - let ret = value.split(dilim).map(i => i.trim()); - if (cast && cast === 'number') { - ret = mapNums(ret); - } - return ret; - } else { - return value; - } - }, - - /** - * @description An alias function for getList() - * - * @param {string} key - A unique key - * @param {object} options - * - */ - list (key, opts) { - return this.getList(key, opts); - } - }); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..a474f0b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,318 @@ +import { isIP } from 'node:net'; +import { URL } from 'node:url'; +import { makeGoodUrl, GoodUrl } from './lib/url-types'; + +type ValidType = 'number' | 'boolean' | 'string'; + +interface EnsureSpec { + type: ValidType; + ok?: (value: string | number | boolean) => boolean; +} + +type EnsureItem = string | Record; + +interface GetListOptions { + dilim?: string; + cast?: 'number' | null; +} + +interface AWSDefaults { + region?: string; +} + +interface AWSCredentials { + awsKeyId: string | undefined; + awsSecretAccessKey: string | undefined; + awsSessionToken: string | undefined; + awsRegion: string | undefined; +} + +interface AWSSMResponse { + SecretString: string; +} + +interface AWSSMClient { + send (cmd: unknown): Promise; +} + +interface AWSSMModule { + SecretsManagerClient: new (config: { region: string }) => AWSSMClient; + GetSecretValueCommand: new (input: { SecretId: string; VersionStage: string }) => unknown; +} + +const ok = (x: unknown): boolean => !!x; +const isArray = Array.isArray; +const is = (x: unknown): string => Object.prototype.toString.call(x); +const isString = (x: unknown): x is string => is(x) === '[object String]'; +const isNumber = (x: unknown): x is number => is(x) === '[object Number]'; +const isBoolean = (x: unknown): x is boolean => is(x) === '[object Boolean]'; +const isObject = (x: unknown): x is Record => is(x) === '[object Object]'; +const isFunction = (x: unknown): x is Function => is(x) === '[object Function]'; +const parse = (items: string[], converter: (s: string, radix: number) => number): number[] => + items.map(t => converter(t, 10)); +const mapNums = (items: string[]): number[] => parse(items, parseInt); +const validType = (item: unknown): item is ValidType => + ['number', 'boolean', 'string'].includes(item as string); + +const store: Record = { ...process.env }; + +const env = { + set (key: string, value: string): void { + process.env[key] = value; + store[key] = value; + }, + + async use (awsSecretsManager: AWSSMModule, secretId?: string): Promise { + const { SecretsManagerClient, GetSecretValueCommand } = awsSecretsManager; + const client = new SecretsManagerClient({ + region: process.env.AWS_REGION || 'us-east-1' + }); + + if (!secretId) { + secretId = this.get(['AWS_SECRET_ID', 'SECRET_ID']) as string | undefined; + } + + if (!secretId) { + throw new Error('\'secretId\' was not specified, and it wasn\'t found as \'AWS_SECRET_ID\' or \'SECRET_ID\' in the environment.'); + } + + const response = await client.send( + new GetSecretValueCommand({ + SecretId: secretId, + VersionStage: 'AWSCURRENT' + }) + ); + const secretStr = response.SecretString; + const secret = JSON.parse(secretStr) as Record; + Object.entries(secret).forEach(([key, value]) => { + this.set(key, value); + }); + }, + + getIP (key: string, defaultVal?: string): string | null { + const strIP = this.get(key) as string | undefined; + + if (!strIP) { + if (!isIP(defaultVal as string)) { + return null; + } else { + return defaultVal as string; + } + } + + if (isIP(strIP)) return strIP; + if (isIP(defaultVal as string)) return defaultVal as string; + return null; + }, + + getAWS ({ region }: AWSDefaults = {}): AWSCredentials { + const { + AWS_ACCESS_KEY_ID: awsKeyId, + AWS_SECRET_ACCESS_KEY: awsSecretAccessKey, + AWS_SESSION_TOKEN: awsSessionToken, + AWS_REGION: awsRegion + } = this.getAll({ + AWS_ACCESS_KEY_ID: null, + AWS_SECRET_ACCESS_KEY: null, + AWS_SESSION_TOKEN: null, + AWS_REGION: region ?? null + }) as Record; + + return { + awsKeyId, + awsSecretAccessKey, + awsSessionToken, + awsRegion + }; + }, + + getUrl (key: string, defaultVal?: string): Readonly | null { + let urlStr = this.get(key, defaultVal) as string | undefined; + + if (urlStr === defaultVal) { + urlStr = defaultVal; + } + + try { + return makeGoodUrl({ url: new URL(urlStr as string) }); + } catch (e) { + return null; + } + }, + + url (key: string, defaultVal?: string): Readonly | null { + return this.getUrl(key, defaultVal); + }, + + get (keyObj: string | string[], defaultVal?: unknown): unknown { + let keys: string[]; + let value: unknown; + + if (isString(keyObj)) { + keys = [keyObj]; + } else if (isArray(keyObj)) { + keys = (keyObj as string[]).map(k => k.trim()); + } else { + throw Error(`Invalid key(s) ${keyObj as string}`); + } + + keys.some(key => { + if (ok(store[key])) { + value = store[key]; + return true; + } + return false; + }); + + if (!ok(value) && typeof ok(defaultVal)) { + value = defaultVal; + } + + return (isString(value)) ? value.trim() : value; + }, + + getAll (items: string[] | Record): (string | undefined)[] | Record { + const boundGet = this.get.bind(this); + + const objReducer = (obj: Record) => + Object.keys(obj).reduce>((prev, next) => { + prev[next] = boundGet(next, obj[next]) as string | undefined; + return prev; + }, {}); + + const arrMapper = (keys: string[]) => keys.map(key => boundGet(key) as string | undefined); + + if (isArray(items)) { + return arrMapper(items); + } else if (isObject(items)) { + return objReducer(items as Record); + } else { + throw Error(`Invalid arg ${items as string}`); + } + }, + + ok: (...keys: string[]): boolean => keys.every(key => ok(store[key])), + + ensure (...items: EnsureItem[]): boolean { + const self = this; + + interface Kit { + validator: (x: unknown) => boolean; + getter: (key: string) => unknown; + } + + const getKit = (item: ValidType): Kit => { + switch (item) { + case 'string': + return { validator: isString, getter: self.get.bind(self) }; + case 'number': + return { validator: isNumber, getter: self.getNumber.bind(self) }; + case 'boolean': + return { validator: isBoolean, getter: self.getBool.bind(self) }; + /* istanbul ignore next */ + default: + throw Error(`Invalid type "${item as string}"`); + } + }; + + return items.every(item => { + if (isString(item)) { + if (this.ok(item)) { + return true; + } else { + throw Error(`No environment configuration for var "${item}"`); + } + } else if (isObject(item)) { + const key = Object.keys(item)[0]; + const spec = (item as Record)[key]; + const type = spec.type; + const validator = spec.ok; + + /* istanbul ignore if */ + if (type && !validType(type)) { + throw Error(`Invalid expected type "${type as string}"`); + } else { + const kit = getKit(type); + const val = kit.getter(key); + const result = kit.validator(val); + if (!result) { + throw Error(`Unexpected result for key="${key}". It may not exist or may not be a valid "${type}"`); + } + + if (validator && isFunction(validator)) { + const valid = validator(val as string | number | boolean); + if (!valid) { + throw Error(`Value ${val as string} did not pass validator function for key "${key}"`); + } + } + return true; + } + } else { + throw Error(`Invalid key ${item as string}`); + } + }); + }, + + assert (...items: EnsureItem[]): boolean { + return this.ensure(...items); + }, + + getBool (key: string, defaultVal?: boolean): boolean { + let value: string | undefined = store[key]; + + if (ok(value)) { + const normalized = (value as string).toLowerCase().trim(); + if (normalized === 'true') { + return true; + } else if (normalized === 'false') { + return false; + } + throw new Error(`${normalized} is not a boolean`); + } else if (defaultVal === true || defaultVal === false) { + return defaultVal; + } + + return false; + }, + + bool (key: string, defaultVal?: boolean): boolean { + return this.getBool(key, defaultVal); + }, + + getNumber (key: string | string[], defaultVal?: number): number | undefined { + const value = this.get(key, defaultVal); + const intVal = parseInt(value as string, 10); + const valIsInt = Number.isInteger(intVal); + + if (value === defaultVal) { + return value as number | undefined; + } else if (valIsInt) { + return intVal; + } + }, + + num (key: string | string[], defaultVal?: number): number | undefined { + return this.getNumber(key, defaultVal); + }, + + getList (key: string, opts: GetListOptions = { dilim: ',', cast: null }): string[] | number[] { + const { dilim = ',', cast } = opts; + const value = this.get(key, []) as string | string[]; + + if (!isArray(value)) { + let ret: string[] = (value as string).split(dilim).map(i => i.trim()); + if (cast && cast === 'number') { + return mapNums(ret); + } + return ret; + } else { + return value as string[]; + } + }, + + list (key: string, opts?: GetListOptions): string[] | number[] { + return this.getList(key, opts); + } +}; + +export = env; diff --git a/src/lib/url-types.js b/src/lib/url-types.ts similarity index 54% rename from src/lib/url-types.js rename to src/lib/url-types.ts index d10b412..927ab60 100644 --- a/src/lib/url-types.js +++ b/src/lib/url-types.ts @@ -1,36 +1,46 @@ -'use strict'; +export interface GoodUrl { + readonly httpOk: boolean; + readonly redisOk: boolean; + readonly pgOk: boolean; + readonly href: string; + readonly raw: URL; +} + +interface UrlInput { + url: URL; +} -function httpUrl({ url }) { +function httpUrl ({ url }: UrlInput): Readonly { return Object.freeze({ httpOk: true, redisOk: false, pgOk: false, href: url.href, - raw: url, + raw: url }); } -function redisUrl({ url }) { +function redisUrl ({ url }: UrlInput): Readonly { return Object.freeze({ redisOk: true, httpOk: false, pgOk: false, href: url.href, - raw: url, + raw: url }); } -function pgUrl({ url }) { +function pgUrl ({ url }: UrlInput): Readonly { return Object.freeze({ pgOk: true, redisOk: false, httpOk: false, href: url.href, - raw: url, + raw: url }); } -function makeGoodUrl({ url }) { +export function makeGoodUrl ({ url }: UrlInput): Readonly | null { if (url.protocol.startsWith('redis')) { return redisUrl({ url }); } else if (url.protocol.startsWith('postgresql')) { @@ -41,5 +51,3 @@ function makeGoodUrl({ url }) { return null; } - -module.exports = { makeGoodUrl }; diff --git a/test/test.js b/test/test.js index 35ea77a..ed08d33 100644 --- a/test/test.js +++ b/test/test.js @@ -3,7 +3,7 @@ require('dotenv').config({ path: 'test/test.env' }); const test = require('tape'); -const env = require('../src/index'); +const env = require('../dist/index'); const { GetSecretValueCommand, SecretsManagerClientHappy diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d7f296c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "moduleResolution": "node", + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "lib": ["ES2020"], + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test"] +} From 2544e47696400b11522b4ddb9959fcda6dc3d990 Mon Sep 17 00:00:00 2001 From: recursivefunk Date: Wed, 8 Apr 2026 17:11:57 -0400 Subject: [PATCH 2/2] Fix float parsing bug --- package-lock.json | 2 +- src/index.ts | 11 +++-------- test/test.env | 1 + test/test.js | 12 ++++++++++++ 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2a7e39a..62fbcaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "good-env", - "version": "7.6.1", + "version": "7.6.2", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/src/index.ts b/src/index.ts index a474f0b..1686434 100644 --- a/src/index.ts +++ b/src/index.ts @@ -281,14 +281,9 @@ const env = { getNumber (key: string | string[], defaultVal?: number): number | undefined { const value = this.get(key, defaultVal); - const intVal = parseInt(value as string, 10); - const valIsInt = Number.isInteger(intVal); - - if (value === defaultVal) { - return value as number | undefined; - } else if (valIsInt) { - return intVal; - } + if (value === defaultVal) return value as number | undefined; + const num = parseFloat(value as string); + if (!isNaN(num)) return num; }, num (key: string | string[], defaultVal?: number): number | undefined { diff --git a/test/test.env b/test/test.env index bf38f33..07eca01 100644 --- a/test/test.env +++ b/test/test.env @@ -1,6 +1,7 @@ FOO=bar BANG=boop INT_NUM=10 +FLOAT_NUM=3.14 MY_LIST=foo ,bar,beep MY_TRUE_KEY=true MY_FALSE_KEY=false diff --git a/test/test.js b/test/test.js index ed08d33..8d9b9cb 100644 --- a/test/test.js +++ b/test/test.js @@ -255,6 +255,18 @@ test('returns integers', (t) => { t.end(); }); +test('returns floats', (t) => { + let result = env.getNumber('FLOAT_NUM'); + t.equals(result, 3.14); + result = null; + result = env.num('FLOAT_NUM'); + t.equals(result, 3.14); + result = null; + result = env.num(['INTT', 'FLOAT_NUM']); + t.equals(result, 3.14); + t.end(); +}); + test('returns undefined for non-exequalsting number', (t) => { const result = env.getNumber('INT_NOT_HERE'); t.equals(undefined, result);