Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d56e31a25a | ||
|
|
92af2dd7e8 | ||
|
|
e20075db3f | ||
|
|
159e7ad7ac | ||
|
|
aaa71df9b2 | ||
|
|
a7fa8c76a4 | ||
|
|
1ee91fcc2f | ||
|
|
427f0d5132 | ||
|
|
2a824b6d19 |
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
@@ -28,24 +28,20 @@ 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
|
||||||
if: steps.cache-opencode.outputs.cache-hit != 'true'
|
run: |
|
||||||
run: bun install -g opencode-ai
|
bun install -g opencode-ai
|
||||||
|
echo "$HOME/.bun/bin" >> $GITHUB_PATH
|
||||||
|
shell: bash
|
||||||
|
|
||||||
- name: Verify OpenCode installation
|
- name: Verify OpenCode installation
|
||||||
run: opencode --version
|
run: |
|
||||||
|
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.0.0",
|
"version": "0.2.1",
|
||||||
"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.0.0",
|
"version": "0.2.1",
|
||||||
"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 = btoa(this.projectDirectory);
|
const encodedPath = Buffer.from(this.projectDirectory).toString('base64');
|
||||||
return `http://${this.settings.hostname}:${this.settings.port}/${encodedPath}`;
|
return `http://${this.settings.hostname}:${this.settings.port}/${encodedPath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,33 +2,130 @@ 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 {
|
||||||
return spawn(command, args, {
|
const process = 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);
|
||||||
|
|
||||||
// Use taskkill with /T flag to kill process tree
|
// Method 1: Find and kill child processes (actual node.exe) using PowerShell
|
||||||
await this.execAsync(`taskkill /T /F /PID ${pid}`);
|
// This is necessary because shell: true spawns cmd.exe -> node.exe, and
|
||||||
|
// 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: 15000,
|
startupTimeout: 45000,
|
||||||
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 = btoa(PROJECT_DIR);
|
const expectedPath = Buffer.from(PROJECT_DIR).toString('base64');
|
||||||
|
|
||||||
expect(url).toBe(`${expectedBase}/${expectedPath}`);
|
expect(url).toBe(`${expectedBase}/${expectedPath}`);
|
||||||
});
|
});
|
||||||
@@ -285,4 +285,37 @@ 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 this system (found via `which ls`)
|
// Use a binary that exists on most Unix systems
|
||||||
const existingBinary = "/etc/profiles/per-user/mat/bin/ls";
|
const existingBinary = "/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