Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/tame-jars-shave.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/std/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"tailwind-merge": "^3.0.0"
},
"devDependencies": {
"@types/node": "^26.1.2",
"typescript": "catalog:"
}
}
60 changes: 60 additions & 0 deletions packages/std/src/assertEnv.test.ts
Original file line number Diff line number Diff line change
@@ -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',
});
});
36 changes: 36 additions & 0 deletions packages/std/src/assertEnv.ts
Original file line number Diff line number Diff line change
@@ -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)}`;
}
1 change: 1 addition & 0 deletions packages/std/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export type { ClassValue } from 'clsx';
export { type AssertEnvOptions, assertEnv } from './assertEnv.js';
export { cn } from './cn.js';
1 change: 1 addition & 0 deletions packages/std/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"types": ["node"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
Expand Down
25 changes: 21 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.