Improved process management
This commit is contained in:
37
src/main.ts
37
src/main.ts
@@ -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;
|
||||
}
|
||||
|
||||
133
src/server/ExecutableResolver.ts
Normal file
133
src/server/ExecutableResolver.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: " });
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user