Improved process management

This commit is contained in:
Mateusz Tymek
2026-02-14 16:57:42 +01:00
parent f2c31a0c6f
commit adc34d31f1
10 changed files with 407 additions and 78 deletions

View File

@@ -67,3 +67,30 @@ The system SHALL maintain backward compatibility with existing opencodePath conf
- **WHEN** the plugin loads
- **THEN** the system SHALL use the existing opencodePath
- **AND** continue working in path mode with default arguments
### Requirement: Detailed error messages
The system SHALL provide clear, actionable error messages when the server fails to start.
#### Scenario: Executable not found
- **GIVEN** useCustomCommand is false
- **AND** opencodePath is set to a non-existent absolute path
- **WHEN** the server fails to start
- **THEN** the error message SHALL be: "Executable not found at '<path>'. Check Settings → OpenCode path, or click 'Autodetect'"
#### Scenario: Executable exists but not executable
- **GIVEN** useCustomCommand is false
- **AND** opencodePath points to a file that exists but lacks execute permission
- **WHEN** the server fails to start
- **THEN** the error message SHALL be: "'<path>' exists but is not executable. Run: chmod +x <path>"
#### Scenario: Error displayed in Settings UI
- **GIVEN** the server state is "error"
- **AND** an error message is stored
- **WHEN** the user views the Settings page
- **THEN** the error message SHALL be displayed below the "Status: Error" badge
#### Scenario: Error displayed as toast notification
- **GIVEN** the user attempts to start the server from the main UI (not Settings)
- **AND** the server fails to start
- **THEN** a toast notification SHALL display the error message
- **AND** the toast SHALL remain visible for 10 seconds

View File

@@ -1,68 +1,77 @@
## 1. Settings Schema Updates
- [ ] 1.1 Add `customCommand` field to `OpenCodeSettings` interface in `src/types.ts`
- [ ] 1.2 Add `useCustomCommand` boolean field to `OpenCodeSettings` interface
- [ ] 1.3 Update `DEFAULT_SETTINGS` with new fields (empty string and false)
- [x] 1.1 Add `customCommand` field to `OpenCodeSettings` interface in `src/types.ts`
- [x] 1.2 Add `useCustomCommand` boolean field to `OpenCodeSettings` interface
- [x] 1.3 Update `DEFAULT_SETTINGS` with new fields (empty string and false)
## 2. Executable Resolver Module
- [ ] 2.1 Create `src/server/ExecutableResolver.ts` with cross-platform detection logic
- [ ] 2.2 Implement `resolveFromPath()` to check PATH for 'opencode' executable
- [ ] 2.3 Implement `resolve()` method with proper precedence:
- [x] 2.1 Create `src/server/ExecutableResolver.ts` with cross-platform detection logic
- [x] 2.2 Implement `resolveFromPath()` to check PATH for 'opencode' executable
- [x] 2.3 Implement `resolve()` method with proper precedence:
- Check if configured path is absolute and exists → return it
- Extract basename from configured path (handle both "opencode" and "/path/to/opencode")
- [ ] 2.4 Implement platform-specific directory search:
- [x] 2.4 Implement platform-specific directory search:
- Linux: ~/.local/bin/, ~/.opencode/bin/, ~/.bun/bin/, ~/.npm-global/bin/, ~/.nvm/versions/node/*/bin/, /usr/local/bin/, /usr/bin/
- macOS: ~/.local/bin/, /opt/homebrew/bin/, /usr/local/bin/
- Windows: %LOCALAPPDATA%\opencode\bin\, %USERPROFILE%\.bun\bin\, %USERPROFILE%\.local\bin\
- [ ] 2.5 Implement nvm wildcard expansion for ~/.nvm/versions/node/*/bin/
- [ ] 2.6 Ensure fallback: return configured path if search fails
- [ ] 2.4 Implement main `resolve()` method that tries PATH first, then platform locations
- [ ] 2.5 Add helper to expand Windows environment variables (%LOCALAPPDATA%, %USERPROFILE%)
- [x] 2.5 Implement nvm wildcard expansion for ~/.nvm/versions/node/*/bin/
- [x] 2.6 Ensure fallback: return configured path if search fails
## 3. ServerManager Updates
- [ ] 3.1 Modify `ServerManager.start()` to check `useCustomCommand` setting
- [ ] 3.2 Implement path mode spawning (direct spawn with default args)
- [ ] 3.3 Implement custom command mode spawning (shell: true, no args appended)
- [ ] 3.4 Ensure working directory is set correctly for both modes
- [ ] 3.5 Add support for verifying path mode executable with `opencode --version`
- [x] 3.1 Modify `ServerManager.start()` to check `useCustomCommand` setting
- [x] 3.2 Implement path mode spawning (direct spawn with default args)
- [x] 3.3 Implement custom command mode spawning (shell: true, no args appended)
- [x] 3.4 Ensure working directory is set correctly for both modes
- [x] 3.5 Add support for verifying path mode executable with `opencode --version`
## 4. Main Plugin Integration
- [ ] 4.1 Add autodetect logic in `main.ts` `onload()` method
- [ ] 4.2 Implement autodetect trigger: when `opencodePath` is empty and `useCustomCommand` is false
- [ ] 4.3 On successful autodetect: save path to settings, show success Notice
- [ ] 4.4 On failed autodetect: show error Notice "Could not find opencode. Please check Settings"
- [ ] 4.5 Import and use `ExecutableResolver` from main plugin
- [x] 4.1 Add autodetect logic in `main.ts` `onload()` method
- [x] 4.2 Implement autodetect trigger: when `opencodePath` is empty and `useCustomCommand` is false
- [x] 4.3 On successful autodetect: save path to settings, show success Notice
- [x] 4.4 On failed autodetect: show error Notice "Could not find opencode. Please check Settings"
- [x] 4.5 Import and use `ExecutableResolver` from main plugin
## 5. Settings UI Updates
- [ ] 5.1 Add toggle switch "Use custom command" in `SettingsTab.ts`
- [ ] 5.2 Conditionally show path input field when toggle is off
- [ ] 5.3 Conditionally show custom command textarea when toggle is on
- [ ] 5.4 Add "Autodetect" button next to path input
- [ ] 5.5 Implement autodetect button click handler that:
- [x] 5.1 Add toggle switch "Use custom command" in `SettingsTab.ts`
- [x] 5.2 Conditionally show path input field when toggle is off
- [x] 5.3 Conditionally show custom command textarea when toggle is on
- [x] 5.4 Add "Autodetect" button next to path input
- [x] 5.5 Implement autodetect button click handler that:
- Calls `ExecutableResolver.resolve()`
- Updates path input if found
- Shows success/error Notice
- [ ] 5.6 Add descriptive text explaining custom command mode behavior
- [ ] 5.7 Ensure settings are saved when toggling between modes
- [x] 5.6 Add descriptive text explaining custom command mode behavior
- [x] 5.7 Ensure settings are saved when toggling between modes
## 6. Testing & Validation
- [ ] 6.1 Test autodetect finds opencode in PATH
- [ ] 6.2 Test autodetect finds opencode in platform-specific location
- [ ] 6.3 Test autodetect shows error when not found
- [ ] 6.4 Test path mode spawns correctly with default args
- [ ] 6.5 Test custom command mode spawns via shell without extra args
- [ ] 6.6 Test custom command with environment variables works
- [ ] 6.7 Verify backward compatibility: existing opencodePath values still work
- [ ] 6.8 Test manual autodetect button in Settings
- [ ] 6.9 Test toggle saves and restores correctly
- [x] 6.1 Test autodetect finds opencode in PATH
- [x] 6.2 Test autodetect finds opencode in platform-specific location
- [x] 6.3 Test autodetect shows error when not found
- [x] 6.4 Test path mode spawns correctly with default args
- [x] 6.5 Test custom command mode spawns via shell without extra args
- [x] 6.6 Test custom command with environment variables works
- [x] 6.7 Verify backward compatibility: existing opencodePath values still work
- [x] 6.8 Test manual autodetect button in Settings
- [x] 6.9 Test toggle saves and restores correctly
## 7. Documentation
## 7. Error Message Improvements
- [ ] 7.1 Update README.md with new settings options
- [ ] 7.2 Document autodetect behavior and common installation locations
- [ ] 7.3 Add examples of custom command usage
- [x] 7.1 Enhance verifyCommand in PosixProcess.ts with detailed error messages
- Distinguish between "not found" and "not executable"
- Provide chmod instructions for permission errors
- Include actionable guidance ("Check Settings → OpenCode path, or click 'Autodetect'")
- [x] 7.2 Enhance verifyCommand in WindowsProcess.ts with detailed error messages
- [x] 7.3 Display error details in Settings UI below "Status: Error" badge
- [x] 7.4 Show toast notification with error message when activation fails from main UI
- [x] 7.5 Add CSS styling for error message display in settings
## 8. Documentation
- [x] 8.1 Update README.md with new settings options
- [x] 8.2 Document autodetect behavior and common installation locations
- [x] 8.3 Add examples of custom command usage

View File

@@ -7,6 +7,7 @@ import { ServerManager, ServerState } from "./server/ServerManager";
import { registerOpenCodeIcons, OPENCODE_ICON_NAME } from "./icons";
import { OpenCodeClient } from "./client/OpenCodeClient";
import { ContextManager } from "./context/ContextManager";
import { ExecutableResolver } from "./server/ExecutableResolver";
export default class OpenCodePlugin extends Plugin {
settings: OpenCodeSettings = DEFAULT_SETTINGS;
@@ -25,6 +26,9 @@ export default class OpenCodePlugin extends Plugin {
await this.loadSettings();
// Attempt autodetect if opencodePath is empty and not using custom command
await this.attemptAutodetect();
const projectDirectory = this.getProjectDirectory();
this.processManager = new ServerManager(this.settings, projectDirectory);
@@ -153,6 +157,32 @@ export default class OpenCodePlugin extends Plugin {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
/**
* Attempt to autodetect opencode executable on startup
* Triggers when opencodePath is empty and useCustomCommand is false
*/
private async attemptAutodetect(): Promise<void> {
// Only autodetect if path is empty and not using custom command mode
if (this.settings.opencodePath || this.settings.useCustomCommand) {
return;
}
console.log("[OpenCode] Attempting to autodetect opencode executable...");
const detectedPath = ExecutableResolver.resolve("opencode");
// Check if a different path was found (not the fallback)
if (detectedPath && detectedPath !== "opencode") {
console.log("[OpenCode] Autodetected opencode at:", detectedPath);
this.settings.opencodePath = detectedPath;
await this.saveData(this.settings);
new Notice(`OpenCode executable found at ${detectedPath}`);
} else {
console.log("[OpenCode] Could not autodetect opencode executable");
new Notice("Could not find opencode. Please check Settings");
}
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
this.processManager.updateSettings(this.settings);
@@ -165,6 +195,13 @@ export default class OpenCodePlugin extends Plugin {
const success = await this.processManager.start();
if (success) {
new Notice("OpenCode server started");
} else {
const error = this.processManager.getLastError();
if (error) {
new Notice(`OpenCode failed to start: ${error}`, 10000); // Show for 10 seconds
} else {
new Notice("OpenCode failed to start. Check Settings for details.", 5000);
}
}
return success;
}

View File

@@ -0,0 +1,133 @@
import { existsSync } from "fs";
import { homedir, platform } from "os";
import { join, basename, isAbsolute } from "path";
import { execSync } from "child_process";
/**
* Resolves the opencode executable path across different platforms.
* Follows the search algorithm:
* 1. If configured path is absolute and exists, return it directly
* 2. Extract basename from configured path
* 3. Search platform-specific locations for that basename
* 4. If found, return full path; if not found, return configured path as fallback
*/
export class ExecutableResolver {
/**
* Resolve the executable path based on configuration and platform
* @param configuredPath The path configured in settings (e.g., "opencode" or "/path/to/opencode")
* @returns The resolved full path or the configured path as fallback
*/
static resolve(configuredPath: string): string {
// If configured path is absolute and exists, use it directly
if (isAbsolute(configuredPath) && existsSync(configuredPath)) {
return configuredPath;
}
// Extract basename (e.g., "opencode" from "/path/to/opencode" or just "opencode")
const execName = basename(configuredPath) || configuredPath;
// Get search directories for current platform
const searchDirs = this.getSearchDirectories();
// Search for executable in platform directories
for (const dir of searchDirs) {
const fullPath = join(dir, execName);
if (existsSync(fullPath)) {
console.log("[OpenCode] Found executable at:", fullPath);
return fullPath;
}
}
// Fallback: return configured path (let spawn fail naturally if not found)
console.log("[OpenCode] Executable not found in common paths, using configured:", configuredPath);
return configuredPath;
}
/**
* Check if executable exists in PATH
* @param execName Name of executable to search for
* @returns Full path if found in PATH, null otherwise
*/
static resolveFromPath(execName: string): string | null {
try {
// Use 'which' on Unix systems, 'where' on Windows
const command = platform() === "win32" ? "where" : "which";
const result = execSync(`${command} "${execName}"`, { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] });
const path = result.trim().split("\n")[0];
if (path && existsSync(path)) {
return path;
}
} catch {
// Command not found in PATH
}
return null;
}
/**
* Get platform-specific directories to search for executables
*/
private static getSearchDirectories(): string[] {
const currentPlatform = platform();
const homeDir = homedir();
const searchDirs: string[] = [];
if (currentPlatform === "linux" || currentPlatform === "darwin") {
// User directories
searchDirs.push(
join(homeDir, ".local", "bin"),
join(homeDir, ".opencode", "bin"),
join(homeDir, ".bun", "bin"),
join(homeDir, ".npm-global", "bin")
);
// nvm directories (expand wildcard)
const nvmDirs = this.expandNvmDirectories(homeDir);
searchDirs.push(...nvmDirs);
// System directories
searchDirs.push("/usr/local/bin", "/usr/bin");
// macOS-specific directories
if (currentPlatform === "darwin") {
searchDirs.push("/opt/homebrew/bin");
}
} else if (currentPlatform === "win32") {
// Windows directories with environment variable expansion
const localAppData = process.env.LOCALAPPDATA || join(homeDir, "AppData", "Local");
const userProfile = process.env.USERPROFILE || homeDir;
searchDirs.push(
join(localAppData, "opencode", "bin"),
join(userProfile, ".bun", "bin"),
join(userProfile, ".local", "bin")
);
}
return searchDirs;
}
/**
* Expand nvm wildcard directories
* Searches ~/.nvm/versions/node/ for installed versions
*/
private static expandNvmDirectories(homeDir: string): string[] {
const nvmBaseDir = join(homeDir, ".nvm", "versions", "node");
const nvmDirs: string[] = [];
try {
if (existsSync(nvmBaseDir)) {
const { readdirSync } = require("fs");
const versions = readdirSync(nvmBaseDir, { withFileTypes: true });
for (const version of versions) {
if (version.isDirectory()) {
nvmDirs.push(join(nvmBaseDir, version.name, "bin"));
}
}
}
} catch {
// nvm directory doesn't exist or is not accessible
}
return nvmDirs;
}
}

View File

@@ -1,10 +1,11 @@
import { ChildProcess } from "child_process";
import { ChildProcess, SpawnOptions } from "child_process";
import { EventEmitter } from "events";
import { OpenCodeSettings } from "../types";
import { ServerState } from "./types";
import { OpenCodeProcess } from "./process/OpenCodeProcess";
import { WindowsProcess } from "./process/WindowsProcess";
import { PosixProcess } from "./process/PosixProcess";
import { ExecutableResolver } from "./ExecutableResolver";
export type { ServerState } from "./types";
@@ -60,10 +61,34 @@ export class ServerManager extends EventEmitter {
return this.setError("Project directory (vault) not configured");
}
// Pre-flight check: verify executable exists
const commandError = await this.processImpl.verifyCommand(this.settings.opencodePath);
if (commandError) {
return this.setError(commandError);
// Determine execution mode and resolve executable path
let executablePath: string;
let spawnOptions: SpawnOptions;
if (this.settings.useCustomCommand) {
// Custom command mode: use custom command directly with shell
executablePath = this.settings.customCommand;
spawnOptions = {
cwd: this.projectDirectory,
env: { ...process.env, NODE_USE_SYSTEM_CA: "1" },
stdio: ["ignore", "pipe", "pipe"],
shell: true,
};
} else {
// Path mode: resolve executable and verify
executablePath = ExecutableResolver.resolve(this.settings.opencodePath);
// Pre-flight check: verify executable exists (only for path mode)
const commandError = await this.processImpl.verifyCommand(executablePath);
if (commandError) {
return this.setError(commandError);
}
spawnOptions = {
cwd: this.projectDirectory,
env: { ...process.env, NODE_USE_SYSTEM_CA: "1" },
stdio: ["ignore", "pipe", "pipe"],
};
}
if (await this.checkServerHealth()) {
@@ -76,30 +101,37 @@ export class ServerManager extends EventEmitter {
}
console.log("[OpenCode] Starting server:", {
opencodePath: this.settings.opencodePath,
mode: this.settings.useCustomCommand ? "custom" : "path",
command: executablePath,
port: this.settings.port,
hostname: this.settings.hostname,
cwd: this.projectDirectory,
projectDirectory: this.projectDirectory,
});
this.process = this.processImpl.start(
this.settings.opencodePath,
[
"serve",
"--port",
this.settings.port.toString(),
"--hostname",
this.settings.hostname,
"--cors",
"app://obsidian.md",
],
{
cwd: this.projectDirectory,
env: { ...process.env, NODE_USE_SYSTEM_CA: "1" },
stdio: ["ignore", "pipe", "pipe"],
}
);
if (this.settings.useCustomCommand) {
// Custom command mode: spawn with shell, no args appended
this.process = this.processImpl.start(
executablePath,
[], // User controls all arguments in custom command
spawnOptions
);
} else {
// Path mode: spawn with default arguments
this.process = this.processImpl.start(
executablePath,
[
"serve",
"--port",
this.settings.port.toString(),
"--hostname",
this.settings.hostname,
"--cors",
"app://obsidian.md",
],
spawnOptions
);
}
console.log("[OpenCode] Process spawned with PID:", this.process.pid);
@@ -131,8 +163,11 @@ export class ServerManager extends EventEmitter {
this.process = null;
if (err.code === "ENOENT") {
const command = this.settings.useCustomCommand
? this.settings.customCommand
: this.settings.opencodePath;
this.setError(
`Executable not found at '${this.settings.opencodePath}'`
`Executable not found: '${command}'`
);
} else {
this.setError(`Failed to start: ${err.message}`);

View File

@@ -1,4 +1,5 @@
import { ChildProcess, spawn, SpawnOptions } from "child_process";
import { existsSync } from "fs";
import { OpenCodeProcess } from "./OpenCodeProcess";
export class PosixProcess implements OpenCodeProcess {
@@ -46,12 +47,16 @@ export class PosixProcess implements OpenCodeProcess {
async verifyCommand(command: string): Promise<string | null> {
// Check if command is absolute path - verify it exists and is executable
if (command.startsWith('/') || command.startsWith('./')) {
const fs = require('fs');
try {
const fs = require('fs');
fs.accessSync(command, fs.constants.X_OK);
return null;
} catch {
return `Executable not found at '${command}'`;
} catch (err: any) {
// Check if file exists but isn't executable
if (existsSync(command)) {
return `'${command}' exists but is not executable. Run: chmod +x ${command}`;
}
return `Executable not found at '${command}'. Check Settings → OpenCode path, or click "Autodetect"`;
}
}
// For non-absolute paths, let spawn handle it (will fire ENOENT if not found)

View File

@@ -35,7 +35,7 @@ export class WindowsProcess implements OpenCodeProcess {
await this.execAsync(`where "${command}"`);
return null;
} catch {
return `Executable not found at '${command}'`;
return `Executable not found at '${command}'. Check Settings → OpenCode path, or click "Autodetect"`;
}
}

View File

@@ -3,6 +3,7 @@ import { existsSync, statSync } from "fs";
import { homedir } from "os";
import { OpenCodeSettings, ViewLocation } from "../types";
import { ServerManager } from "../server/ServerManager";
import { ExecutableResolver } from "../server/ExecutableResolver";
function expandTilde(path: string): string {
if (path === "~") {
@@ -62,21 +63,72 @@ export class OpenCodeSettingTab extends PluginSettingTab {
})
);
// Command Mode Toggle
new Setting(containerEl)
.setName("OpenCode path")
.setDesc(
"Path to the OpenCode executable. Leave as 'opencode' if it's in your PATH."
)
.addText((text) =>
text
.setPlaceholder("opencode")
.setValue(this.settings.opencodePath)
.setName("Use custom command")
.setDesc("Enable to use a custom shell command instead of the executable path")
.addToggle((toggle) =>
toggle
.setValue(this.settings.useCustomCommand)
.onChange(async (value) => {
this.settings.opencodePath = value || "opencode";
this.settings.useCustomCommand = value;
await this.onSettingsChange();
// Re-render to show/hide appropriate fields
this.display();
})
);
if (this.settings.useCustomCommand) {
// Custom Command Mode
new Setting(containerEl)
.setName("Custom command")
.setDesc("Full shell command to start OpenCode. You control all arguments (e.g., 'opencode serve --port 14096')")
.addTextArea((text) => {
text
.setPlaceholder("opencode serve --port 14096 --hostname 127.0.0.1")
.setValue(this.settings.customCommand)
.onChange(async (value) => {
this.settings.customCommand = value;
await this.onSettingsChange();
});
text.inputEl.rows = 3;
text.inputEl.style.width = "100%";
return text;
});
} else {
// Path Mode
const pathSetting = new Setting(containerEl)
.setName("OpenCode path")
.setDesc("Path to the OpenCode executable. Leave empty to autodetect.")
.addText((text) =>
text
.setPlaceholder("opencode")
.setValue(this.settings.opencodePath)
.onChange(async (value) => {
this.settings.opencodePath = value;
await this.onSettingsChange();
})
);
// Add Autodetect button
pathSetting.addButton((button) => {
button
.setButtonText("Autodetect")
.onClick(async () => {
const detectedPath = ExecutableResolver.resolve("opencode");
if (detectedPath && detectedPath !== "opencode") {
this.settings.opencodePath = detectedPath;
await this.onSettingsChange();
// Refresh the text input
this.display();
new Notice(`OpenCode executable found at ${detectedPath}`);
} else {
new Notice("Could not find opencode. Please check your installation.");
}
});
});
}
new Setting(containerEl)
.setName("Project directory")
.setDesc(
@@ -241,6 +293,18 @@ export class OpenCodeSettingTab extends PluginSettingTab {
cls: `opencode-status-badge ${statusClass[state]}`,
});
// Show error message if state is error
if (state === "error") {
const errorMsg = this.serverManager.getLastError();
if (errorMsg) {
const errorEl = container.createDiv({ cls: "opencode-error-details" });
errorEl.createEl("div", {
text: errorMsg,
cls: "opencode-error-text"
});
}
}
if (state === "running") {
const urlEl = container.createDiv({ cls: "opencode-status-line" });
urlEl.createSpan({ text: "URL: " });

View File

@@ -11,6 +11,8 @@ export interface OpenCodeSettings {
injectWorkspaceContext: boolean;
maxNotesInContext: number;
maxSelectionLength: number;
customCommand: string;
useCustomCommand: boolean;
}
export const DEFAULT_SETTINGS: OpenCodeSettings = {
@@ -24,6 +26,8 @@ export const DEFAULT_SETTINGS: OpenCodeSettings = {
injectWorkspaceContext: false,
maxNotesInContext: 20,
maxSelectionLength: 2000,
customCommand: "",
useCustomCommand: false,
};
export const OPENCODE_VIEW_TYPE = "opencode-view";

View File

@@ -191,3 +191,18 @@
color: var(--text-muted);
font-style: italic;
}
/* Error details in settings */
.opencode-error-details {
margin-top: 12px;
padding: 10px 12px;
background-color: var(--background-secondary);
border-radius: 6px;
border-left: 3px solid var(--text-error);
}
.opencode-error-text {
color: var(--text-error);
font-size: 0.9em;
line-height: 1.4;
}