diff --git a/electron/main.ts b/electron/main.ts index b1c8bdb..fd19428 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -21,6 +21,13 @@ import { uninstallHydraSkillLinks, } from "./hydra-skill"; import { buildLaunchSpec } from "./pty-launch.js"; +import { + addWindowsPathEntry, + hasWindowsPathEntry, + readWindowsUserPath, + removeWindowsPathEntry, + writeWindowsUserPath, +} from "./windows-cli-path"; import { createDefaultComposerSubmitDeps, submitComposerRequest, @@ -850,6 +857,14 @@ function getPathExportLine(): string { } function isCliRegistered(): boolean { + if (process.platform === "win32") { + try { + return hasWindowsPathEntry(readWindowsUserPath(), getCliDir()); + } catch { + return false; + } + } + if (process.platform === "darwin") { try { const content = fs.readFileSync(ZPROFILE_PATH, "utf-8"); @@ -885,6 +900,16 @@ function registerCli(): boolean { let ok = false; + if (process.platform === "win32") { + try { + const nextPath = addWindowsPathEntry(readWindowsUserPath(), cliDir); + writeWindowsUserPath(nextPath); + ok = true; + } catch { + return false; + } + } + if (process.platform === "darwin") { const line = getPathExportLine(); try { @@ -944,6 +969,16 @@ function unregisterCli(): boolean { // Auto-uninstall hydra skill alongside CLI uninstallSkill(); + if (process.platform === "win32") { + try { + const nextPath = removeWindowsPathEntry(readWindowsUserPath(), getCliDir()); + writeWindowsUserPath(nextPath); + return true; + } catch { + return false; + } + } + if (process.platform === "darwin") { const line = getPathExportLine(); try { diff --git a/electron/windows-cli-path.ts b/electron/windows-cli-path.ts new file mode 100644 index 0000000..6d7a90b --- /dev/null +++ b/electron/windows-cli-path.ts @@ -0,0 +1,63 @@ +import { execFileSync } from "node:child_process"; + +function escapePowerShellLiteral(value: string): string { + return value.replaceAll("'", "''"); +} + +function normalizeWindowsPathEntry(entry: string): string { + return entry + .trim() + .replaceAll("/", "\\") + .replace(/[\\]+$/, "") + .toLowerCase(); +} + +export function splitWindowsPathEntries(pathValue: string): string[] { + return pathValue + .split(";") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +export function hasWindowsPathEntry(pathValue: string, entry: string): boolean { + const target = normalizeWindowsPathEntry(entry); + return splitWindowsPathEntries(pathValue).some( + (candidate) => normalizeWindowsPathEntry(candidate) === target, + ); +} + +export function addWindowsPathEntry(pathValue: string, entry: string): string { + if (hasWindowsPathEntry(pathValue, entry)) return pathValue; + return pathValue.trim().length > 0 ? `${pathValue};${entry}` : entry; +} + +export function removeWindowsPathEntry(pathValue: string, entry: string): string { + const target = normalizeWindowsPathEntry(entry); + return splitWindowsPathEntries(pathValue) + .filter((candidate) => normalizeWindowsPathEntry(candidate) !== target) + .join(";"); +} + +export function readWindowsUserPath(): string { + return execFileSync( + "powershell.exe", + [ + "-NoProfile", + "-Command", + "[Environment]::GetEnvironmentVariable('Path', 'User')", + ], + { encoding: "utf-8" }, + ).trim(); +} + +export function writeWindowsUserPath(pathValue: string): void { + execFileSync( + "powershell.exe", + [ + "-NoProfile", + "-Command", + `[Environment]::SetEnvironmentVariable('Path', '${escapePowerShellLiteral(pathValue)}', 'User')`, + ], + { stdio: "pipe" }, + ); +} diff --git a/tests/windows-cli-path.test.ts b/tests/windows-cli-path.test.ts new file mode 100644 index 0000000..f3e3c3d --- /dev/null +++ b/tests/windows-cli-path.test.ts @@ -0,0 +1,42 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + addWindowsPathEntry, + hasWindowsPathEntry, + removeWindowsPathEntry, +} from "../electron/windows-cli-path.ts"; + +test("hasWindowsPathEntry ignores case and slash style", () => { + assert.equal( + hasWindowsPathEntry( + "C:\\Windows\\System32;C:\\Users\\Foo\\AppData\\Local\\termcanvas\\bin", + "c:/users/foo/appdata/local/termcanvas/bin/", + ), + true, + ); +}); + +test("addWindowsPathEntry appends missing entries once", () => { + assert.equal( + addWindowsPathEntry("C:\\Windows\\System32", "C:\\termcanvas\\bin"), + "C:\\Windows\\System32;C:\\termcanvas\\bin", + ); + assert.equal( + addWindowsPathEntry( + "C:\\Windows\\System32;C:\\termcanvas\\bin", + "c:/termcanvas/bin/", + ), + "C:\\Windows\\System32;C:\\termcanvas\\bin", + ); +}); + +test("removeWindowsPathEntry removes only the target entry", () => { + assert.equal( + removeWindowsPathEntry( + "C:\\Windows\\System32;C:\\termcanvas\\bin;C:\\Other", + "c:/termcanvas/bin", + ), + "C:\\Windows\\System32;C:\\Other", + ); +});