diff --git a/.changeset/tame-jars-shave.md b/.changeset/tame-jars-shave.md new file mode 100644 index 0000000..4f56d0e --- /dev/null +++ b/.changeset/tame-jars-shave.md @@ -0,0 +1,5 @@ +--- +'@plexus-ms/std': minor +--- + +Add `assertEnv` helper that reads the first environment variable that is set from a list of candidate names, or throws with a message naming all of them and an optional hint. diff --git a/packages/std/package.json b/packages/std/package.json index 7b21541..970ace5 100644 --- a/packages/std/package.json +++ b/packages/std/package.json @@ -34,6 +34,7 @@ "tailwind-merge": "^3.0.0" }, "devDependencies": { + "@types/node": "^26.1.2", "typescript": "catalog:" } } diff --git a/packages/std/src/assertEnv.test.ts b/packages/std/src/assertEnv.test.ts new file mode 100644 index 0000000..3c4a11b --- /dev/null +++ b/packages/std/src/assertEnv.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { assertEnv } from './assertEnv.ts'; + +const touched: string[] = []; + +function setEnv(name: string, value: string) { + touched.push(name); + process.env[name] = value; +} + +afterEach(() => { + for (const name of touched.splice(0)) delete process.env[name]; +}); + +test('returns the value of a set variable', () => { + setEnv('STD_TEST_A', 'value-a'); + assert.equal(assertEnv('STD_TEST_A'), 'value-a'); +}); + +test('returns the first variable that is set', () => { + setEnv('STD_TEST_B', 'value-b'); + assert.equal(assertEnv('STD_TEST_MISSING', 'STD_TEST_B'), 'value-b'); +}); + +test('trims the value and treats a blank variable as missing', () => { + setEnv('STD_TEST_BLANK', ' '); + setEnv('STD_TEST_PADDED', ' value '); + assert.equal(assertEnv('STD_TEST_BLANK', 'STD_TEST_PADDED'), 'value'); +}); + +test('throws naming a single variable', () => { + assert.throws(() => assertEnv('STD_TEST_MISSING'), { + message: 'Missing environment variable STD_TEST_MISSING', + }); +}); + +test('throws joining two variables with "or"', () => { + assert.throws(() => assertEnv('AUTH_SECRET', 'BETTER_AUTH_SECRET'), { + message: 'Missing environment variable AUTH_SECRET or BETTER_AUTH_SECRET', + }); +}); + +test('throws joining three or more variables with commas and "or"', () => { + assert.throws(() => assertEnv('APP_URL', 'AUTH_URL', 'BETTER_AUTH_URL', 'VERCEL_URL'), { + message: 'Missing environment variable APP_URL, AUTH_URL, BETTER_AUTH_URL, or VERCEL_URL', + }); +}); + +test('appends the hint after a colon', () => { + assert.throws(() => assertEnv('AUTH_SECRET', 'BETTER_AUTH_SECRET', { hint: 'use `pnx auth secret` to generate' }), { + message: 'Missing environment variable AUTH_SECRET or BETTER_AUTH_SECRET: use `pnx auth secret` to generate', + }); +}); + +test('an options argument without a hint does not affect the message', () => { + assert.throws(() => assertEnv('STD_TEST_MISSING', {}), { + message: 'Missing environment variable STD_TEST_MISSING', + }); +}); diff --git a/packages/std/src/assertEnv.ts b/packages/std/src/assertEnv.ts new file mode 100644 index 0000000..0ee5c00 --- /dev/null +++ b/packages/std/src/assertEnv.ts @@ -0,0 +1,36 @@ +export interface AssertEnvOptions { + /** Appended to the error message after a colon, e.g. how to obtain the value. */ + hint?: string; +} + +/** + * Read the first environment variable that is set, or throw. + * + * Names are tried in order; a variable that is unset or empty (whitespace only) + * counts as missing. The returned value is trimmed. + * + * @example assertEnv('AUTH_SECRET', 'BETTER_AUTH_SECRET', { hint: 'use `pnx auth secret` to generate' }) + * // throws: Missing environment variable AUTH_SECRET or BETTER_AUTH_SECRET: use `pnx auth secret` to generate + */ +export function assertEnv(...names: [string, ...string[]]): string; +export function assertEnv(...args: [string, ...string[], AssertEnvOptions]): string; +export function assertEnv(...args: (string | AssertEnvOptions)[]): string { + const last = args.at(-1); + const options = typeof last === 'object' ? last : undefined; + const names = (options ? args.slice(0, -1) : args) as string[]; + + for (const name of names) { + const value = process.env[name]?.trim(); + if (value) return value; + } + + const hint = options?.hint ? `: ${options.hint}` : ''; + throw new Error(`Missing environment variable ${formatList(names)}${hint}`); +} + +/** Join with commas and a trailing "or", Oxford-comma style: "A", "A or B", "A, B, or C". */ +function formatList(names: string[]): string { + if (names.length <= 1) return names.join(''); + if (names.length === 2) return names.join(' or '); + return `${names.slice(0, -1).join(', ')}, or ${names.at(-1)}`; +} diff --git a/packages/std/src/index.ts b/packages/std/src/index.ts index a22f01c..52eb47f 100644 --- a/packages/std/src/index.ts +++ b/packages/std/src/index.ts @@ -1,2 +1,3 @@ export type { ClassValue } from 'clsx'; +export { type AssertEnvOptions, assertEnv } from './assertEnv.js'; export { cn } from './cn.js'; diff --git a/packages/std/tsconfig.json b/packages/std/tsconfig.json index 722e30d..804130c 100644 --- a/packages/std/tsconfig.json +++ b/packages/std/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "target": "ES2022", "lib": ["ES2022"], + "types": ["node"], "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "dist", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7678164..450f8c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,7 +16,7 @@ importers: devDependencies: '@changesets/cli': specifier: ^2 - version: 2.31.0 + version: 2.31.0(@types/node@26.1.2) '@plexus-ms/config': specifier: workspace:* version: link:packages/config @@ -35,6 +35,9 @@ importers: specifier: ^3.0.0 version: 3.6.0 devDependencies: + '@types/node': + specifier: ^26.1.2 + version: 26.1.2 typescript: specifier: 'catalog:' version: 6.0.3 @@ -160,6 +163,9 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@26.1.2': + resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -449,6 +455,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -491,7 +500,7 @@ snapshots: dependencies: '@changesets/types': 6.1.0 - '@changesets/cli@2.31.0': + '@changesets/cli@2.31.0(@types/node@26.1.2)': dependencies: '@changesets/apply-release-plan': 7.1.1 '@changesets/assemble-release-plan': 6.0.10 @@ -507,7 +516,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.3 + '@inquirer/external-editor': 1.0.3(@types/node@26.1.2) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 enquirer: 2.4.1 @@ -605,10 +614,12 @@ snapshots: human-id: 4.2.0 prettier: 2.8.8 - '@inquirer/external-editor@1.0.3': + '@inquirer/external-editor@1.0.3(@types/node@26.1.2)': dependencies: chardet: 2.2.0 iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 26.1.2 '@manypkg/find-root@1.1.0': dependencies: @@ -658,6 +669,10 @@ snapshots: '@types/node@12.20.55': {} + '@types/node@26.1.2': + dependencies: + undici-types: 8.3.0 + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} @@ -904,6 +919,8 @@ snapshots: typescript@6.0.3: {} + undici-types@8.3.0: {} + universalify@0.1.2: {} which@2.0.2: