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:
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
147
src/components/environment-variables-editor.tsx
Normal file
147
src/components/environment-variables-editor.tsx
Normal 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
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export class FileStorage {
|
|||||||
return {
|
return {
|
||||||
preferences: {
|
preferences: {
|
||||||
showThinkingBlocks: false,
|
showThinkingBlocks: false,
|
||||||
|
environmentVariables: {},
|
||||||
},
|
},
|
||||||
recentFolders: [],
|
recentFolders: [],
|
||||||
opencodeBinaries: [],
|
opencodeBinaries: [],
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user