Skip to content

Commit caf6973

Browse files
committed
feat: envReplaceLossy
1 parent a8ba779 commit caf6973

5 files changed

Lines changed: 904 additions & 655 deletions

File tree

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,58 @@
11
---
22
labels: ['configuration']
3-
description: 'A function for repacing env variables in configuration settings'
3+
description: 'Functions for replacing env variables in configuration settings'
44
---
55

6-
API:
6+
## `envReplace`
7+
8+
Strict mode — throws when a `${VAR}` placeholder has no value and no default.
79

810
```ts
911
function envReplace(settingValue: string, env: NodeJS.ProcessEnv): string;
1012
```
1113

12-
Usage:
13-
1414
```ts
1515
import { envReplace } from '@pnpm/config.env-replace'
1616

1717
envReplace('${foo}', process.env)
1818
```
19+
20+
## `envReplaceLossy`
21+
22+
Lossy mode — replaces unresolved `${VAR}` placeholders with `''` and reports
23+
them in `unresolved` instead of throwing. Resolvable placeholders and
24+
`${VAR-default}` / `${VAR:-default}` fallbacks elsewhere in the same string
25+
still expand normally; only the genuinely unresolved bare ones are dropped.
26+
27+
Use this when leaving the literal `${VAR}` in the substituted value would be
28+
worse than dropping it (e.g. auth tokens in `.npmrc` under OIDC trusted
29+
publishing).
30+
31+
```ts
32+
function envReplaceLossy(
33+
settingValue: string,
34+
env: NodeJS.ProcessEnv
35+
): { value: string; unresolved: string[] };
36+
```
37+
38+
```ts
39+
import { envReplaceLossy } from '@pnpm/config.env-replace'
40+
41+
const { value, unresolved } = envReplaceLossy('${foo}-${missing}', process.env)
42+
// value: 'foo_value-'
43+
// unresolved: ['${missing}']
44+
```
45+
46+
## Placeholder syntax
47+
48+
- `${NAME}` — strict; the env var must be set (otherwise `envReplace` throws,
49+
or `envReplaceLossy` substitutes `''` and records the placeholder).
50+
- `${NAME-fallback}``fallback` if `NAME` is unset.
51+
- `${NAME:-fallback}``fallback` if `NAME` is unset **or** an empty string.
52+
- A leading `\` escapes the placeholder (`\${NAME}` stays literal).
53+
54+
`{ NAME: undefined }` in `env` is treated as **unset** for both functions —
55+
the same as the key being absent. This matches the
56+
`Record<string, string | undefined>` shape of `NodeJS.ProcessEnv`, where
57+
callers (notably tests) routinely model an unset variable as a
58+
present-but-undefined property.

config/env-replace/env-replace.spec.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { envReplace } from './env-replace';
1+
import { envReplace, envReplaceLossy } from './env-replace';
22

33
const ENV = {
44
foo: 'foo_value',
@@ -25,3 +25,51 @@ test('fail when the env variable is not found', () => {
2525
expect(() => envReplace('${foo:-}', ENV)).toThrow(`Failed to replace env in config: \${foo:-}`);
2626
})
2727

28+
test('treat present-but-undefined env value as unset for fallbacks', () => {
29+
// `NodeJS.ProcessEnv` is `Record<string, string | undefined>`. Callers that
30+
// construct the env object directly may represent an unset variable as
31+
// `{ KEY: undefined }`. `${KEY-default}` must use the fallback in that case.
32+
const ENV_WITH_UNDEFINED = { ...ENV, qux: undefined };
33+
expect(envReplace('${qux-fallback}', ENV_WITH_UNDEFINED)).toBe('fallback');
34+
expect(envReplace('${qux:-fallback}', ENV_WITH_UNDEFINED)).toBe('fallback');
35+
expect(() => envReplace('${qux}', ENV_WITH_UNDEFINED))
36+
.toThrow(`Failed to replace env in config: \${qux}`);
37+
})
38+
39+
describe('envReplaceLossy', () => {
40+
test('substitutes unresolved placeholders with empty string and records them', () => {
41+
const { value, unresolved } = envReplaceLossy('${baz}', ENV);
42+
expect(value).toBe('');
43+
expect(unresolved).toEqual(['${baz}']);
44+
});
45+
46+
test('preserves resolvable placeholders and default fallbacks alongside unresolved ones', () => {
47+
// Mixed value: one resolvable, one unresolved bare, one with a `-default` fallback.
48+
// Only the bare unresolved one becomes ''; the others still expand normally.
49+
const { value, unresolved } = envReplaceLossy(
50+
'${foo}-${baz}-${qux-fallback}',
51+
ENV,
52+
);
53+
expect(value).toBe('foo_value--fallback');
54+
expect(unresolved).toEqual(['${baz}']);
55+
});
56+
57+
test('records every unresolved placeholder occurrence in source order', () => {
58+
const { value, unresolved } = envReplaceLossy('${a}-${b}-${a}', {});
59+
expect(value).toBe('--');
60+
expect(unresolved).toEqual(['${a}', '${b}', '${a}']);
61+
});
62+
63+
test('returns the value unchanged and empty unresolved when nothing fails', () => {
64+
const { value, unresolved } = envReplaceLossy('-${foo}-${bar}-', ENV);
65+
expect(value).toBe('-foo_value-bar_value-');
66+
expect(unresolved).toEqual([]);
67+
});
68+
69+
test('respects backslash escapes the same way envReplace does', () => {
70+
// Odd backslash count escapes the placeholder; lookup never runs.
71+
expect(envReplaceLossy('\\${baz}', ENV)).toEqual({ value: '${baz}', unresolved: [] });
72+
// Even count: half collapses to a literal `\`, the placeholder expands.
73+
expect(envReplaceLossy('\\\\${foo}', ENV)).toEqual({ value: '\\foo_value', unresolved: [] });
74+
});
75+
});

config/env-replace/env-replace.ts

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,75 @@
11
const ENV_EXPR = /(?<!\\)(\\*)\$\{([^${}]+)\}/g
2+
const ENV_VALUE = /([^:-]+)(:?)-(.+)/
23

3-
export function envReplace(settingValue: string, env: NodeJS.ProcessEnv): string {
4-
return settingValue.replace(ENV_EXPR, replaceEnvMatch.bind(null, env))
4+
/**
5+
* Replace every `${VAR}` (or `${VAR-default}` / `${VAR:-default}`) placeholder
6+
* in `settingValue` with the env value resolved from `env`. Throws on the first
7+
* placeholder that has no value and no default.
8+
*/
9+
export function envReplace (settingValue: string, env: NodeJS.ProcessEnv): string {
10+
return replaceWith(settingValue, env, (orig) => {
11+
throw new Error(`Failed to replace env in config: ${orig}`)
12+
})
513
}
614

7-
function replaceEnvMatch (env: NodeJS.ProcessEnv, orig: string, escape: string, name: string): string {
8-
if (escape.length % 2) {
9-
return orig.slice((escape.length + 1) / 2)
10-
}
11-
const envValue = getEnvValue(env, name)
12-
if (envValue === undefined) {
13-
throw new Error(`Failed to replace env in config: ${orig}`)
14-
}
15-
return `${(escape.slice(escape.length / 2))}${envValue}`
15+
export interface EnvReplaceLossyResult {
16+
/** The substituted text. Unresolved placeholders are replaced with `''`. */
17+
value: string
18+
/**
19+
* The list of unresolved placeholders (each including its surrounding `${...}`,
20+
* in source order). One entry per occurrence — duplicates appear multiple times.
21+
* Callers typically surface each as a warning.
22+
*/
23+
unresolved: string[]
1624
}
1725

18-
const ENV_VALUE = /([^:-]+)(:?)-(.+)/
26+
/**
27+
* Like {@link envReplace}, but replaces unresolved `${VAR}` placeholders with
28+
* `''` and reports them in {@link EnvReplaceLossyResult.unresolved} instead of
29+
* throwing. Resolvable placeholders and `${VAR-default}` / `${VAR:-default}`
30+
* fallbacks still expand normally — only the genuinely unresolved bare ones are
31+
* dropped.
32+
*
33+
* Use this when leaving the literal `${VAR}` in the substituted value would be
34+
* worse than dropping it (e.g. auth tokens in `.npmrc` under OIDC trusted
35+
* publishing — see https://github.com/pnpm/pnpm/issues/11513).
36+
*/
37+
export function envReplaceLossy (settingValue: string, env: NodeJS.ProcessEnv): EnvReplaceLossyResult {
38+
const unresolved: string[] = []
39+
const value = replaceWith(settingValue, env, (orig) => {
40+
unresolved.push(orig)
41+
return ''
42+
})
43+
return { value, unresolved }
44+
}
45+
46+
// Shared substitution loop. `onUnresolved` decides what to splice in (and may
47+
// throw) when a placeholder has no value and no default.
48+
function replaceWith (
49+
settingValue: string,
50+
env: NodeJS.ProcessEnv,
51+
onUnresolved: (orig: string) => string
52+
): string {
53+
return settingValue.replace(ENV_EXPR, (orig: string, escape: string, name: string) => {
54+
if (escape.length % 2) return orig.slice((escape.length + 1) / 2)
55+
const halfEscape = escape.slice(escape.length / 2)
56+
const envValue = getEnvValue(env, name)
57+
if (envValue === undefined) return `${halfEscape}${onUnresolved(orig)}`
58+
return `${halfEscape}${envValue}`
59+
})
60+
}
1961

2062
function getEnvValue (env: NodeJS.ProcessEnv, name: string): string | undefined {
2163
const matched = name.match(ENV_VALUE)
2264
if (!matched) return env[name]
2365
const [, variableName, colon, fallback] = matched
24-
if (Object.prototype.hasOwnProperty.call(env, variableName)) {
25-
return !env[variableName] && colon ? fallback : env[variableName]
26-
}
27-
return fallback
66+
// Treat `{ KEY: undefined }` as unset rather than "explicitly empty": the
67+
// `NodeJS.ProcessEnv` (= `Record<string, string | undefined>`) signature lets
68+
// callers represent an unset variable as a present-but-undefined property,
69+
// and `${KEY-default}` must reach the fallback in that case. Using
70+
// `hasOwnProperty` would treat the property as set and return `undefined`
71+
// instead of `fallback`.
72+
const v = env[variableName]
73+
if (v === undefined) return fallback
74+
return !v && colon ? fallback : v
2875
}

config/env-replace/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { envReplace } from './env-replace';
1+
export { envReplace, envReplaceLossy, type EnvReplaceLossyResult } from './env-replace';

0 commit comments

Comments
 (0)