mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
feat(config): support ${VAR:-default} in config env substitution (#155164)
Support inline environment defaults when a variable is unset or empty, using the same token grammar for substitution and authored config write-back. Preserve escapes, unsupported forms, and escaped-reference activation guards. Include regression coverage and user documentation. This intentionally changes previously literal fallback-shaped values on upgrade. Operators preserving such an existing Gateway credential can supply its exact bytes through OPENCLAW_GATEWAY_TOKEN, remove the inline token, and retain token mode; isolated production loader/writeback/auth-boundary proof verifies that transition. Closes #149551 Co-authored-by: Mitchell Etzel <16581152+etzelm@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
This commit is contained in:
parent
31587f12b6
commit
fcfa4bf0c1
7 changed files with 443 additions and 41 deletions
|
|
@ -52,6 +52,30 @@ Reference env vars in any config string with `${VAR_NAME}`:
|
|||
- Escape with `$${VAR}` to produce a literal `${VAR}` value.
|
||||
- Works with `$include`.
|
||||
|
||||
#### Default values
|
||||
|
||||
Add a fallback with `${VAR_NAME:-fallback}`. It is used when the variable is unset or empty:
|
||||
|
||||
```json5
|
||||
{
|
||||
mcp: {
|
||||
servers: {
|
||||
nautobot: {
|
||||
env: { NAUTOBOT_TIMEOUT: "${NAUTOBOT_TIMEOUT:-60}" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- A reference with a fallback always resolves, so it never emits a missing-var warning.
|
||||
- An empty fallback is allowed: `${VAR:-}` resolves to an empty string.
|
||||
- The fallback is literal text. It must not contain `$` or `{`, so a nested reference such as `${A:-${B}}` is not a fallback expression; it stays literal and only the inner `${B}` substitutes.
|
||||
- Only `:-` is supported. Other shell operators (`:=`, `:?`, `:+`, `-`, `#`, `%`, `/`, `^`) are left untouched as literal text. Bash's `-` is omitted deliberately: OpenClaw treats unset and empty as the same state, so it could not differ from `:-`.
|
||||
- Escaping still wins: `$${VAR:-x}` produces the literal `${VAR:-x}` and reads nothing from the environment.
|
||||
- Authored fallbacks survive config write-back; OpenClaw restores `${VAR:-fallback}` rather than inlining the value it resolved to.
|
||||
- A fallback is config text, not a secret store. Put credentials in `env.vars` or a [SecretRef](#secretref) and reference them bare.
|
||||
|
||||
---
|
||||
|
||||
## Secrets
|
||||
|
|
|
|||
|
|
@ -168,6 +168,29 @@ describe("config env vars", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("skips env.vars values holding an unresolved reference, bare or with a default", async () => {
|
||||
await withEnvAsync(
|
||||
{ BARE_REF: undefined, DEFAULT_REF: undefined, PLAIN_VALUE: undefined },
|
||||
async () => {
|
||||
applyConfigEnvVars({
|
||||
env: {
|
||||
vars: {
|
||||
BARE_REF: "${SOME_VAR}",
|
||||
DEFAULT_REF: "${SOME_VAR:-fallback}",
|
||||
PLAIN_VALUE: "literal",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
|
||||
// applyConfigEnvVars runs before env substitution, so a reference that is still
|
||||
// a template would otherwise be exported into process.env as literal "${...}" text.
|
||||
expect(process.env.BARE_REF).toBeUndefined();
|
||||
expect(process.env.DEFAULT_REF).toBeUndefined();
|
||||
expect(process.env.PLAIN_VALUE).toBe("literal");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("can build a merged runtime env without mutating process.env", async () => {
|
||||
await withEnvAsync({ OPENROUTER_API_KEY: undefined }, async () => {
|
||||
const merged = createConfigRuntimeEnv({
|
||||
|
|
|
|||
|
|
@ -1,33 +1,21 @@
|
|||
import { isDeepStrictEqual } from "node:util";
|
||||
import { isPlainObject } from "../infra/plain-object.js";
|
||||
import { containsEnvVarReference } from "./env-substitution.js";
|
||||
|
||||
const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
||||
import { containsEnvVarReference, scanEnvTemplateTokens } from "./env-substitution.js";
|
||||
|
||||
/**
|
||||
* Keyed by bare variable name, deliberately: `${VAR}` and `${VAR:-x}` are one identity
|
||||
* here. These counts feed fail-closed guards against a write turning an authored
|
||||
* `$${VAR}` literal into an active reference. Keying on the authored text instead would
|
||||
* let `$${VAR}` to `${VAR:-x}` slip past the guard that already rejects `$${VAR}` to
|
||||
* `${VAR}`.
|
||||
*/
|
||||
type AuthoredEnvRef = { kind: "escaped" | "unescaped"; name: string };
|
||||
|
||||
function collectAuthoredEnvRefs(value: string): AuthoredEnvRef[] {
|
||||
const refs: AuthoredEnvRef[] = [];
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (value[index] !== "$") {
|
||||
continue;
|
||||
}
|
||||
const isEscaped = value[index + 1] === "$" && value[index + 2] === "{";
|
||||
const nameStart = index + (isEscaped ? 3 : 2);
|
||||
if (!isEscaped && value[index + 1] !== "{") {
|
||||
continue;
|
||||
}
|
||||
const nameEnd = value.indexOf("}", nameStart);
|
||||
if (nameEnd === -1 || !ENV_VAR_NAME_PATTERN.test(value.slice(nameStart, nameEnd))) {
|
||||
continue;
|
||||
}
|
||||
refs.push({
|
||||
kind: isEscaped ? "escaped" : "unescaped",
|
||||
name: value.slice(nameStart, nameEnd),
|
||||
});
|
||||
index = nameEnd;
|
||||
}
|
||||
return refs;
|
||||
return scanEnvTemplateTokens(value).map((token) => ({
|
||||
kind: token.kind === "escaped" ? ("escaped" as const) : ("unescaped" as const),
|
||||
name: token.name,
|
||||
}));
|
||||
}
|
||||
|
||||
function hasEscapedEnvVarRef(value: string): boolean {
|
||||
|
|
|
|||
|
|
@ -118,6 +118,50 @@ describe("restoreEnvVarRefs", () => {
|
|||
expect(result).toEqual({ value: "${API_TOKEN}:${OPTIONAL_SUFFIX}" });
|
||||
});
|
||||
|
||||
it("restores a ${VAR:-default} template that resolved from its fallback", () => {
|
||||
// Without this, an authored fallback is inlined into openclaw.json on the next write
|
||||
// and the operator's template is lost: the value resolves to "60", the writer sees a
|
||||
// plain string, and nothing links it back to what was authored.
|
||||
const incoming = { env: { NAUTOBOT_TIMEOUT: "60" } };
|
||||
const parsed = { env: { NAUTOBOT_TIMEOUT: "${NAUTOBOT_TIMEOUT:-60}" } };
|
||||
|
||||
const result = restoreEnvVarRefs(incoming, parsed, {});
|
||||
|
||||
expect(result).toEqual({ env: { NAUTOBOT_TIMEOUT: "${NAUTOBOT_TIMEOUT:-60}" } });
|
||||
});
|
||||
|
||||
it("restores a ${VAR:-default} template that resolved from the environment", () => {
|
||||
const incoming = { timeout: "90" };
|
||||
const parsed = { timeout: "${NAUTOBOT_TIMEOUT:-60}" };
|
||||
|
||||
const result = restoreEnvVarRefs(incoming, parsed, { NAUTOBOT_TIMEOUT: "90" });
|
||||
|
||||
expect(result).toEqual({ timeout: "${NAUTOBOT_TIMEOUT:-60}" });
|
||||
});
|
||||
|
||||
it("keeps a deliberate edit over a ${VAR:-default} template", () => {
|
||||
const incoming = { timeout: "120" };
|
||||
const parsed = { timeout: "${NAUTOBOT_TIMEOUT:-60}" };
|
||||
|
||||
const result = restoreEnvVarRefs(incoming, parsed, {});
|
||||
|
||||
expect(result).toEqual({ timeout: "120" });
|
||||
});
|
||||
|
||||
it("restores composite and escaped ${VAR:-default} templates", () => {
|
||||
expect(
|
||||
restoreEnvVarRefs(
|
||||
{ url: "https://api.example.com/v1" },
|
||||
{ url: "https://${API_HOST:-api.example.com}/v1" },
|
||||
{},
|
||||
),
|
||||
).toEqual({ url: "https://${API_HOST:-api.example.com}/v1" });
|
||||
|
||||
expect(restoreEnvVarRefs({ literal: "${VAR:-x}" }, { literal: "$${VAR:-x}" }, {})).toEqual({
|
||||
literal: "$${VAR:-x}",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects structural changes to arrays containing environment references", () => {
|
||||
const duplicateEnv = {
|
||||
PLUGIN_A: "same-plugin",
|
||||
|
|
@ -129,6 +173,25 @@ describe("restoreEnvVarRefs", () => {
|
|||
).toThrow("Config write would reorder or modify an array containing environment references");
|
||||
});
|
||||
|
||||
it("rejects activating an escaped literal as a ${VAR:-default} reference", () => {
|
||||
// Rewriting an entry the operator deliberately escaped into a live env read must fail
|
||||
// closed. Write-back accounting keys refs by bare variable name so ${VAR:-x} is caught
|
||||
// here; keying on the authored text would let this through.
|
||||
expectEnvRefArrayMutationError(() =>
|
||||
restoreEnvVarRefs([{ v: "${VAR:-x}" }, { v: "other" }], [{ v: "$${VAR}" }, { v: "other" }], {
|
||||
VAR: "from-env",
|
||||
}),
|
||||
);
|
||||
|
||||
// The unchanged round trip is not an activation: the incoming value is exactly what the
|
||||
// escape resolves to, so the authored escape is restored rather than rejected.
|
||||
expect(
|
||||
restoreEnvVarRefs([{ v: "${VAR}" }, { v: "other" }], [{ v: "$${VAR}" }, { v: "other" }], {
|
||||
VAR: "from-env",
|
||||
}),
|
||||
).toEqual([{ v: "$${VAR}" }, { v: "other" }]);
|
||||
});
|
||||
|
||||
it("allows array edits when placeholders are escaped literals", () => {
|
||||
const result = restoreEnvVarRefs(["${ESCAPED}", "changed"], ["$${ESCAPED}", "literal"], {});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
containsUnaccountedActiveEscapedEnvRef,
|
||||
preservesAuthoredEscapedEnvRefs,
|
||||
} from "./env-preserve-authored.js";
|
||||
import { resolveConfigEnvVars } from "./env-substitution.js";
|
||||
import { resolveConfigEnvVars, scanEnvTemplateTokens } from "./env-substitution.js";
|
||||
|
||||
/**
|
||||
* Preserves `${VAR}` environment variable references during config write-back.
|
||||
|
|
@ -26,8 +26,6 @@ import { resolveConfigEnvVars } from "./env-substitution.js";
|
|||
* resolves to), the new value is kept as-is.
|
||||
*/
|
||||
|
||||
const ENV_VAR_PATTERN = /\$\{[A-Z_][A-Z0-9_]*\}/;
|
||||
|
||||
class EnvRefArrayMutationError extends Error {
|
||||
constructor() {
|
||||
super("Config write would reorder or modify an array containing environment references.");
|
||||
|
|
@ -36,10 +34,13 @@ class EnvRefArrayMutationError extends Error {
|
|||
}
|
||||
|
||||
/**
|
||||
* Check if a string contains any `${VAR}` env var references.
|
||||
* Check if a string contains any `${VAR}` env var references, escaped or not.
|
||||
*
|
||||
* Escaped `$${VAR}` counts: it still changes under substitution, so the authored text
|
||||
* must be restored on write-back the same way an active reference is.
|
||||
*/
|
||||
function hasEnvVarRef(value: string): boolean {
|
||||
return ENV_VAR_PATTERN.test(value);
|
||||
return scanEnvTemplateTokens(value).length > 0;
|
||||
}
|
||||
|
||||
type ArrayIdentityPath = string[];
|
||||
|
|
|
|||
|
|
@ -556,4 +556,218 @@ describe("resolveConfigEnvVars", () => {
|
|||
expectResolvedScenarios(scenarios);
|
||||
});
|
||||
});
|
||||
|
||||
describe("default value syntax", () => {
|
||||
it("resolves ${VAR:-default} from the fallback, the env value, or an empty fallback", () => {
|
||||
const scenarios: SubstitutionScenario[] = [
|
||||
{
|
||||
name: "filed case: unset var falls back to the default",
|
||||
config: { env: { NAUTOBOT_TIMEOUT: "${NAUTOBOT_TIMEOUT:-60}" } },
|
||||
env: {},
|
||||
expected: { env: { NAUTOBOT_TIMEOUT: "60" } },
|
||||
},
|
||||
{
|
||||
name: "set var wins over the default",
|
||||
config: { env: { NAUTOBOT_TIMEOUT: "${NAUTOBOT_TIMEOUT:-60}" } },
|
||||
env: { NAUTOBOT_TIMEOUT: "90" },
|
||||
expected: { env: { NAUTOBOT_TIMEOUT: "90" } },
|
||||
},
|
||||
{
|
||||
name: "empty var takes the default, matching existing missing-var semantics",
|
||||
config: { env: { NAUTOBOT_TIMEOUT: "${NAUTOBOT_TIMEOUT:-60}" } },
|
||||
env: { NAUTOBOT_TIMEOUT: "" },
|
||||
expected: { env: { NAUTOBOT_TIMEOUT: "60" } },
|
||||
},
|
||||
{
|
||||
name: "empty fallback resolves to an empty string",
|
||||
config: { key: "${OPTIONAL_SUFFIX:-}" },
|
||||
env: {},
|
||||
expected: { key: "" },
|
||||
},
|
||||
{
|
||||
name: "fallback is used inline",
|
||||
config: { key: "https://${API_HOST:-api.example.com}/v1" },
|
||||
env: {},
|
||||
expected: { key: "https://api.example.com/v1" },
|
||||
},
|
||||
{
|
||||
name: "multiple references in one string mix resolved and fallback",
|
||||
config: { key: "${API_HOST:-localhost}:${API_PORT:-8080}" },
|
||||
env: { API_HOST: "example.com" },
|
||||
expected: { key: "example.com:8080" },
|
||||
},
|
||||
{
|
||||
name: "fallback text is preserved verbatim",
|
||||
config: { key: "${VAR:-a b c}" },
|
||||
env: {},
|
||||
expected: { key: "a b c" },
|
||||
},
|
||||
{
|
||||
name: "fallback may itself contain the operator",
|
||||
config: { key: "${VAR:-:-}" },
|
||||
env: {},
|
||||
expected: { key: ":-" },
|
||||
},
|
||||
{
|
||||
name: "fallback may start with a dash",
|
||||
config: { key: "${VAR:--5}" },
|
||||
env: {},
|
||||
expected: { key: "-5" },
|
||||
},
|
||||
];
|
||||
|
||||
expectResolvedScenarios(scenarios);
|
||||
});
|
||||
|
||||
it("treats a fallback as a resolution, so no warning is collected", () => {
|
||||
const warnings: EnvSubstitutionWarning[] = [];
|
||||
const resolved = resolveConfigEnvVars(
|
||||
{
|
||||
mcp: { servers: { nautobot: { env: { NAUTOBOT_TIMEOUT: "${NAUTOBOT_TIMEOUT:-60}" } } } },
|
||||
},
|
||||
{},
|
||||
{ onMissing: (warning) => warnings.push(warning) },
|
||||
);
|
||||
|
||||
expect(resolved).toEqual({
|
||||
mcp: { servers: { nautobot: { env: { NAUTOBOT_TIMEOUT: "60" } } } },
|
||||
});
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not throw MissingEnvVarError when a fallback is authored", () => {
|
||||
expect(resolveConfigEnvVars({ key: "${ABSENT_VAR:-fallback}" }, {})).toEqual({
|
||||
key: "fallback",
|
||||
});
|
||||
// A bare reference with no fallback keeps throwing.
|
||||
expect(() => resolveConfigEnvVars({ key: "${ABSENT_VAR}" }, {})).toThrow(MissingEnvVarError);
|
||||
});
|
||||
|
||||
it("keeps the escape winning over the fallback form", () => {
|
||||
const scenarios: SubstitutionScenario[] = [
|
||||
{
|
||||
name: "escaped fallback form stays a literal even when the var is set",
|
||||
config: { key: "$${VAR:-x}" },
|
||||
env: { VAR: "from-env" },
|
||||
expected: { key: "${VAR:-x}" },
|
||||
},
|
||||
{
|
||||
name: "escaped fallback form stays a literal when the var is unset",
|
||||
config: { key: "$${VAR:-x}" },
|
||||
env: {},
|
||||
expected: { key: "${VAR:-x}" },
|
||||
},
|
||||
];
|
||||
|
||||
expectResolvedScenarios(scenarios);
|
||||
});
|
||||
|
||||
it("leaves every operator other than :- untouched", () => {
|
||||
const scenarios: SubstitutionScenario[] = [
|
||||
{ name: "assign", config: { key: "${VAR:=d}" }, env: {}, expected: { key: "${VAR:=d}" } },
|
||||
{ name: "error", config: { key: "${VAR:?m}" }, env: {}, expected: { key: "${VAR:?m}" } },
|
||||
{ name: "alt", config: { key: "${VAR:+a}" }, env: {}, expected: { key: "${VAR:+a}" } },
|
||||
{
|
||||
name: "unset-only dash is not supported; empty and unset are one state here",
|
||||
config: { key: "${VAR-d}" },
|
||||
env: {},
|
||||
expected: { key: "${VAR-d}" },
|
||||
},
|
||||
{ name: "prefix", config: { key: "${VAR#p}" }, env: {}, expected: { key: "${VAR#p}" } },
|
||||
{ name: "suffix", config: { key: "${VAR%s}" }, env: {}, expected: { key: "${VAR%s}" } },
|
||||
{
|
||||
name: "replace",
|
||||
config: { key: "${VAR/a/b}" },
|
||||
env: {},
|
||||
expected: { key: "${VAR/a/b}" },
|
||||
},
|
||||
{
|
||||
name: "json modifier proposed by PR #95603 does not collide with :-",
|
||||
config: { key: "${VAR:json}" },
|
||||
env: {},
|
||||
expected: { key: "${VAR:json}" },
|
||||
},
|
||||
];
|
||||
|
||||
expectResolvedScenarios(scenarios);
|
||||
});
|
||||
|
||||
it("requires a valid uppercase name to the left of the operator", () => {
|
||||
const scenarios: SubstitutionScenario[] = [
|
||||
{
|
||||
name: "lowercase name",
|
||||
config: { key: "${lowercase:-d}" },
|
||||
env: { lowercase: "value" },
|
||||
expected: { key: "${lowercase:-d}" },
|
||||
},
|
||||
{
|
||||
name: "mixed-case name",
|
||||
config: { key: "${MixedCase:-d}" },
|
||||
env: {},
|
||||
expected: { key: "${MixedCase:-d}" },
|
||||
},
|
||||
{
|
||||
name: "numeric prefix",
|
||||
config: { key: "${123INVALID:-d}" },
|
||||
env: {},
|
||||
expected: { key: "${123INVALID:-d}" },
|
||||
},
|
||||
{ name: "empty name", config: { key: "${:-d}" }, env: {}, expected: { key: "${:-d}" } },
|
||||
{
|
||||
name: "other template dialects stay untouched",
|
||||
config: { key: "${my-service} ${count+1} ${a=b}" },
|
||||
env: {},
|
||||
expected: { key: "${my-service} ${count+1} ${a=b}" },
|
||||
},
|
||||
];
|
||||
|
||||
expectResolvedScenarios(scenarios);
|
||||
});
|
||||
|
||||
it("does not change how a fallback containing $ or { is handled", () => {
|
||||
// The fallback grammar deliberately excludes "$" and "{" so the scan for the closing
|
||||
// brace stays a plain indexOf("}"). These inputs therefore resolve exactly as they do
|
||||
// without default-value support: the outer expression stays literal and only a valid
|
||||
// inner reference substitutes.
|
||||
const scenarios: SubstitutionScenario[] = [
|
||||
{
|
||||
name: "nested reference: outer literal, inner substitutes",
|
||||
config: { key: "${A:-${B}}" },
|
||||
env: { A: "a-value", B: "b-value" },
|
||||
expected: { key: "${A:-b-value}" },
|
||||
},
|
||||
{
|
||||
name: "nested reference with both unset",
|
||||
config: { key: "${A:-${B:-c}}" },
|
||||
env: {},
|
||||
expected: { key: "${A:-c}" },
|
||||
},
|
||||
{
|
||||
name: "fallback containing a brace",
|
||||
config: { key: '${A:-{"n":1}}' },
|
||||
env: {},
|
||||
expected: { key: '${A:-{"n":1}}' },
|
||||
},
|
||||
{
|
||||
name: "fallback containing a bare dollar",
|
||||
config: { key: "${PRICE:-$5}" },
|
||||
env: {},
|
||||
expected: { key: "${PRICE:-$5}" },
|
||||
},
|
||||
];
|
||||
|
||||
expectResolvedScenarios(scenarios);
|
||||
});
|
||||
|
||||
it("counts a fallback reference as an env var reference", () => {
|
||||
expect(containsEnvVarReference("${VAR:-x}")).toBe(true);
|
||||
expect(containsEnvVarReference("prefix-${VAR:-x}")).toBe(true);
|
||||
expect(containsEnvVarReference("${VAR:-}")).toBe(true);
|
||||
// Escaped and unsupported forms are still not references.
|
||||
expect(containsEnvVarReference("$${VAR:-x}")).toBe(false);
|
||||
expect(containsEnvVarReference("${VAR:=x}")).toBe(false);
|
||||
expect(containsEnvVarReference("${VAR-x}")).toBe(false);
|
||||
expect(containsEnvVarReference("${lowercase:-x}")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@
|
|||
*
|
||||
* Supports `${VAR_NAME}` syntax in string values, substituted at config load time.
|
||||
* - Only uppercase env vars are matched: `[A-Z_][A-Z0-9_]*`
|
||||
* - `${VAR_NAME:-fallback}` uses `fallback` when the var is unset or empty
|
||||
* - Escape with `$${}` to output literal `${}`
|
||||
* - Missing env vars throw `MissingEnvVarError` with context
|
||||
* - Missing env vars without a fallback throw `MissingEnvVarError` with context
|
||||
*
|
||||
* @example
|
||||
* ```json5
|
||||
|
|
@ -28,6 +29,17 @@ import { parseEnvTemplateSecretRef } from "./types.secrets.js";
|
|||
|
||||
const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
||||
|
||||
/**
|
||||
* Bash-style default-value operator: `${VAR:-fallback}`.
|
||||
*
|
||||
* Only `:-` is recognized, the form the reported issue names. Bash also has `-`, which
|
||||
* substitutes when the var is unset but not when it is set to `""`. That is implementable
|
||||
* here as a local branch on the missing test below, so it is left out by choice, not by
|
||||
* constraint: `${VAR}` already treats `""` as missing, and putting `${VAR-x}` beside
|
||||
* `${VAR:-x}` would place two different notions of "set" in one config file.
|
||||
*/
|
||||
const DEFAULT_VALUE_OPERATOR = ":-";
|
||||
|
||||
/** Error thrown when a config value references a missing or empty environment variable. */
|
||||
export class MissingEnvVarError extends Error {
|
||||
constructor(
|
||||
|
|
@ -39,9 +51,52 @@ export class MissingEnvVarError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
type EnvToken =
|
||||
| { kind: "escaped"; name: string; end: number }
|
||||
| { kind: "substitution"; name: string; end: number };
|
||||
/** One recognized `${VAR}` / `${VAR:-fallback}` placeholder, without its position. */
|
||||
export type EnvTemplateToken = {
|
||||
kind: "escaped" | "substitution";
|
||||
name: string;
|
||||
/** Authored fallback text, or `undefined` for a bare reference. `""` is a real empty fallback. */
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
type EnvToken = EnvTemplateToken & { end: number };
|
||||
|
||||
/**
|
||||
* Parses the text between `${` and the first following `}`.
|
||||
*
|
||||
* A fallback is recognized only when it carries no `$` and no `{`. That keeps the scan
|
||||
* for the closing brace a plain `indexOf("}")`, so no input that is left literal today
|
||||
* starts parsing differently: `${A:-${B}}` still falls through to the literal path and
|
||||
* its inner `${B}` is still the only thing that substitutes, exactly as before.
|
||||
*/
|
||||
function parseEnvTokenBody(body: string): Omit<EnvTemplateToken, "kind"> | null {
|
||||
if (ENV_VAR_NAME_PATTERN.test(body)) {
|
||||
return { name: body };
|
||||
}
|
||||
|
||||
const operatorIndex = body.indexOf(DEFAULT_VALUE_OPERATOR);
|
||||
if (operatorIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = body.slice(0, operatorIndex);
|
||||
if (!ENV_VAR_NAME_PATTERN.test(name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const defaultValue = body.slice(operatorIndex + DEFAULT_VALUE_OPERATOR.length);
|
||||
if (defaultValue.includes("$") || defaultValue.includes("{")) {
|
||||
return null;
|
||||
}
|
||||
return { name, defaultValue };
|
||||
}
|
||||
|
||||
/** Rebuilds the authored placeholder text for a parsed token. */
|
||||
function renderEnvTemplateToken(token: EnvTemplateToken): string {
|
||||
return token.defaultValue === undefined
|
||||
? `\${${token.name}}`
|
||||
: `\${${token.name}${DEFAULT_VALUE_OPERATOR}${token.defaultValue}}`;
|
||||
}
|
||||
|
||||
function parseEnvTokenAt(value: string, index: number): EnvToken | null {
|
||||
if (value[index] !== "$") {
|
||||
|
|
@ -57,9 +112,9 @@ function parseEnvTokenAt(value: string, index: number): EnvToken | null {
|
|||
const start = index + 3;
|
||||
const end = value.indexOf("}", start);
|
||||
if (end !== -1) {
|
||||
const name = value.slice(start, end);
|
||||
if (ENV_VAR_NAME_PATTERN.test(name)) {
|
||||
return { kind: "escaped", name, end };
|
||||
const body = parseEnvTokenBody(value.slice(start, end));
|
||||
if (body) {
|
||||
return { kind: "escaped", ...body, end };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -69,9 +124,9 @@ function parseEnvTokenAt(value: string, index: number): EnvToken | null {
|
|||
const start = index + 2;
|
||||
const end = value.indexOf("}", start);
|
||||
if (end !== -1) {
|
||||
const name = value.slice(start, end);
|
||||
if (ENV_VAR_NAME_PATTERN.test(name)) {
|
||||
return { kind: "substitution", name, end };
|
||||
const body = parseEnvTokenBody(value.slice(start, end));
|
||||
if (body) {
|
||||
return { kind: "substitution", ...body, end };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -79,6 +134,33 @@ function parseEnvTokenAt(value: string, index: number): EnvToken | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists every recognized placeholder in authoring order.
|
||||
*
|
||||
* Exported so config write-back preservation shares this grammar instead of keeping its
|
||||
* own copy; a second scanner would silently stop restoring authored templates the moment
|
||||
* the two drifted.
|
||||
*/
|
||||
export function scanEnvTemplateTokens(value: string): EnvTemplateToken[] {
|
||||
const tokens: EnvTemplateToken[] = [];
|
||||
if (!value.includes("$")) {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
if (value[i] !== "$") {
|
||||
continue;
|
||||
}
|
||||
const token = parseEnvTokenAt(value, i);
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
tokens.push({ kind: token.kind, name: token.name, defaultValue: token.defaultValue });
|
||||
i = token.end;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/** Missing environment variable warning emitted when substitution is configured to continue. */
|
||||
export type EnvSubstitutionWarning = {
|
||||
varName: string;
|
||||
|
|
@ -119,20 +201,27 @@ function substituteString(
|
|||
|
||||
const token = parseEnvTokenAt(value, i);
|
||||
if (token?.kind === "escaped") {
|
||||
chunks.push(`\${${token.name}}`);
|
||||
chunks.push(renderEnvTemplateToken(token));
|
||||
i = token.end;
|
||||
continue;
|
||||
}
|
||||
if (token?.kind === "substitution") {
|
||||
const envValue = env[token.name];
|
||||
if (envValue === undefined || envValue === "") {
|
||||
if (token.defaultValue !== undefined) {
|
||||
// An authored fallback resolves the reference, so this is not a missing var:
|
||||
// no warning, no MissingEnvVarError, and no pending-SecretRef signal.
|
||||
chunks.push(token.defaultValue);
|
||||
i = token.end;
|
||||
continue;
|
||||
}
|
||||
if (opts?.onMissing) {
|
||||
opts.onMissing({ varName: token.name, configPath });
|
||||
if (authoredRef?.id === token.name) {
|
||||
opts.onPendingEnvSecretRef?.(token.name, configPath);
|
||||
}
|
||||
// Preserve the original placeholder so the value is visibly unresolved.
|
||||
chunks.push(`\${${token.name}}`);
|
||||
chunks.push(renderEnvTemplateToken(token));
|
||||
i = token.end;
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue