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: |
|
||||
${{ 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
|
||||
run: bun install
|
||||
|
||||
- name: Install OpenCode CLI
|
||||
if: steps.cache-opencode.outputs.cache-hit != 'true'
|
||||
run: bun install -g opencode-ai
|
||||
run: |
|
||||
bun install -g opencode-ai
|
||||
echo "$HOME/.bun/bin" >> $GITHUB_PATH
|
||||
shell: bash
|
||||
|
||||
- name: Verify OpenCode installation
|
||||
run: opencode --version
|
||||
run: |
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
opencode --version
|
||||
shell: bash
|
||||
|
||||
- name: Type check
|
||||
run: bun run tsc -noEmit -skipLibCheck
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "opencode-obsidian",
|
||||
"name": "OpenCode-Obsidian",
|
||||
"version": "0.0.0",
|
||||
"version": "0.2.1",
|
||||
"minAppVersion": "1.4.0",
|
||||
"description": "Embed OpenCode AI assistant in Obsidian for AI-powered note management",
|
||||
"author": "mtymek",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-opencode",
|
||||
"version": "0.0.0",
|
||||
"version": "0.2.1",
|
||||
"description": "Embed OpenCode AI assistant in Obsidian",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -44,7 +44,7 @@ export class ServerManager extends EventEmitter {
|
||||
}
|
||||
|
||||
getUrl(): string {
|
||||
const encodedPath = btoa(this.projectDirectory);
|
||||
const encodedPath = Buffer.from(this.projectDirectory).toString('base64');
|
||||
return `http://${this.settings.hostname}:${this.settings.port}/${encodedPath}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,33 +2,130 @@ import { ChildProcess, spawn, SpawnOptions } from "child_process";
|
||||
import { OpenCodeProcess } from "./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(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: SpawnOptions
|
||||
): ChildProcess {
|
||||
return spawn(command, args, {
|
||||
const process = spawn(command, args, {
|
||||
...options,
|
||||
shell: true,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
// Store process for cleanup
|
||||
WindowsProcess.currentProcess = process;
|
||||
WindowsProcess.registerCleanupHandler();
|
||||
|
||||
return process;
|
||||
}
|
||||
|
||||
async stop(process: ChildProcess): Promise<void> {
|
||||
const pid = process.pid;
|
||||
if (!pid) {
|
||||
WindowsProcess.currentProcess = null;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[OpenCode] Stopping server process tree, PID:", pid);
|
||||
|
||||
// Use taskkill with /T flag to kill process tree
|
||||
await this.execAsync(`taskkill /T /F /PID ${pid}`);
|
||||
// Method 1: Find and kill child processes (actual node.exe) using PowerShell
|
||||
// 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
|
||||
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> {
|
||||
// Use 'where' command to check if executable exists in PATH
|
||||
try {
|
||||
|
||||
@@ -21,7 +21,7 @@ export const DEFAULT_SETTINGS: OpenCodeSettings = {
|
||||
autoStart: false,
|
||||
opencodePath: "opencode",
|
||||
projectDirectory: "",
|
||||
startupTimeout: 15000,
|
||||
startupTimeout: 45000,
|
||||
defaultViewLocation: "sidebar",
|
||||
injectWorkspaceContext: false,
|
||||
maxNotesInContext: 20,
|
||||
|
||||
@@ -89,7 +89,7 @@ describe("ServerManager", () => {
|
||||
|
||||
const url = currentManager.getUrl();
|
||||
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}`);
|
||||
});
|
||||
@@ -285,4 +285,37 @@ describe("ServerManager", () => {
|
||||
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 () => {
|
||||
// Use a binary that exists on this system (found via `which ls`)
|
||||
const existingBinary = "/etc/profiles/per-user/mat/bin/ls";
|
||||
// Use a binary that exists on most Unix systems
|
||||
const existingBinary = "/bin/ls";
|
||||
const result = await processImpl.verifyCommand(existingBinary);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user