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

@@ -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";