Skip to content

Commit dd8582e

Browse files
committed
feat: Add env dependency injection architecture
Overhaul environment variable handling to use structured dependency injection, making code more testable and removing direct process.env.* calls. Architecture: - Base types (BaseEnv, BaseCiEnv) in common that packages extend - Package-specific types: CliEnv, SdkEnv, EvalsCiEnv - Helper functions: getCliEnv(), getSdkEnv(), getBaseEnv() - Test helpers: createTestCliEnv(), createTestSdkEnv(), etc. New files: - common/src/types/contracts/env.ts - Base type definitions - common/src/env-process.ts - Base env helpers - common/src/env-ci.ts - CI env helpers - cli/src/types/env.ts, cli/src/utils/env.ts - CLI env - sdk/src/types/env.ts, sdk/src/env.ts - SDK env - evals/types/env.ts - Evals env Migrated files: - CLI utilities (theme-system, open-file, detect-shell, etc.) - Agent runtime (linkup-api, codebuff-web-api, tool handlers) - SDK impl/agent-runtime.ts Other changes: - Added clientEnv and ciEnv to AgentRuntimeDeps - Added ESLint rules to enforce package-specific env types - Added 62 unit tests for env helpers - Documented architecture in knowledge.md
1 parent 9365583 commit dd8582e

File tree

41 files changed

+1876
-135
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1876
-135
lines changed
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { describe, test, expect, afterEach } from 'bun:test'
2+
3+
import { getCliEnv, createTestCliEnv } from '../../utils/env'
4+
5+
describe('cli/utils/env', () => {
6+
describe('getCliEnv', () => {
7+
const originalEnv = { ...process.env }
8+
9+
afterEach(() => {
10+
// Restore original env
11+
Object.keys(process.env).forEach((key) => {
12+
if (!(key in originalEnv)) {
13+
delete process.env[key]
14+
}
15+
})
16+
Object.assign(process.env, originalEnv)
17+
})
18+
19+
test('returns current process.env values for base vars', () => {
20+
process.env.SHELL = '/bin/zsh'
21+
process.env.HOME = '/Users/testuser'
22+
const env = getCliEnv()
23+
expect(env.SHELL).toBe('/bin/zsh')
24+
expect(env.HOME).toBe('/Users/testuser')
25+
})
26+
27+
test('returns current process.env values for terminal detection vars', () => {
28+
process.env.TERM_PROGRAM = 'iTerm.app'
29+
process.env.KITTY_WINDOW_ID = '12345'
30+
const env = getCliEnv()
31+
expect(env.TERM_PROGRAM).toBe('iTerm.app')
32+
expect(env.KITTY_WINDOW_ID).toBe('12345')
33+
})
34+
35+
test('returns current process.env values for VS Code detection', () => {
36+
process.env.VSCODE_PID = '1234'
37+
process.env.VSCODE_THEME_KIND = 'dark'
38+
const env = getCliEnv()
39+
expect(env.VSCODE_PID).toBe('1234')
40+
expect(env.VSCODE_THEME_KIND).toBe('dark')
41+
})
42+
43+
test('returns current process.env values for Cursor detection', () => {
44+
process.env.CURSOR_PORT = '5678'
45+
process.env.CURSOR = 'true'
46+
const env = getCliEnv()
47+
expect(env.CURSOR_PORT).toBe('5678')
48+
expect(env.CURSOR).toBe('true')
49+
})
50+
51+
test('returns current process.env values for JetBrains detection', () => {
52+
process.env.TERMINAL_EMULATOR = 'JetBrains-JediTerm'
53+
process.env.IDE_CONFIG_DIR = '/path/to/idea'
54+
const env = getCliEnv()
55+
expect(env.TERMINAL_EMULATOR).toBe('JetBrains-JediTerm')
56+
expect(env.IDE_CONFIG_DIR).toBe('/path/to/idea')
57+
})
58+
59+
test('returns current process.env values for editor preferences', () => {
60+
process.env.EDITOR = 'vim'
61+
process.env.CODEBUFF_CLI_EDITOR = 'code'
62+
const env = getCliEnv()
63+
expect(env.EDITOR).toBe('vim')
64+
expect(env.CODEBUFF_CLI_EDITOR).toBe('code')
65+
})
66+
67+
test('returns current process.env values for theme preferences', () => {
68+
process.env.OPEN_TUI_THEME = 'dark'
69+
const env = getCliEnv()
70+
expect(env.OPEN_TUI_THEME).toBe('dark')
71+
})
72+
73+
test('returns current process.env values for binary build config', () => {
74+
process.env.CODEBUFF_IS_BINARY = 'true'
75+
process.env.CODEBUFF_CLI_VERSION = '1.0.0'
76+
const env = getCliEnv()
77+
expect(env.CODEBUFF_IS_BINARY).toBe('true')
78+
expect(env.CODEBUFF_CLI_VERSION).toBe('1.0.0')
79+
})
80+
81+
test('returns undefined for unset env vars', () => {
82+
delete process.env.KITTY_WINDOW_ID
83+
delete process.env.VSCODE_PID
84+
const env = getCliEnv()
85+
expect(env.KITTY_WINDOW_ID).toBeUndefined()
86+
expect(env.VSCODE_PID).toBeUndefined()
87+
})
88+
89+
test('returns a snapshot that does not change when process.env changes', () => {
90+
process.env.TERM_PROGRAM = 'iTerm.app'
91+
const env = getCliEnv()
92+
process.env.TERM_PROGRAM = 'vscode'
93+
expect(env.TERM_PROGRAM).toBe('iTerm.app')
94+
})
95+
})
96+
97+
describe('createTestCliEnv', () => {
98+
test('returns a CliEnv with default test values', () => {
99+
const env = createTestCliEnv()
100+
expect(env.HOME).toBe('/home/test')
101+
expect(env.NODE_ENV).toBe('test')
102+
expect(env.TERM).toBe('xterm-256color')
103+
expect(env.PATH).toBe('/usr/bin')
104+
})
105+
106+
test('returns undefined for CLI-specific vars by default', () => {
107+
const env = createTestCliEnv()
108+
expect(env.KITTY_WINDOW_ID).toBeUndefined()
109+
expect(env.VSCODE_PID).toBeUndefined()
110+
expect(env.CURSOR_PORT).toBeUndefined()
111+
expect(env.IDE_CONFIG_DIR).toBeUndefined()
112+
expect(env.CODEBUFF_IS_BINARY).toBeUndefined()
113+
})
114+
115+
test('allows overriding terminal detection vars', () => {
116+
const env = createTestCliEnv({
117+
TERM_PROGRAM: 'iTerm.app',
118+
KITTY_WINDOW_ID: '12345',
119+
SIXEL_SUPPORT: 'true',
120+
})
121+
expect(env.TERM_PROGRAM).toBe('iTerm.app')
122+
expect(env.KITTY_WINDOW_ID).toBe('12345')
123+
expect(env.SIXEL_SUPPORT).toBe('true')
124+
})
125+
126+
test('allows overriding VS Code detection vars', () => {
127+
const env = createTestCliEnv({
128+
VSCODE_PID: '1234',
129+
VSCODE_THEME_KIND: 'dark',
130+
VSCODE_GIT_IPC_HANDLE: '/tmp/vscode-git',
131+
})
132+
expect(env.VSCODE_PID).toBe('1234')
133+
expect(env.VSCODE_THEME_KIND).toBe('dark')
134+
expect(env.VSCODE_GIT_IPC_HANDLE).toBe('/tmp/vscode-git')
135+
})
136+
137+
test('allows overriding editor preferences', () => {
138+
const env = createTestCliEnv({
139+
EDITOR: 'vim',
140+
VISUAL: 'code',
141+
CODEBUFF_CLI_EDITOR: 'cursor',
142+
})
143+
expect(env.EDITOR).toBe('vim')
144+
expect(env.VISUAL).toBe('code')
145+
expect(env.CODEBUFF_CLI_EDITOR).toBe('cursor')
146+
})
147+
148+
test('allows overriding binary build config', () => {
149+
const env = createTestCliEnv({
150+
CODEBUFF_IS_BINARY: 'true',
151+
CODEBUFF_CLI_VERSION: '2.0.0',
152+
CODEBUFF_CLI_TARGET: 'darwin-arm64',
153+
})
154+
expect(env.CODEBUFF_IS_BINARY).toBe('true')
155+
expect(env.CODEBUFF_CLI_VERSION).toBe('2.0.0')
156+
expect(env.CODEBUFF_CLI_TARGET).toBe('darwin-arm64')
157+
})
158+
159+
test('allows overriding default values', () => {
160+
const env = createTestCliEnv({
161+
HOME: '/custom/home',
162+
NODE_ENV: 'production',
163+
})
164+
expect(env.HOME).toBe('/custom/home')
165+
expect(env.NODE_ENV).toBe('production')
166+
})
167+
})
168+
})

cli/src/hooks/use-usage-query.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { env } from '@codebuff/common/env'
12
import { useQuery, useQueryClient } from '@tanstack/react-query'
23

34
import { getAuthToken } from '../utils/auth'
45
import { logger as defaultLogger } from '../utils/logger'
56

7+
import type { ClientEnv } from '@codebuff/common/types/contracts/env'
68
import type { Logger } from '@codebuff/common/types/contracts/logger'
79

810
// Query keys for type-safe cache management
@@ -26,6 +28,7 @@ interface UsageResponse {
2628
interface FetchUsageParams {
2729
authToken: string
2830
logger?: Logger
31+
clientEnv?: ClientEnv
2932
}
3033

3134
/**
@@ -34,8 +37,9 @@ interface FetchUsageParams {
3437
export async function fetchUsageData({
3538
authToken,
3639
logger = defaultLogger,
40+
clientEnv = env,
3741
}: FetchUsageParams): Promise<UsageResponse> {
38-
const appUrl = process.env.NEXT_PUBLIC_CODEBUFF_APP_URL
42+
const appUrl = clientEnv.NEXT_PUBLIC_CODEBUFF_APP_URL
3943
if (!appUrl) {
4044
throw new Error('NEXT_PUBLIC_CODEBUFF_APP_URL is not set')
4145
}

cli/src/hooks/use-why-did-you-update.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { useEffect, useRef } from 'react'
22

3+
import { getCliEnv } from '../utils/env'
4+
35
import { logger } from '../utils/logger'
46

57
/**
@@ -35,9 +37,10 @@ export function useWhyDidYouUpdate<T extends Record<string, any>>(
3537
enabled?: boolean
3638
} = {},
3739
): void {
40+
const env = getCliEnv()
3841
const {
3942
logLevel = 'info',
40-
enabled = process.env.NODE_ENV === 'development',
43+
enabled = env.NODE_ENV === 'development',
4144
} = options
4245

4346
const previousProps = useRef<T | null>(null)
@@ -115,7 +118,8 @@ export function useWhyDidYouUpdateById<T extends Record<string, any>>(
115118
enabled?: boolean
116119
} = {},
117120
): void {
118-
const { logLevel = 'info', enabled = process.env.ENVIRONMENT === 'dev' } =
121+
const env = getCliEnv()
122+
const { logLevel = 'info', enabled = env.NODE_ENV === 'development' } =
119123
options
120124

121125
const previousProps = useRef<T | null>(null)

cli/src/native/ripgrep.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import path from 'path'
22

3+
import { getCliEnv } from '../utils/env'
34
import { getBundledRgPath } from '@codebuff/sdk'
45
import { spawnSync } from 'bun'
56

67
import { logger } from '../utils/logger'
78

89
const getRipgrepPath = async (): Promise<string> => {
10+
const env = getCliEnv()
911
// In dev mode, use the SDK's bundled ripgrep binary
10-
if (!process.env.CODEBUFF_IS_BINARY) {
12+
if (!env.CODEBUFF_IS_BINARY) {
1113
return getBundledRgPath()
1214
}
1315

cli/src/types/env.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* CLI-specific environment variable types.
3+
*
4+
* Extends base types from common with CLI-specific vars for:
5+
* - Terminal/IDE detection
6+
* - Editor preferences
7+
* - Binary build configuration
8+
*/
9+
10+
import type {
11+
BaseEnv,
12+
ClientEnv,
13+
} from '@codebuff/common/types/contracts/env'
14+
15+
/**
16+
* CLI-specific env vars for terminal/IDE detection and editor preferences.
17+
*/
18+
export type CliEnv = BaseEnv & {
19+
// Terminal-specific
20+
KITTY_WINDOW_ID?: string
21+
SIXEL_SUPPORT?: string
22+
ZED_NODE_ENV?: string
23+
24+
// VS Code family detection
25+
VSCODE_THEME_KIND?: string
26+
VSCODE_COLOR_THEME_KIND?: string
27+
VSCODE_GIT_IPC_HANDLE?: string
28+
VSCODE_PID?: string
29+
VSCODE_CWD?: string
30+
VSCODE_NLS_CONFIG?: string
31+
32+
// Cursor editor detection
33+
CURSOR_PORT?: string
34+
CURSOR?: string
35+
36+
// JetBrains IDE detection
37+
JETBRAINS_REMOTE_RUN?: string
38+
IDEA_INITIAL_DIRECTORY?: string
39+
IDE_CONFIG_DIR?: string
40+
JB_IDE_CONFIG_DIR?: string
41+
42+
// Editor preferences
43+
VISUAL?: string
44+
EDITOR?: string
45+
CODEBUFF_CLI_EDITOR?: string
46+
CODEBUFF_EDITOR?: string
47+
48+
// Theme preferences
49+
OPEN_TUI_THEME?: string
50+
OPENTUI_THEME?: string
51+
52+
// Codebuff CLI-specific (set during binary build)
53+
CODEBUFF_IS_BINARY?: string
54+
CODEBUFF_CLI_VERSION?: string
55+
CODEBUFF_CLI_TARGET?: string
56+
CODEBUFF_RG_PATH?: string
57+
}
58+
59+
/**
60+
* Full CLI env deps combining client env and CLI env.
61+
*/
62+
export type CliEnvDeps = {
63+
clientEnv: ClientEnv
64+
env: CliEnv
65+
}
66+
67+
/**
68+
* Function type for getting CLI env values.
69+
*/
70+
export type GetCliEnvFn = () => CliEnv

cli/src/utils/codebuff-client.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getCliEnv } from './env'
12
import { API_KEY_ENV_VAR } from '@codebuff/common/old-constants'
23
import { AskUserBridge } from '@codebuff/common/utils/ask-user-bridge'
34
import { CodebuffClient } from '@codebuff/sdk'
@@ -58,9 +59,11 @@ export async function getCodebuffClient(): Promise<CodebuffClient | null> {
5859
const projectRoot = getProjectRoot()
5960

6061
// Set up ripgrep path for SDK to use
61-
if (process.env.CODEBUFF_IS_BINARY) {
62+
const env = getCliEnv()
63+
if (env.CODEBUFF_IS_BINARY) {
6264
try {
6365
const rgPath = await getRgPath()
66+
// Note: We still set process.env here because SDK reads from it
6467
process.env.CODEBUFF_RG_PATH = rgPath
6568
} catch (error) {
6669
logger.error(error, 'Failed to set up ripgrep binary for SDK')

cli/src/utils/detect-shell.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { execSync } from 'child_process'
22

3+
import type { CliEnv } from '../types/env'
4+
import { getCliEnv } from './env'
5+
36
type KnownShell =
47
| 'bash'
58
| 'zsh'
@@ -23,24 +26,24 @@ const SHELL_ALIASES: Record<string, KnownShell> = {
2326
'powershell.exe': 'powershell',
2427
}
2528

26-
export function detectShell(): ShellName {
29+
export function detectShell(env: CliEnv = getCliEnv()): ShellName {
2730
if (cachedShell) {
2831
return cachedShell
2932
}
3033

3134
const detected =
32-
detectFromEnvironment() ?? detectViaParentProcessInspection() ?? 'unknown'
35+
detectFromEnvironment(env) ?? detectViaParentProcessInspection() ?? 'unknown'
3336
cachedShell = detected
3437
return detected
3538
}
3639

37-
function detectFromEnvironment(): ShellName | null {
40+
function detectFromEnvironment(env: CliEnv): ShellName | null {
3841
const candidates: Array<string | undefined> = []
3942

4043
if (process.platform === 'win32') {
41-
candidates.push(process.env.COMSPEC, process.env.SHELL)
44+
candidates.push(env.COMSPEC, env.SHELL)
4245
} else {
43-
candidates.push(process.env.SHELL)
46+
candidates.push(env.SHELL)
4447
}
4548

4649
for (const candidate of candidates) {

0 commit comments

Comments
 (0)