diff --git a/cli/src/commands/router/commands/compose.ts b/cli/src/commands/router/commands/compose.ts index 9b4f5e75e1..0fbcee3d88 100644 --- a/cli/src/commands/router/commands/compose.ts +++ b/cli/src/commands/router/commands/compose.ts @@ -30,6 +30,16 @@ import { composeSubgraphs, introspectSubgraph } from '../../../utils.js'; const STATIC_SCHEMA_VERSION_ID = '00000000-0000-0000-0000-000000000000'; +/** + * Expands environment variables in a string using ${VAR_NAME} syntax. + * Consistent with Go's os.ExpandEnv() used in router config.yaml. + */ +export function expandEnvVars(content: string): string { + return content.replace(/\${([^}]+)}/g, (_, varName) => { + return process.env[varName] ?? ''; + }); +} + type ConfigSubgraph = StandardSubgraphConfig | SubgraphPluginConfig | GRPCSubgraphConfig; type StandardSubgraphConfig = { @@ -183,7 +193,8 @@ export default (opts: BaseCommandOptions) => { } const fileContent = (await readFile(inputFile)).toString(); - const config = yaml.load(fileContent) as Config; + const expandedContent = expandEnvVars(fileContent); + const config = yaml.load(expandedContent) as Config; const subgraphs: SubgraphMetadata[] = []; diff --git a/cli/test/expand-env-vars.test.ts b/cli/test/expand-env-vars.test.ts new file mode 100644 index 0000000000..6fed0ff8a8 --- /dev/null +++ b/cli/test/expand-env-vars.test.ts @@ -0,0 +1,50 @@ +/* eslint-disable no-template-curly-in-string */ +import { describe, test, expect, beforeEach, afterEach } from 'vitest'; +import { expandEnvVars } from '../src/commands/router/commands/compose.js'; + +describe('expandEnvVars', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + test('replaces ${VAR} with environment variable value', () => { + process.env.TEST_VAR = 'hello'; + expect(expandEnvVars('${TEST_VAR}')).toBe('hello'); + }); + + test('replaces missing variable with empty string', () => { + delete process.env.NONEXISTENT_VAR; + expect(expandEnvVars('${NONEXISTENT_VAR}')).toBe(''); + }); + + test('handles multiple variables in one string', () => { + process.env.VAR1 = 'foo'; + process.env.VAR2 = 'bar'; + expect(expandEnvVars('${VAR1} and ${VAR2}')).toBe('foo and bar'); + }); + + test('handles adjacent variables', () => { + process.env.A = 'hello'; + process.env.B = 'world'; + expect(expandEnvVars('${A}${B}')).toBe('helloworld'); + }); + + test('preserves text without variables', () => { + expect(expandEnvVars('no variables here')).toBe('no variables here'); + }); + + test('handles variable in Authorization header context', () => { + process.env.API_TOKEN = 'secret123'; + const input = `headers: + Authorization: "Bearer \${API_TOKEN}"`; + const expected = `headers: + Authorization: "Bearer secret123"`; + expect(expandEnvVars(input)).toBe(expected); + }); +});