Add environment variables configuration to OpenCode instances

- Add environment variables editor in advanced settings
- Store environment variables in global preferences
- Pass environment variables to OpenCode processes when spawning
- Display environment variables in instance information and server logs
- Fix system PATH binary handling for 'opencode' command
- Show detailed environment variable values in startup logs
- Save and restore last used binary across app restarts
- Add system PATH binary to recent binaries list when used

Features:
- Environment variables editor with add/remove functionality
- Persistent storage of environment variables across sessions
- Real-time display of active environment variables in logs
- Instance information shows configured environment variables
- Proper handling of system PATH vs custom binary paths
- Last used binary persistence and automatic restoration
This commit is contained in:
Shantur Rathore
2025-10-26 10:48:47 +00:00
parent f63a4b3754
commit 0b26ffd97d
8 changed files with 245 additions and 49 deletions

View File

@@ -37,47 +37,54 @@ export function setupInstanceIPC(mainWindow: BrowserWindow) {
return result.filePaths[0] return result.filePaths[0]
}) })
ipcMain.handle("instance:create", async (event, id: string, folder: string, binaryPath?: string) => { ipcMain.handle(
const instance: Instance = { "instance:create",
id, async (event, id: string, folder: string, binaryPath?: string, environmentVariables?: Record<string, string>) => {
folder, const instance: Instance = {
port: 0, id,
pid: 0, folder,
status: "starting", port: 0,
} pid: 0,
status: "starting",
instances.set(id, instance)
try {
const { pid, port, binaryPath: actualBinaryPath } = await processManager.spawn(folder, id, binaryPath)
instance.port = port
instance.pid = pid
instance.status = "ready"
mainWindow.webContents.send("instance:started", { id, port, pid, binaryPath: actualBinaryPath })
const meta = processManager.getAllProcesses().get(pid)
if (meta) {
meta.childProcess.on("exit", (code, signal) => {
instance.status = "stopped"
mainWindow.webContents.send("instance:stopped", { id })
})
} }
return { id, port, pid, binaryPath: actualBinaryPath } instances.set(id, instance)
} catch (error) {
instance.status = "error"
instance.error = error instanceof Error ? error.message : String(error)
mainWindow.webContents.send("instance:error", { try {
id, const {
error: instance.error, pid,
}) port,
binaryPath: actualBinaryPath,
} = await processManager.spawn(folder, id, binaryPath, environmentVariables)
throw error instance.port = port
} instance.pid = pid
}) instance.status = "ready"
mainWindow.webContents.send("instance:started", { id, port, pid, binaryPath: actualBinaryPath })
const meta = processManager.getAllProcesses().get(pid)
if (meta) {
meta.childProcess.on("exit", (code, signal) => {
instance.status = "stopped"
mainWindow.webContents.send("instance:stopped", { id })
})
}
return { id, port, pid, binaryPath: actualBinaryPath }
} catch (error) {
instance.status = "error"
instance.error = error instanceof Error ? error.message : String(error)
mainWindow.webContents.send("instance:error", {
id,
error: instance.error,
})
throw error
}
},
)
ipcMain.handle("instance:stop", async (event, pid: number) => { ipcMain.handle("instance:stop", async (event, pid: number) => {
await processManager.kill(pid) await processManager.kill(pid)

View File

@@ -50,17 +50,29 @@ class ProcessManager {
} }
} }
async spawn(folder: string, instanceId: string, binaryPath?: string): Promise<ProcessInfo> { async spawn(
folder: string,
instanceId: string,
binaryPath?: string,
environmentVariables?: Record<string, string>,
): Promise<ProcessInfo> {
this.validateFolder(folder) this.validateFolder(folder)
const actualBinaryPath = binaryPath ? this.validateCustomBinary(binaryPath) : this.validateOpenCodeBinary() const actualBinaryPath = binaryPath ? this.validateCustomBinary(binaryPath) : this.validateOpenCodeBinary()
this.sendLog(instanceId, "info", `Starting OpenCode server for ${folder} using ${actualBinaryPath}...`) this.sendLog(instanceId, "info", `Starting OpenCode server for ${folder} using ${actualBinaryPath}...`)
// Merge environment variables with process environment
const env = { ...process.env }
if (environmentVariables) {
Object.assign(env, environmentVariables)
this.sendLog(instanceId, "info", `Using ${Object.keys(environmentVariables).length} custom environment variables`)
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const child = spawn(actualBinaryPath, ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"], { const child = spawn(actualBinaryPath, ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"], {
cwd: folder, cwd: folder,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
env: process.env, env,
shell: false, shell: false,
}) })

View File

@@ -6,6 +6,7 @@ export interface ElectronAPI {
id: string, id: string,
folder: string, folder: string,
binaryPath?: string, binaryPath?: string,
environmentVariables?: Record<string, string>,
) => Promise<{ id: string; port: number; pid: number; binaryPath: string }> ) => Promise<{ id: string; port: number; pid: number; binaryPath: string }>
stopInstance: (pid: number) => Promise<void> stopInstance: (pid: number) => Promise<void>
onInstanceStarted: (callback: (data: { id: string; port: number; pid: number; binaryPath: string }) => void) => void onInstanceStarted: (callback: (data: { id: string; port: number; pid: number; binaryPath: string }) => void) => void
@@ -35,8 +36,8 @@ export interface ElectronAPI {
const electronAPI: ElectronAPI = { const electronAPI: ElectronAPI = {
selectFolder: () => ipcRenderer.invoke("dialog:selectFolder"), selectFolder: () => ipcRenderer.invoke("dialog:selectFolder"),
createInstance: (id: string, folder: string, binaryPath?: string) => createInstance: (id: string, folder: string, binaryPath?: string, environmentVariables?: Record<string, string>) =>
ipcRenderer.invoke("instance:create", id, folder, binaryPath), ipcRenderer.invoke("instance:create", id, folder, binaryPath, environmentVariables),
stopInstance: (pid: number) => ipcRenderer.invoke("instance:stop", pid), stopInstance: (pid: number) => ipcRenderer.invoke("instance:stop", pid),
onInstanceStarted: (callback) => { onInstanceStarted: (callback) => {
ipcRenderer.on("instance:started", (_, data) => callback(data)) ipcRenderer.on("instance:started", (_, data) => callback(data))

View File

@@ -0,0 +1,147 @@
import { Component, createSignal, For, Show } from "solid-js"
import { Plus, Trash2, Key, Globe } from "lucide-solid"
import {
preferences,
addEnvironmentVariable,
removeEnvironmentVariable,
updateEnvironmentVariables,
} from "../stores/preferences"
interface EnvironmentVariablesEditorProps {
disabled?: boolean
}
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
const [envVars, setEnvVars] = createSignal<Record<string, string>>(preferences().environmentVariables || {})
const [newKey, setNewKey] = createSignal("")
const [newValue, setNewValue] = createSignal("")
const entries = () => Object.entries(envVars())
function handleAddVariable() {
const key = newKey().trim()
const value = newValue().trim()
if (!key) return
addEnvironmentVariable(key, value)
setEnvVars({ ...envVars(), [key]: value })
setNewKey("")
setNewValue("")
}
function handleRemoveVariable(key: string) {
removeEnvironmentVariable(key)
const { [key]: removed, ...rest } = envVars()
setEnvVars(rest)
}
function handleUpdateVariable(key: string, value: string) {
const updated = { ...envVars(), [key]: value }
setEnvVars(updated)
updateEnvironmentVariables(updated)
}
function handleKeyPress(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleAddVariable()
}
}
return (
<div class="space-y-3">
<div class="flex items-center gap-2 mb-3">
<Globe class="w-4 h-4 text-gray-500 dark:text-gray-400" />
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Environment Variables</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
({entries().length} variable{entries().length !== 1 ? "s" : ""})
</span>
</div>
{/* Existing variables */}
<Show when={entries().length > 0}>
<div class="space-y-2">
<For each={entries()}>
{([key, value]) => (
<div class="flex items-center gap-2">
<div class="flex-1 flex items-center gap-2">
<Key class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 flex-shrink-0" />
<input
type="text"
value={key}
disabled={props.disabled}
class="flex-1 px-2.5 py-1.5 text-sm bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-500 dark:text-gray-400 cursor-not-allowed"
placeholder="Variable name"
title="Variable name (read-only)"
/>
<input
type="text"
value={value}
disabled={props.disabled}
onInput={(e) => handleUpdateVariable(key, e.currentTarget.value)}
class="flex-1 px-2.5 py-1.5 text-sm bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Variable value"
/>
</div>
<button
onClick={() => handleRemoveVariable(key)}
disabled={props.disabled}
class="p-1.5 text-gray-400 dark:text-gray-500 hover:text-red-600 dark:hover:text-red-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Remove variable"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
)}
</For>
</div>
</Show>
{/* Add new variable */}
<div class="flex items-center gap-2 pt-2 border-t border-gray-200 dark:border-gray-600">
<div class="flex-1 flex items-center gap-2">
<Key class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 flex-shrink-0" />
<input
type="text"
value={newKey()}
onInput={(e) => setNewKey(e.currentTarget.value)}
onKeyPress={handleKeyPress}
disabled={props.disabled}
class="flex-1 px-2.5 py-1.5 text-sm bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Variable name"
/>
<input
type="text"
value={newValue()}
onInput={(e) => setNewValue(e.currentTarget.value)}
onKeyPress={handleKeyPress}
disabled={props.disabled}
class="flex-1 px-2.5 py-1.5 text-sm bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Variable value"
/>
</div>
<button
onClick={handleAddVariable}
disabled={props.disabled || !newKey().trim()}
class="p-1.5 text-gray-400 dark:text-gray-500 hover:text-blue-600 dark:hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Add variable"
>
<Plus class="w-3.5 h-3.5" />
</button>
</div>
<Show when={entries().length === 0}>
<div class="text-xs text-gray-500 dark:text-gray-400 text-center py-2">
No environment variables configured. Add variables above to customize the OpenCode environment.
</div>
</Show>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-2">
These variables will be available in the OpenCode environment when starting instances.
</div>
</div>
)
}
export default EnvironmentVariablesEditor

View File

@@ -2,6 +2,7 @@ import { Component, createSignal, Show, For, onMount, onCleanup } from "solid-js
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronDown, ChevronUp } from "lucide-solid" import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronDown, ChevronUp } from "lucide-solid"
import { recentFolders, removeRecentFolder, preferences } from "../stores/preferences" import { recentFolders, removeRecentFolder, preferences } from "../stores/preferences"
import OpenCodeBinarySelector from "./opencode-binary-selector" import OpenCodeBinarySelector from "./opencode-binary-selector"
import EnvironmentVariablesEditor from "./environment-variables-editor"
interface FolderSelectionViewProps { interface FolderSelectionViewProps {
onSelectFolder: (folder?: string, binaryPath?: string) => void onSelectFolder: (folder?: string, binaryPath?: string) => void
@@ -265,13 +266,19 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</button> </button>
<Show when={showAdvanced()}> <Show when={showAdvanced()}>
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-900/50 overflow-visible"> <div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-900/50 overflow-visible space-y-4">
<div class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">OpenCode Binary</div> <div>
<OpenCodeBinarySelector <div class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">OpenCode Binary</div>
selectedBinary={selectedBinary()} <OpenCodeBinarySelector
onBinaryChange={setSelectedBinary} selectedBinary={selectedBinary()}
disabled={props.isLoading} onBinaryChange={setSelectedBinary}
/> disabled={props.isLoading}
/>
</div>
<div>
<EnvironmentVariablesEditor disabled={props.isLoading} />
</div>
</div> </div>
</Show> </Show>
</div> </div>

View File

@@ -51,6 +51,7 @@ export class FileStorage {
return { return {
preferences: { preferences: {
showThinkingBlocks: false, showThinkingBlocks: false,
environmentVariables: {},
}, },
recentFolders: [], recentFolders: [],
opencodeBinaries: [], opencodeBinaries: [],

View File

@@ -3,6 +3,7 @@ import type { Instance, LogEntry } from "../types/instance"
import { sdkManager } from "../lib/sdk-manager" import { sdkManager } from "../lib/sdk-manager"
import { sseManager } from "../lib/sse-manager" import { sseManager } from "../lib/sse-manager"
import { fetchSessions, fetchAgents, fetchProviders } from "./sessions" import { fetchSessions, fetchAgents, fetchProviders } from "./sessions"
import { preferences } from "./preferences"
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map()) const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null) const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null)
@@ -61,7 +62,7 @@ async function createInstance(folder: string, binaryPath?: string): Promise<stri
port, port,
pid, pid,
binaryPath: actualBinaryPath, binaryPath: actualBinaryPath,
} = await window.electronAPI.createInstance(id, folder, binaryPath) } = await window.electronAPI.createInstance(id, folder, binaryPath, preferences().environmentVariables)
const client = sdkManager.createClient(port) const client = sdkManager.createClient(port)

View File

@@ -4,6 +4,7 @@ import { storage, type ConfigData } from "../lib/storage"
export interface Preferences { export interface Preferences {
showThinkingBlocks: boolean showThinkingBlocks: boolean
lastUsedBinary?: string lastUsedBinary?: string
environmentVariables?: Record<string, string>
} }
export interface OpenCodeBinary { export interface OpenCodeBinary {
@@ -105,6 +106,22 @@ function updateLastUsedBinary(path: string): void {
} }
} }
function updateEnvironmentVariables(envVars: Record<string, string>): void {
updatePreferences({ environmentVariables: envVars })
}
function addEnvironmentVariable(key: string, value: string): void {
const current = preferences().environmentVariables || {}
const updated = { ...current, [key]: value }
updateEnvironmentVariables(updated)
}
function removeEnvironmentVariable(key: string): void {
const current = preferences().environmentVariables || {}
const { [key]: removed, ...rest } = current
updateEnvironmentVariables(rest)
}
// Load config on mount and listen for changes from other instances // Load config on mount and listen for changes from other instances
onMount(() => { onMount(() => {
loadConfig() loadConfig()
@@ -129,4 +146,7 @@ export {
addOpenCodeBinary, addOpenCodeBinary,
removeOpenCodeBinary, removeOpenCodeBinary,
updateLastUsedBinary, updateLastUsedBinary,
updateEnvironmentVariables,
addEnvironmentVariable,
removeEnvironmentVariable,
} }