Skip to content

Commit 4898715

Browse files
authored
feat: allow env placeholders in config files (#42)
* feat(config): support env placeholders in parsed config * test(config): cover env placeholder parsing * docs: clarify env placeholder config syntax
1 parent dd4311c commit 4898715

File tree

4 files changed

+224
-15
lines changed

4 files changed

+224
-15
lines changed

README.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,20 @@ Zero-config by default (uses `auto` mode). Customize in `.opencode/codebase-inde
348348
}
349349
```
350350

351+
String values in `codebase-index.json` can reference environment variables with `{env:VAR_NAME}` when the placeholder is the entire string value. Variable names must match `[A-Z_][A-Z0-9_]*`. This is useful for secrets such as custom provider API keys so they do not need to be committed to the config file.
352+
353+
```json
354+
{
355+
"embeddingProvider": "custom",
356+
"customProvider": {
357+
"baseUrl": "{env:EMBED_BASE_URL}",
358+
"model": "nomic-embed-text",
359+
"dimensions": 768,
360+
"apiKey": "{env:EMBED_API_KEY}"
361+
}
362+
}
363+
```
364+
351365
### Options Reference
352366

353367
| Option | Default | Description |
@@ -604,16 +618,16 @@ Works with any server that implements the OpenAI `/v1/embeddings` API format (ll
604618
{
605619
"embeddingProvider": "custom",
606620
"customProvider": {
607-
"baseUrl": "http://localhost:11434/v1",
621+
"baseUrl": "{env:EMBED_BASE_URL}",
608622
"model": "nomic-embed-text",
609623
"dimensions": 768,
610-
"apiKey": "optional-api-key",
624+
"apiKey": "{env:EMBED_API_KEY}",
611625
"maxTokens": 8192,
612626
"timeoutMs": 30000
613627
}
614628
}
615629
```
616-
Required fields: `baseUrl`, `model`, `dimensions` (positive integer). Optional: `apiKey`, `maxTokens`, `timeoutMs` (default: 30000).
630+
Required fields: `baseUrl`, `model`, `dimensions` (positive integer). Optional: `apiKey`, `maxTokens`, `timeoutMs` (default: 30000). `{env:VAR_NAME}` placeholders are resolved before config validation for fields that are actually used and throw if the referenced environment variable is missing or malformed.
617631

618632
## ⚠️ Tradeoffs
619633

src/config/env-substitution.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
const ENV_REFERENCE_PATTERN = /^\{env:([A-Z_][A-Z0-9_]*)\}$/;
2+
const ENV_REFERENCE_LIKE_PATTERN = /\{env:[^}]+\}/;
3+
4+
export function substituteEnvString(value: string, keyPath: string): string {
5+
const match = value.match(ENV_REFERENCE_PATTERN);
6+
7+
if (!match) {
8+
if (ENV_REFERENCE_LIKE_PATTERN.test(value)) {
9+
throw new Error(
10+
`Invalid environment variable reference at '${keyPath}'. ` +
11+
"Expected the entire string to match '{env:VAR_NAME}' with VAR_NAME matching [A-Z_][A-Z0-9_]*."
12+
);
13+
}
14+
15+
return value;
16+
}
17+
18+
const variableName = match[1];
19+
const envValue = process.env[variableName];
20+
21+
if (envValue === undefined) {
22+
throw new Error(`Missing environment variable '${variableName}' referenced by config at '${keyPath}'.`);
23+
}
24+
25+
return envValue;
26+
}
27+
28+
export function substituteEnvReferences(raw: unknown, keyPath = "$root"): unknown {
29+
if (typeof raw === "string") {
30+
return substituteEnvString(raw, keyPath);
31+
}
32+
33+
if (Array.isArray(raw)) {
34+
return raw.map((item, index) => substituteEnvReferences(item, `${keyPath}[${index}]`));
35+
}
36+
37+
if (raw && typeof raw === "object") {
38+
return Object.fromEntries(
39+
Object.entries(raw).map(([key, value]) => [key, substituteEnvReferences(value, `${keyPath}.${key}`)])
40+
);
41+
}
42+
43+
return raw;
44+
}

src/config/schema.ts

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Config schema without zod dependency to avoid version conflicts with OpenCode SDK
22

33
import { DEFAULT_INCLUDE, DEFAULT_EXCLUDE, EMBEDDING_MODELS, DEFAULT_PROVIDER_MODELS } from "./constants.js";
4+
import { substituteEnvString } from "./env-substitution.js";
45

56
export type IndexScope = "project" | "global";
67

@@ -154,12 +155,32 @@ function isStringArray(value: unknown): value is string[] {
154155
return Array.isArray(value) && value.every(item => typeof item === "string");
155156
}
156157

158+
function getResolvedString(value: unknown, keyPath: string): string | undefined {
159+
if (typeof value !== "string") {
160+
return undefined;
161+
}
162+
163+
return substituteEnvString(value, keyPath);
164+
}
165+
166+
function getResolvedStringArray(value: unknown, keyPath: string): string[] | undefined {
167+
if (!isStringArray(value)) {
168+
return undefined;
169+
}
170+
171+
return value.map((item, index) => substituteEnvString(item, `${keyPath}[${index}]`));
172+
}
173+
157174
function isValidLogLevel(value: unknown): value is LogLevel {
158175
return typeof value === "string" && VALID_LOG_LEVELS.includes(value as LogLevel);
159176
}
160177

161178
export function parseConfig(raw: unknown): ParsedCodebaseIndexConfig {
162179
const input = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
180+
const embeddingProviderValue = getResolvedString(input.embeddingProvider, "$root.embeddingProvider");
181+
const scopeValue = getResolvedString(input.scope, "$root.scope");
182+
const includeValue = getResolvedStringArray(input.include, "$root.include");
183+
const excludeValue = getResolvedStringArray(input.exclude, "$root.exclude");
163184

164185
const defaultIndexing = getDefaultIndexingConfig();
165186
const defaultSearch = getDefaultSearchConfig();
@@ -208,15 +229,18 @@ export function parseConfig(raw: unknown): ParsedCodebaseIndexConfig {
208229
let embeddingModel: EmbeddingModelName | undefined = undefined;
209230
let customProvider: CustomProviderConfig | undefined = undefined;
210231

211-
if (input.embeddingProvider === 'custom') {
232+
if (embeddingProviderValue === 'custom') {
212233
embeddingProvider = 'custom';
213234
const rawCustom = (input.customProvider && typeof input.customProvider === 'object' ? input.customProvider : null) as Record<string, unknown> | null;
214-
if (rawCustom && typeof rawCustom.baseUrl === 'string' && rawCustom.baseUrl.trim().length > 0 && typeof rawCustom.model === 'string' && rawCustom.model.trim().length > 0 && typeof rawCustom.dimensions === 'number' && Number.isInteger(rawCustom.dimensions) && rawCustom.dimensions > 0) {
235+
const baseUrlValue = getResolvedString(rawCustom?.baseUrl, "$root.customProvider.baseUrl");
236+
const modelValue = getResolvedString(rawCustom?.model, "$root.customProvider.model");
237+
const apiKeyValue = getResolvedString(rawCustom?.apiKey, "$root.customProvider.apiKey");
238+
if (rawCustom && typeof baseUrlValue === 'string' && baseUrlValue.trim().length > 0 && typeof modelValue === 'string' && modelValue.trim().length > 0 && typeof rawCustom.dimensions === 'number' && Number.isInteger(rawCustom.dimensions) && rawCustom.dimensions > 0) {
215239
customProvider = {
216-
baseUrl: rawCustom.baseUrl.trim().replace(/\/+$/, ''),
217-
model: rawCustom.model,
240+
baseUrl: baseUrlValue.trim().replace(/\/+$/, ''),
241+
model: modelValue,
218242
dimensions: rawCustom.dimensions,
219-
apiKey: typeof rawCustom.apiKey === 'string' ? rawCustom.apiKey : undefined,
243+
apiKey: apiKeyValue,
220244
maxTokens: typeof rawCustom.maxTokens === 'number' ? rawCustom.maxTokens : undefined,
221245
timeoutMs: typeof rawCustom.timeoutMs === 'number' ? Math.max(1000, rawCustom.timeoutMs) : undefined,
222246
concurrency: typeof rawCustom.concurrency === 'number' ? Math.max(1, Math.floor(rawCustom.concurrency)) : undefined,
@@ -237,10 +261,16 @@ export function parseConfig(raw: unknown): ParsedCodebaseIndexConfig {
237261
"Required fields: baseUrl (string), model (string), dimensions (positive integer)."
238262
);
239263
}
240-
} else if (isValidProvider(input.embeddingProvider)) {
241-
embeddingProvider = input.embeddingProvider;
242-
if (input.embeddingModel) {
243-
embeddingModel = isValidModel(input.embeddingModel, embeddingProvider) ? input.embeddingModel : DEFAULT_PROVIDER_MODELS[embeddingProvider];
264+
} else if (isValidProvider(embeddingProviderValue)) {
265+
embeddingProvider = embeddingProviderValue;
266+
const rawEmbeddingModel = input.embeddingModel;
267+
if (typeof rawEmbeddingModel === "string") {
268+
const embeddingModelValue = substituteEnvString(rawEmbeddingModel, "$root.embeddingModel");
269+
if (embeddingModelValue) {
270+
embeddingModel = isValidModel(embeddingModelValue, embeddingProvider) ? embeddingModelValue : DEFAULT_PROVIDER_MODELS[embeddingProvider];
271+
}
272+
} else if (rawEmbeddingModel) {
273+
embeddingModel = DEFAULT_PROVIDER_MODELS[embeddingProvider];
244274
}
245275
} else {
246276
embeddingProvider = 'auto';
@@ -250,9 +280,9 @@ export function parseConfig(raw: unknown): ParsedCodebaseIndexConfig {
250280
embeddingProvider,
251281
embeddingModel,
252282
customProvider,
253-
scope: isValidScope(input.scope) ? input.scope : "project",
254-
include: isStringArray(input.include) ? input.include : DEFAULT_INCLUDE,
255-
exclude: isStringArray(input.exclude) ? input.exclude : DEFAULT_EXCLUDE,
283+
scope: isValidScope(scopeValue) ? scopeValue : "project",
284+
include: includeValue ?? DEFAULT_INCLUDE,
285+
exclude: excludeValue ?? DEFAULT_EXCLUDE,
256286
indexing,
257287
search,
258288
debug,

tests/config.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, it, expect, vi } from "vitest";
2+
import { substituteEnvReferences } from "../src/config/env-substitution.js";
23
import {
34
parseConfig,
45
getDefaultModelForProvider,
@@ -10,6 +11,70 @@ import {
1011
} from "../src/config/constants.js";
1112

1213
describe("config schema", () => {
14+
describe("substituteEnvReferences", () => {
15+
it("should replace an env placeholder string with the environment value", () => {
16+
vi.stubEnv("OPENCODE_TEST_API_KEY", "secret-key");
17+
18+
expect(substituteEnvReferences("{env:OPENCODE_TEST_API_KEY}")).toBe("secret-key");
19+
20+
vi.unstubAllEnvs();
21+
});
22+
23+
it("should replace nested env placeholders in objects and arrays", () => {
24+
vi.stubEnv("OPENCODE_TEST_BASE_URL", "https://example.test/v1");
25+
vi.stubEnv("OPENCODE_TEST_API_KEY", "secret-key");
26+
27+
const substituted = substituteEnvReferences({
28+
customProvider: {
29+
baseUrl: "{env:OPENCODE_TEST_BASE_URL}",
30+
apiKey: "{env:OPENCODE_TEST_API_KEY}",
31+
},
32+
include: ["src/**/*.ts", "{env:OPENCODE_TEST_API_KEY}"],
33+
});
34+
35+
expect(substituted).toEqual({
36+
customProvider: {
37+
baseUrl: "https://example.test/v1",
38+
apiKey: "secret-key",
39+
},
40+
include: ["src/**/*.ts", "secret-key"],
41+
});
42+
43+
vi.unstubAllEnvs();
44+
});
45+
46+
it("should leave non-placeholder strings unchanged", () => {
47+
expect(substituteEnvReferences("custom-provider")).toBe("custom-provider");
48+
});
49+
50+
it("should reject malformed env reference strings", () => {
51+
expect(() => substituteEnvReferences("prefix-{env:OPENCODE_TEST_API_KEY}")).toThrow(
52+
"Invalid environment variable reference"
53+
);
54+
expect(() => substituteEnvReferences("{env:opencode_test_api_key}")).toThrow(
55+
"Invalid environment variable reference"
56+
);
57+
});
58+
59+
it("should throw when a referenced environment variable is missing", () => {
60+
expect(() => substituteEnvReferences({
61+
customProvider: {
62+
apiKey: "{env:OPENCODE_MISSING_API_KEY}",
63+
},
64+
})).toThrow("Missing environment variable 'OPENCODE_MISSING_API_KEY' referenced by config at '$root.customProvider.apiKey'.");
65+
});
66+
67+
it("should preserve non-string values", () => {
68+
expect(substituteEnvReferences({
69+
indexing: { autoIndex: true, maxChunksPerFile: 5 },
70+
search: { hybridWeight: 0.5 },
71+
})).toEqual({
72+
indexing: { autoIndex: true, maxChunksPerFile: 5 },
73+
search: { hybridWeight: 0.5 },
74+
});
75+
});
76+
});
77+
1378
describe("parseConfig", () => {
1479
it("should return defaults for undefined input", () => {
1580
const config = parseConfig(undefined);
@@ -292,6 +357,62 @@ describe("config schema", () => {
292357
expect(config.customProvider!.maxTokens).toBe(4096);
293358
});
294359

360+
it("should accept env-substituted custom provider credentials", () => {
361+
vi.stubEnv("OPENCODE_CUSTOM_PROVIDER_URL", "https://api.example.com/v1");
362+
vi.stubEnv("OPENCODE_CUSTOM_PROVIDER_KEY", "sk-env-key");
363+
364+
const config = parseConfig(substituteEnvReferences({
365+
embeddingProvider: "custom",
366+
customProvider: {
367+
baseUrl: "{env:OPENCODE_CUSTOM_PROVIDER_URL}",
368+
model: "my-model",
369+
dimensions: 1024,
370+
apiKey: "{env:OPENCODE_CUSTOM_PROVIDER_KEY}",
371+
},
372+
}));
373+
374+
expect(config.customProvider!.baseUrl).toBe("https://api.example.com/v1");
375+
expect(config.customProvider!.apiKey).toBe("sk-env-key");
376+
377+
vi.unstubAllEnvs();
378+
});
379+
380+
it("should ignore env refs in inactive customProvider branches", () => {
381+
const config = parseConfig({
382+
embeddingProvider: "openai",
383+
customProvider: {
384+
baseUrl: "{env:EMBED_BASE_URL}",
385+
model: "{env:EMBED_MODEL}",
386+
dimensions: 768,
387+
apiKey: "{env:MISSING_API_KEY}",
388+
},
389+
});
390+
391+
expect(config.embeddingProvider).toBe("openai");
392+
expect(config.customProvider).toBeUndefined();
393+
});
394+
395+
it("should reject malformed env refs in active custom provider fields", () => {
396+
expect(() => parseConfig({
397+
embeddingProvider: "custom",
398+
customProvider: {
399+
baseUrl: "prefix-{env:EMBED_BASE_URL}",
400+
model: "test",
401+
dimensions: 768,
402+
},
403+
})).toThrow("Invalid environment variable reference");
404+
405+
expect(() => parseConfig({
406+
embeddingProvider: "custom",
407+
customProvider: {
408+
baseUrl: "http://localhost:11434/v1",
409+
model: "test",
410+
dimensions: 768,
411+
apiKey: "{env:embed_api_key}",
412+
},
413+
})).toThrow("Invalid environment variable reference");
414+
});
415+
295416
it("should throw when custom provider is selected but config is missing", () => {
296417
expect(() => parseConfig({
297418
embeddingProvider: "custom",

0 commit comments

Comments
 (0)