Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8113b8cc0b | ||
|
|
9fd626c2df |
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
@@ -28,20 +28,24 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-bun-
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
|
- name: Cache OpenCode binary
|
||||||
|
id: cache-opencode
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.bun/bin/opencode
|
||||||
|
key: ${{ runner.os }}-opencode-${{ hashFiles('.github/workflows/ci.yml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-opencode-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: bun install
|
||||||
|
|
||||||
- name: Install OpenCode CLI
|
- name: Install OpenCode CLI
|
||||||
run: |
|
if: steps.cache-opencode.outputs.cache-hit != 'true'
|
||||||
bun install -g opencode-ai
|
run: bun install -g opencode-ai
|
||||||
echo "$HOME/.bun/bin" >> $GITHUB_PATH
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Verify OpenCode installation
|
- name: Verify OpenCode installation
|
||||||
run: |
|
run: opencode --version
|
||||||
export PATH="$HOME/.bun/bin:$PATH"
|
|
||||||
opencode --version
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: bun run tsc -noEmit -skipLibCheck
|
run: bun run tsc -noEmit -skipLibCheck
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "opencode-obsidian",
|
"id": "opencode-obsidian",
|
||||||
"name": "OpenCode-Obsidian",
|
"name": "OpenCode-Obsidian",
|
||||||
"version": "0.2.1",
|
"version": "0.2.0",
|
||||||
"minAppVersion": "1.4.0",
|
"minAppVersion": "1.4.0",
|
||||||
"description": "Embed OpenCode AI assistant in Obsidian for AI-powered note management",
|
"description": "Embed OpenCode AI assistant in Obsidian for AI-powered note management",
|
||||||
"author": "mtymek",
|
"author": "mtymek",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsidian-opencode",
|
"name": "obsidian-opencode",
|
||||||
"version": "0.2.1",
|
"version": "0.2.0",
|
||||||
"description": "Embed OpenCode AI assistant in Obsidian",
|
"description": "Embed OpenCode AI assistant in Obsidian",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export class ServerManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getUrl(): string {
|
getUrl(): string {
|
||||||
const encodedPath = Buffer.from(this.projectDirectory).toString('base64');
|
const encodedPath = btoa(this.projectDirectory);
|
||||||
return `http://${this.settings.hostname}:${this.settings.port}/${encodedPath}`;
|
return `http://${this.settings.hostname}:${this.settings.port}/${encodedPath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,130 +2,33 @@ import { ChildProcess, spawn, SpawnOptions } from "child_process";
|
|||||||
import { OpenCodeProcess } from "./OpenCodeProcess";
|
import { OpenCodeProcess } from "./OpenCodeProcess";
|
||||||
|
|
||||||
export class WindowsProcess implements OpenCodeProcess {
|
export class WindowsProcess implements OpenCodeProcess {
|
||||||
// Static state to track the current process for cleanup
|
|
||||||
private static currentProcess: ChildProcess | null = null;
|
|
||||||
private static cleanupHandlerRegistered = false;
|
|
||||||
|
|
||||||
start(
|
start(
|
||||||
command: string,
|
command: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
options: SpawnOptions
|
options: SpawnOptions
|
||||||
): ChildProcess {
|
): ChildProcess {
|
||||||
const process = spawn(command, args, {
|
return spawn(command, args, {
|
||||||
...options,
|
...options,
|
||||||
shell: true,
|
shell: true,
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store process for cleanup
|
|
||||||
WindowsProcess.currentProcess = process;
|
|
||||||
WindowsProcess.registerCleanupHandler();
|
|
||||||
|
|
||||||
return process;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(process: ChildProcess): Promise<void> {
|
async stop(process: ChildProcess): Promise<void> {
|
||||||
const pid = process.pid;
|
const pid = process.pid;
|
||||||
if (!pid) {
|
if (!pid) {
|
||||||
WindowsProcess.currentProcess = null;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[OpenCode] Stopping server process tree, PID:", pid);
|
console.log("[OpenCode] Stopping server process tree, PID:", pid);
|
||||||
|
|
||||||
// Method 1: Find and kill child processes (actual node.exe) using PowerShell
|
// Use taskkill with /T flag to kill process tree
|
||||||
// This is necessary because shell: true spawns cmd.exe -> node.exe, and
|
await this.execAsync(`taskkill /T /F /PID ${pid}`);
|
||||||
// killing cmd.exe leaves node.exe orphaned
|
|
||||||
try {
|
|
||||||
const { execSync } = require("child_process");
|
|
||||||
const output = execSync(
|
|
||||||
`powershell -Command "Get-CimInstance Win32_Process -Filter \\"ParentProcessId=${pid}\\" | Select-Object ProcessId"`,
|
|
||||||
{ encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }
|
|
||||||
);
|
|
||||||
|
|
||||||
const lines = output.split("\n").slice(3); // Skip headers
|
|
||||||
for (const line of lines) {
|
|
||||||
const childPid = line.trim();
|
|
||||||
if (childPid && !isNaN(parseInt(childPid))) {
|
|
||||||
try {
|
|
||||||
execSync(`taskkill /F /PID ${childPid}`, { stdio: "ignore" });
|
|
||||||
} catch {
|
|
||||||
// Child may already be gone
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// PowerShell lookup failed, continue to other methods
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method 2: Kill the parent process (cmd.exe)
|
|
||||||
try {
|
|
||||||
await this.execAsync(`taskkill /F /PID ${pid}`);
|
|
||||||
} catch {
|
|
||||||
// Parent may already be gone
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear stored process
|
|
||||||
WindowsProcess.currentProcess = null;
|
|
||||||
|
|
||||||
// Wait for process to exit
|
// Wait for process to exit
|
||||||
await this.waitForExit(process, 5000);
|
await this.waitForExit(process, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static registerCleanupHandler(): void {
|
|
||||||
if (WindowsProcess.cleanupHandlerRegistered) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register beforeunload handler for window close cleanup
|
|
||||||
// Skip in CI/test environments to avoid interfering with test lifecycle
|
|
||||||
if (typeof window !== "undefined" && !process.env.CI) {
|
|
||||||
window.addEventListener("beforeunload", () => {
|
|
||||||
if (WindowsProcess.currentProcess?.pid) {
|
|
||||||
WindowsProcess.killProcessSync(WindowsProcess.currentProcess.pid);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
WindowsProcess.cleanupHandlerRegistered = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static killProcessSync(pid: number): void {
|
|
||||||
try {
|
|
||||||
const { execSync } = require("child_process");
|
|
||||||
|
|
||||||
// Method 1: Kill child processes using PowerShell
|
|
||||||
try {
|
|
||||||
const output = execSync(
|
|
||||||
`powershell -Command "Get-CimInstance Win32_Process -Filter \\"ParentProcessId=${pid}\\" | Select-Object ProcessId"`,
|
|
||||||
{ encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }
|
|
||||||
);
|
|
||||||
|
|
||||||
const lines = output.split("\n").slice(3);
|
|
||||||
for (const line of lines) {
|
|
||||||
const childPid = line.trim();
|
|
||||||
if (childPid && !isNaN(parseInt(childPid))) {
|
|
||||||
try {
|
|
||||||
execSync(`taskkill /F /PID ${childPid}`, { stdio: "ignore" });
|
|
||||||
} catch {
|
|
||||||
// Child may already be gone
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// PowerShell lookup failed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method 2: Kill parent process
|
|
||||||
try {
|
|
||||||
execSync(`taskkill /F /PID ${pid}`, { stdio: "ignore" });
|
|
||||||
} catch {
|
|
||||||
// Parent may already be gone
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Process may already be gone
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifyCommand(command: string): Promise<string | null> {
|
async verifyCommand(command: string): Promise<string | null> {
|
||||||
// Use 'where' command to check if executable exists in PATH
|
// Use 'where' command to check if executable exists in PATH
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const DEFAULT_SETTINGS: OpenCodeSettings = {
|
|||||||
autoStart: false,
|
autoStart: false,
|
||||||
opencodePath: "opencode",
|
opencodePath: "opencode",
|
||||||
projectDirectory: "",
|
projectDirectory: "",
|
||||||
startupTimeout: 45000,
|
startupTimeout: 15000,
|
||||||
defaultViewLocation: "sidebar",
|
defaultViewLocation: "sidebar",
|
||||||
injectWorkspaceContext: false,
|
injectWorkspaceContext: false,
|
||||||
maxNotesInContext: 20,
|
maxNotesInContext: 20,
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ describe("ServerManager", () => {
|
|||||||
|
|
||||||
const url = currentManager.getUrl();
|
const url = currentManager.getUrl();
|
||||||
const expectedBase = `http://127.0.0.1:${port}`;
|
const expectedBase = `http://127.0.0.1:${port}`;
|
||||||
const expectedPath = Buffer.from(PROJECT_DIR).toString('base64');
|
const expectedPath = btoa(PROJECT_DIR);
|
||||||
|
|
||||||
expect(url).toBe(`${expectedBase}/${expectedPath}`);
|
expect(url).toBe(`${expectedBase}/${expectedPath}`);
|
||||||
});
|
});
|
||||||
@@ -285,37 +285,4 @@ describe("ServerManager", () => {
|
|||||||
expect(currentManager.getState()).toBe("stopped");
|
expect(currentManager.getState()).toBe("stopped");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Unicode path support", () => {
|
|
||||||
test("getUrl handles Chinese characters in project directory", () => {
|
|
||||||
const settings = createTestSettings(getNextPort());
|
|
||||||
const chinesePath = "C:/用户/Notes";
|
|
||||||
const manager = new ServerManager(settings, chinesePath);
|
|
||||||
|
|
||||||
const url = manager.getUrl();
|
|
||||||
|
|
||||||
expect(url).toContain("http://127.0.0.1:");
|
|
||||||
expect(url).toContain(Buffer.from(chinesePath).toString('base64'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("getUrl handles Japanese characters in project directory", () => {
|
|
||||||
const settings = createTestSettings(getNextPort());
|
|
||||||
const japanesePath = "/home/ユーザー/ノート";
|
|
||||||
const manager = new ServerManager(settings, japanesePath);
|
|
||||||
|
|
||||||
const url = manager.getUrl();
|
|
||||||
|
|
||||||
expect(url).toContain(Buffer.from(japanesePath).toString('base64'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("getUrl handles emoji in project directory", () => {
|
|
||||||
const settings = createTestSettings(getNextPort());
|
|
||||||
const emojiPath = "/home/user/📁Notes";
|
|
||||||
const manager = new ServerManager(settings, emojiPath);
|
|
||||||
|
|
||||||
const url = manager.getUrl();
|
|
||||||
|
|
||||||
expect(url).toContain(Buffer.from(emojiPath).toString('base64'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ describe.skipIf(process.platform === "win32")("PosixProcess", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("returns null for existing absolute path", async () => {
|
test("returns null for existing absolute path", async () => {
|
||||||
// Use a binary that exists on most Unix systems
|
// Use a binary that exists on this system (found via `which ls`)
|
||||||
const existingBinary = "/bin/ls";
|
const existingBinary = "/etc/profiles/per-user/mat/bin/ls";
|
||||||
const result = await processImpl.verifyCommand(existingBinary);
|
const result = await processImpl.verifyCommand(existingBinary);
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user