From 5b964a12b0d0984971dad7a2fee117cb9cb1aeac Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Tue, 17 Mar 2026 14:29:05 +0100 Subject: [PATCH 1/4] fix: handle Windows backslash paths in encodeProjectPath encodeProjectPath only replaced forward slashes with dashes, but on Windows process.cwd() returns backslash paths (e.g. C:\Users\...). This caused session-context lookups to fail on Windows PowerShell. --- src/helpers/session-context.ts | 2 +- tests/helpers/session-context.test.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/helpers/session-context.ts b/src/helpers/session-context.ts index b373988b..2af57545 100644 --- a/src/helpers/session-context.ts +++ b/src/helpers/session-context.ts @@ -4,7 +4,7 @@ import { homedir } from "node:os"; /** Encode a project root path into the directory name used by Claude/Cursor. */ export function encodeProjectPath(projectRoot: string): string { - return projectRoot.replaceAll("/", "-"); + return projectRoot.replaceAll("\\", "-").replaceAll("/", "-"); } const RELEVANT_TYPES = new Set(["user", "assistant"]); diff --git a/tests/helpers/session-context.test.ts b/tests/helpers/session-context.test.ts index abe58d95..e8fd1889 100644 --- a/tests/helpers/session-context.test.ts +++ b/tests/helpers/session-context.test.ts @@ -21,6 +21,18 @@ describe("encodeProjectPath", () => { test("replaces multiple consecutive slashes", () => { expect(encodeProjectPath("/a//b")).toBe("-a--b"); }); + + test("replaces backslashes with dashes (Windows paths)", () => { + expect(encodeProjectPath("C:\\Users\\user\\project")).toBe( + "C:-Users-user-project" + ); + }); + + test("handles mixed slashes", () => { + expect(encodeProjectPath("C:\\Users/user\\project")).toBe( + "C:-Users-user-project" + ); + }); }); describe("readClaudeCodeSession", () => { From dc2c629d84fb2db4f6394d8e3efd467b24bd22ea Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Tue, 17 Mar 2026 15:11:01 +0100 Subject: [PATCH 2/4] feat: add WSL/cross-environment support and unify platform checks - Add src/helpers/platform.ts with WSL detection, path conversion, cross-environment command resolution, and platform helpers - Unify all process.platform checks to use platform helpers - Make encodeProjectPath async with WSL Windows path conversion - Make getVscodeUserSettingsPath async with WSL AppData resolution - Use resolveCommand for claude/copilot/git/npm CLI detection - Detect install package manager (bun/pnpm/yarn/npm) in upgrade command --- src/cli.ts | 3 +- src/commands/upgrade.ts | 102 ++++++++++- src/helpers/git.ts | 12 +- src/helpers/platform.ts | 236 ++++++++++++++++++++++++++ src/helpers/plugin-install.ts | 27 ++- src/helpers/session-context.ts | 19 ++- src/helpers/vscode-settings.ts | 31 +++- tests/helpers/platform.test.ts | 179 +++++++++++++++++++ tests/helpers/session-context.test.ts | 26 +-- tests/helpers/vscode-settings.test.ts | 21 ++- 10 files changed, 600 insertions(+), 56 deletions(-) create mode 100644 src/helpers/platform.ts create mode 100644 tests/helpers/platform.test.ts diff --git a/src/cli.ts b/src/cli.ts index 5353bcc3..303a7c42 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,7 @@ import { registerSessionContextCommand } from "./commands/session-context/index" import { registerPluginCommand } from "./commands/plugin/index"; import { checkForUpdatesIfNeeded } from "./helpers/update-check"; import { logError } from "./helpers/log"; +import { isSupportedPlatform } from "./helpers/platform"; if (typeof Bun === "undefined") throw new Error( @@ -24,7 +25,7 @@ if (typeof Bun === "undefined") if (!semver.satisfies(Bun.version, ">=1.2.21")) throw new Error("You need to update Bun to version 1.2.21 or higher"); -if (!["darwin", "linux", "win32"].includes(process.platform)) +if (!isSupportedPlatform()) throw new Error("Archgate only supports macOS, Linux, and Windows"); createPathIfNotExists(paths.cacheFolder); diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index c4a6c9ec..11c06399 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -1,13 +1,105 @@ import type { Command } from "@commander-js/extra-typings"; import { semver } from "bun"; import { logError } from "../helpers/log"; +import { resolveCommand } from "../helpers/platform"; const NPM_REGISTRY = "https://registry.npmjs.org/archgate/latest"; +interface PackageManager { + name: string; + globalBinCmd: string[]; + upgradeArgs: string[]; +} + +const PACKAGE_MANAGERS: PackageManager[] = [ + { + name: "bun", + globalBinCmd: ["bun", "pm", "-g", "bin"], + upgradeArgs: ["add", "-g", "archgate@latest"], + }, + { + name: "pnpm", + globalBinCmd: ["pnpm", "bin", "-g"], + upgradeArgs: ["add", "-g", "archgate@latest"], + }, + { + name: "yarn", + globalBinCmd: ["yarn", "global", "bin"], + upgradeArgs: ["global", "add", "archgate@latest"], + }, + { + name: "npm", + globalBinCmd: ["npm", "bin", "-g"], + upgradeArgs: ["install", "-g", "archgate@latest"], + }, +]; + +/** + * Get the global bin directory for a package manager. + * Returns null if the command is not available or fails. + */ +async function getGlobalBinDir(cmd: string[]): Promise { + try { + const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" }); + const stdout = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) return null; + return stdout.trim() || null; + } catch { + return null; + } +} + +/** + * Detect which package manager installed archgate by checking whether + * the running binary lives under each manager's global bin directory. + * Resolves all candidates in parallel. Falls back to npm if none match. + */ +async function detectPackageManager(): Promise<{ + cmd: string; + args: string[]; + manualHint: string; +}> { + const binaryPath = process.execPath; + + // Resolve all package managers in parallel + const candidates = await Promise.all( + PACKAGE_MANAGERS.map(async (pm) => { + const resolved = await resolveCommand(pm.name); + if (!resolved) return null; + const globalBinCmd = [resolved, ...pm.globalBinCmd.slice(1)]; + const binDir = await getGlobalBinDir(globalBinCmd); + return { pm, resolved, binDir }; + }) + ); + + // Find which PM's global bin dir contains the running binary + const match = candidates.find( + (c) => c?.binDir && binaryPath.startsWith(c.binDir) + ); + + if (match) { + return { + cmd: match.resolved, + args: match.pm.upgradeArgs, + manualHint: `${match.pm.name} ${match.pm.upgradeArgs.join(" ")}`, + }; + } + + // Default to npm + const npmCandidate = candidates.find((c) => c?.pm.name === "npm"); + const npm = PACKAGE_MANAGERS.find((pm) => pm.name === "npm")!; + return { + cmd: npmCandidate?.resolved ?? "npm", + args: npm.upgradeArgs, + manualHint: `npm ${npm.upgradeArgs.join(" ")}`, + }; +} + export function registerUpgradeCommand(program: Command) { program .command("upgrade") - .description("Upgrade Archgate to the latest version via npm") + .description("Upgrade Archgate to the latest version") .action(async () => { console.log("Checking for latest Archgate release..."); @@ -58,7 +150,9 @@ export function registerUpgradeCommand(program: Command) { console.log(`Upgrading ${currentVersion} -> ${latestVersion}...`); - const proc = Bun.spawn(["npm", "install", "-g", "archgate@latest"], { + const { cmd, args, manualHint } = await detectPackageManager(); + + const proc = Bun.spawn([cmd, ...args], { stdout: "inherit", stderr: "inherit", }); @@ -66,8 +160,8 @@ export function registerUpgradeCommand(program: Command) { if (exitCode !== 0) { logError( - "Failed to install the latest version via npm.", - "Try running `npm install -g archgate@latest` manually." + "Failed to install the latest version.", + `Try running \`${manualHint}\` manually.` ); process.exit(1); } diff --git a/src/helpers/git.ts b/src/helpers/git.ts index 8b22444e..d6f7048f 100644 --- a/src/helpers/git.ts +++ b/src/helpers/git.ts @@ -1,20 +1,20 @@ import { logDebug } from "./log"; +import { isWindows, isMacOS, resolveCommand } from "./platform"; export async function installGit() { - if (Bun.which("git")) { + if (await resolveCommand("git")) { logDebug("Git is already installed"); return; } console.log("Git is not installed. Installing..."); - if (process.platform === "win32") { + if (isWindows()) { throw new Error( "Git is not installed. Install it from https://git-scm.com/download/win and make sure it is on your PATH." ); } - const cmd = - process.platform === "darwin" - ? ["brew", "install", "git"] - : ["sudo", "apt-get", "install", "-y", "git"]; + const cmd = isMacOS() + ? ["brew", "install", "git"] + : ["sudo", "apt-get", "install", "-y", "git"]; const proc = Bun.spawn(cmd, { stdout: "inherit", stderr: "inherit" }); const exitCode = await proc.exited; if (exitCode !== 0) { diff --git a/src/helpers/platform.ts b/src/helpers/platform.ts new file mode 100644 index 00000000..35f6bb5d --- /dev/null +++ b/src/helpers/platform.ts @@ -0,0 +1,236 @@ +import { readFileSync } from "node:fs"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface PlatformInfo { + /** The Node.js process.platform value ("win32", "linux", "darwin", etc.) */ + runtime: NodeJS.Platform; + /** Whether the process is running inside WSL (1 or 2) */ + isWSL: boolean; + /** The WSL distribution name (e.g. "Ubuntu"), or null if not WSL */ + wslDistro: string | null; +} + +// --------------------------------------------------------------------------- +// Detection (sync, cached) +// --------------------------------------------------------------------------- + +let cachedPlatformInfo: PlatformInfo | null = null; + +/** + * Detect the current platform, including WSL detection. + * Results are cached for the lifetime of the process. + */ +export function getPlatformInfo(): PlatformInfo { + if (cachedPlatformInfo) return cachedPlatformInfo; + + const runtime = process.platform; + + // WSL only applies when process.platform is "linux" + if (runtime !== "linux") { + cachedPlatformInfo = { runtime, isWSL: false, wslDistro: null }; + return cachedPlatformInfo; + } + + // Check WSL2 env vars first (fastest) + const distroName = process.env.WSL_DISTRO_NAME; + if (distroName) { + cachedPlatformInfo = { runtime, isWSL: true, wslDistro: distroName }; + return cachedPlatformInfo; + } + + if (process.env.WSL_INTEROP) { + cachedPlatformInfo = { runtime, isWSL: true, wslDistro: null }; + return cachedPlatformInfo; + } + + // Fallback: /proc/version check (catches WSL1) + try { + const procVersion = readFileSync("/proc/version", "utf-8"); + if (/microsoft/i.test(procVersion)) { + cachedPlatformInfo = { runtime, isWSL: true, wslDistro: null }; + return cachedPlatformInfo; + } + } catch { + // /proc/version not available — not WSL + } + + cachedPlatformInfo = { runtime, isWSL: false, wslDistro: null }; + return cachedPlatformInfo; +} + +/** + * Shorthand: returns true if running inside WSL. + */ +export function isWSL(): boolean { + return getPlatformInfo().isWSL; +} + +/** + * Returns true if the process is running on native Windows (not WSL). + */ +export function isWindows(): boolean { + return getPlatformInfo().runtime === "win32"; +} + +/** + * Returns true if the process is running on macOS. + */ +export function isMacOS(): boolean { + return getPlatformInfo().runtime === "darwin"; +} + +/** + * Returns true if the process is running on Linux (including WSL). + */ +export function isLinux(): boolean { + return getPlatformInfo().runtime === "linux"; +} + +/** + * Returns true if the platform is one of the supported ones (macOS, Linux, Windows). + */ +export function isSupportedPlatform(): boolean { + const { runtime } = getPlatformInfo(); + return runtime === "darwin" || runtime === "linux" || runtime === "win32"; +} + +/** + * Reset the cached platform info. For testing only. + */ +export function _resetPlatformCache(): void { + cachedPlatformInfo = null; +} + +// --------------------------------------------------------------------------- +// Path Conversion (async, WSL only) +// --------------------------------------------------------------------------- + +/** + * Convert a WSL path to a Windows path (e.g. /mnt/c/Users → C:\Users). + * Returns null if not in WSL or conversion fails. + */ +export async function toWindowsPath(wslPath: string): Promise { + if (!isWSL()) return null; + try { + const proc = Bun.spawn(["wslpath", "-w", wslPath], { + stdout: "pipe", + stderr: "pipe", + }); + const stdout = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) return null; + return stdout.trim() || null; + } catch { + return null; + } +} + +/** + * Convert a Windows path to a WSL path (e.g. C:\Users → /mnt/c/Users). + * Returns null if not in WSL or conversion fails. + */ +export async function toWslPath(windowsPath: string): Promise { + if (!isWSL()) return null; + try { + const proc = Bun.spawn(["wslpath", "-u", windowsPath], { + stdout: "pipe", + stderr: "pipe", + }); + const stdout = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) return null; + return stdout.trim() || null; + } catch { + return null; + } +} + +let cachedWindowsHomeDir: string | null | undefined; + +/** Try resolving the Windows home dir using a specific cmd.exe path. */ +async function tryGetWindowsHome(cmd: string): Promise { + try { + const proc = Bun.spawn([cmd, "/c", "echo", "%USERPROFILE%"], { + stdout: "pipe", + stderr: "pipe", + }); + const stdout = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) return null; + + const winHome = stdout.trim(); + if (!winHome || winHome === "%USERPROFILE%") return null; + + return await toWslPath(winHome); + } catch { + return null; + } +} + +/** + * Get the Windows user home directory as a WSL path (e.g. /mnt/c/Users/user). + * Cached per process. Returns null if not in WSL or resolution fails. + */ +export async function getWindowsHomeDirFromWSL(): Promise { + if (!isWSL()) return null; + if (cachedWindowsHomeDir !== undefined) return cachedWindowsHomeDir; + + const result = + (await tryGetWindowsHome("cmd.exe")) ?? + (await tryGetWindowsHome("/mnt/c/Windows/System32/cmd.exe")); + + cachedWindowsHomeDir = result; + return result; +} + +// --------------------------------------------------------------------------- +// Cross-Environment Command Resolution (async) +// --------------------------------------------------------------------------- + +/** + * Resolve a command name across environments. + * + * 1. Try `Bun.which(name)` — native PATH lookup + * 2. If WSL: try `Bun.which(name + ".exe")` — Windows binaries callable from WSL2 + * 3. If win32: try `wsl which name` — check WSL availability + * 4. Return null if all fail + */ +export async function resolveCommand(name: string): Promise { + // 1. Native lookup + if (Bun.which(name)) return name; + + const info = getPlatformInfo(); + + // 2. WSL: try Windows .exe variant + if (info.isWSL) { + const exeName = name + ".exe"; + if (Bun.which(exeName)) return exeName; + } + + // 3. Native Windows: try WSL + if (info.runtime === "win32") { + try { + const proc = Bun.spawn(["wsl", "which", name], { + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await proc.exited; + if (exitCode === 0) return name; + } catch { + // wsl not available + } + } + + return null; +} + +/** + * Reset all caches (platform + Windows home dir). For testing only. + */ +export function _resetAllCaches(): void { + cachedPlatformInfo = null; + cachedWindowsHomeDir = undefined; +} diff --git a/src/helpers/plugin-install.ts b/src/helpers/plugin-install.ts index 035b9a2c..24389adb 100644 --- a/src/helpers/plugin-install.ts +++ b/src/helpers/plugin-install.ts @@ -11,6 +11,7 @@ import { join } from "node:path"; import { mkdirSync, unlinkSync } from "node:fs"; import { logDebug } from "./log"; import type { StoredCredentials } from "./auth"; +import { resolveCommand } from "./platform"; const PLUGINS_API = "https://plugins.archgate.dev"; @@ -56,14 +57,11 @@ export function buildVscodeMarketplaceUrl( /** * Check whether the `claude` CLI is available on the system PATH. + * On WSL, also checks for `claude.exe` (Windows-side installation). */ export async function isClaudeCliAvailable(): Promise { - try { - const { exitCode } = await run(["claude", "--version"]); - return exitCode === 0; - } catch { - return false; - } + const resolved = await resolveCommand("claude"); + return resolved !== null; } /** @@ -79,9 +77,10 @@ export async function installClaudePlugin( credentials: StoredCredentials ): Promise { const url = buildMarketplaceUrl(credentials); + const cmd = (await resolveCommand("claude")) ?? "claude"; logDebug("Adding archgate marketplace to claude CLI"); - const addResult = await run(["claude", "plugin", "marketplace", "add", url]); + const addResult = await run([cmd, "plugin", "marketplace", "add", url]); if (addResult.exitCode !== 0) { throw new Error( `claude plugin marketplace add failed (exit ${addResult.exitCode})` @@ -90,7 +89,7 @@ export async function installClaudePlugin( logDebug("Installing archgate plugin via claude CLI"); const installResult = await run([ - "claude", + cmd, "plugin", "install", "archgate@archgate", @@ -154,14 +153,11 @@ export async function installCursorPlugin( /** * Check whether the `copilot` CLI is available on the system PATH. + * On WSL, also checks for `copilot.exe` (Windows-side installation). */ export async function isCopilotCliAvailable(): Promise { - try { - const { exitCode } = await run(["copilot", "--version"]); - return exitCode === 0; - } catch { - return false; - } + const resolved = await resolveCommand("copilot"); + return resolved !== null; } /** @@ -176,9 +172,10 @@ export async function installCopilotPlugin( credentials: StoredCredentials ): Promise { const url = buildMarketplaceUrl(credentials); + const cmd = (await resolveCommand("copilot")) ?? "copilot"; logDebug("Installing archgate plugin via copilot CLI"); - const installResult = await run(["copilot", "plugin", "install", url]); + const installResult = await run([cmd, "plugin", "install", url]); if (installResult.exitCode !== 0) { throw new Error( `copilot plugin install failed (exit ${installResult.exitCode})` diff --git a/src/helpers/session-context.ts b/src/helpers/session-context.ts index 2af57545..f0dbfc2a 100644 --- a/src/helpers/session-context.ts +++ b/src/helpers/session-context.ts @@ -1,9 +1,20 @@ import { readdirSync, statSync } from "node:fs"; import { join, basename } from "node:path"; import { homedir } from "node:os"; +import { isWSL, toWindowsPath } from "./platform"; -/** Encode a project root path into the directory name used by Claude/Cursor. */ -export function encodeProjectPath(projectRoot: string): string { +/** + * Encode a project root path into the directory name used by Claude/Cursor. + * In WSL, converts to Windows path first so the encoded name matches + * what Windows-side Claude/Cursor uses. + */ +export async function encodeProjectPath(projectRoot: string): Promise { + if (isWSL()) { + const winPath = await toWindowsPath(projectRoot); + if (winPath) { + return winPath.replaceAll("\\", "-").replaceAll("/", "-"); + } + } return projectRoot.replaceAll("\\", "-").replaceAll("/", "-"); } @@ -84,7 +95,7 @@ export async function readClaudeCodeSession( options?: ReadSessionOptions ): Promise { const limit = options?.maxEntries ?? 200; - const encodedPath = encodeProjectPath(projectRoot ?? process.cwd()); + const encodedPath = await encodeProjectPath(projectRoot ?? process.cwd()); const projectsDir = join(homedir(), ".claude", "projects", encodedPath); let files: string[]; @@ -150,7 +161,7 @@ export async function readCursorSession( options?: ReadCursorSessionOptions ): Promise { const limit = options?.maxEntries ?? 200; - const encodedPath = encodeProjectPath(projectRoot ?? process.cwd()); + const encodedPath = await encodeProjectPath(projectRoot ?? process.cwd()); const transcriptsDir = join( homedir(), ".cursor", diff --git a/src/helpers/vscode-settings.ts b/src/helpers/vscode-settings.ts index 668f778e..74a52757 100644 --- a/src/helpers/vscode-settings.ts +++ b/src/helpers/vscode-settings.ts @@ -1,6 +1,12 @@ import { join } from "node:path"; import { existsSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; +import { + isWSL, + isWindows, + isMacOS, + getWindowsHomeDirFromWSL, +} from "./platform"; type VscodeUserSettings = Record; @@ -60,15 +66,15 @@ export function mergeMarketplaceUrl( * - Windows: %APPDATA%/Code/User/settings.json * - macOS: ~/Library/Application Support/Code/User/settings.json * - Linux: ~/.config/Code/User/settings.json + * - WSL: Windows-side AppData path (VS Code runs on Windows) */ -export function getVscodeUserSettingsPath(): string { - const platform = process.platform; - if (platform === "win32") { +export async function getVscodeUserSettingsPath(): Promise { + if (isWindows()) { const appData = process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"); return join(appData, "Code", "User", "settings.json"); } - if (platform === "darwin") { + if (isMacOS()) { return join( homedir(), "Library", @@ -78,6 +84,21 @@ export function getVscodeUserSettingsPath(): string { "settings.json" ); } + // WSL: VS Code runs on the Windows side, so resolve Windows AppData path + if (isWSL()) { + const winHome = await getWindowsHomeDirFromWSL(); + if (winHome) { + return join( + winHome, + "AppData", + "Roaming", + "Code", + "User", + "settings.json" + ); + } + // Fall through to Linux path if Windows home not resolvable + } // Linux and others return join(homedir(), ".config", "Code", "User", "settings.json"); } @@ -119,7 +140,7 @@ export async function configureVscodeSettings( export async function addMarketplaceToUserSettings( marketplaceUrl: string ): Promise { - const settingsPath = getVscodeUserSettingsPath(); + const settingsPath = await getVscodeUserSettingsPath(); const settingsDir = join(settingsPath, ".."); let existing: VscodeUserSettings = {}; diff --git a/tests/helpers/platform.test.ts b/tests/helpers/platform.test.ts new file mode 100644 index 00000000..2d4a8af3 --- /dev/null +++ b/tests/helpers/platform.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { + getPlatformInfo, + isWSL, + isWindows, + isMacOS, + isLinux, + isSupportedPlatform, + resolveCommand, + toWindowsPath, + toWslPath, + getWindowsHomeDirFromWSL, + _resetAllCaches, +} from "../../src/helpers/platform"; + +describe("getPlatformInfo", () => { + let savedEnv: Record; + + beforeEach(() => { + savedEnv = { + WSL_DISTRO_NAME: process.env.WSL_DISTRO_NAME, + WSL_INTEROP: process.env.WSL_INTEROP, + }; + _resetAllCaches(); + }); + + afterEach(() => { + process.env.WSL_DISTRO_NAME = savedEnv.WSL_DISTRO_NAME; + process.env.WSL_INTEROP = savedEnv.WSL_INTEROP; + _resetAllCaches(); + }); + + test("returns a PlatformInfo object", () => { + const info = getPlatformInfo(); + expect(info).toHaveProperty("runtime"); + expect(info).toHaveProperty("isWSL"); + expect(info).toHaveProperty("wslDistro"); + }); + + test("runtime matches process.platform", () => { + const info = getPlatformInfo(); + expect(info.runtime).toBe(process.platform); + }); + + test("caches result across calls", () => { + const first = getPlatformInfo(); + const second = getPlatformInfo(); + expect(first).toBe(second); // same reference + }); + + test("cache is cleared by _resetAllCaches", () => { + const first = getPlatformInfo(); + _resetAllCaches(); + const second = getPlatformInfo(); + // Different reference after cache reset (may have same values) + expect(first).not.toBe(second); + }); + + test("isWSL is false on win32 and darwin", () => { + if (process.platform === "win32" || process.platform === "darwin") { + expect(getPlatformInfo().isWSL).toBe(false); + } + }); +}); + +describe("isWSL", () => { + beforeEach(() => _resetAllCaches()); + afterEach(() => _resetAllCaches()); + + test("returns a boolean", () => { + expect(typeof isWSL()).toBe("boolean"); + }); +}); + +describe("platform shorthand helpers", () => { + beforeEach(() => _resetAllCaches()); + afterEach(() => _resetAllCaches()); + + test("isWindows matches process.platform", () => { + expect(isWindows()).toBe(process.platform === "win32"); + }); + + test("isMacOS matches process.platform", () => { + expect(isMacOS()).toBe(process.platform === "darwin"); + }); + + test("isLinux matches process.platform", () => { + expect(isLinux()).toBe(process.platform === "linux"); + }); + + test("isSupportedPlatform returns true on supported platforms", () => { + expect(isSupportedPlatform()).toBe( + ["darwin", "linux", "win32"].includes(process.platform) + ); + }); + + test("exactly one of isWindows/isMacOS/isLinux is true", () => { + const checks = [isWindows(), isMacOS(), isLinux()]; + expect(checks.filter(Boolean).length).toBe(1); + }); +}); + +describe("resolveCommand", () => { + test("finds bun on PATH", async () => { + const result = await resolveCommand("bun"); + expect(result).toBe("bun"); + }); + + test("returns null for non-existent command", async () => { + const result = await resolveCommand("definitely-not-a-real-command-xyz123"); + expect(result).toBeNull(); + }); +}); + +// WSL-only tests: path conversion and Windows home directory +const inWSL = !!process.env.WSL_DISTRO_NAME; + +describe("toWindowsPath", () => { + beforeEach(() => _resetAllCaches()); + afterEach(() => _resetAllCaches()); + + test.skipIf(!inWSL)("converts /mnt/c to C:\\", async () => { + const result = await toWindowsPath("/mnt/c"); + expect(result).toBe("C:\\"); + }); + + test.skipIf(!inWSL)("converts WSL home path", async () => { + const result = await toWindowsPath("/mnt/c/Users"); + expect(result).toMatch(/^C:\\Users$/); + }); + + test("returns null when not in WSL", async () => { + if (!inWSL) { + const result = await toWindowsPath("/some/path"); + expect(result).toBeNull(); + } + }); +}); + +describe("toWslPath", () => { + beforeEach(() => _resetAllCaches()); + afterEach(() => _resetAllCaches()); + + test.skipIf(!inWSL)("converts C:\\ to /mnt/c", async () => { + const result = await toWslPath("C:\\"); + expect(result).toBe("/mnt/c/"); + }); + + test("returns null when not in WSL", async () => { + if (!inWSL) { + const result = await toWslPath("C:\\Users"); + expect(result).toBeNull(); + } + }); +}); + +describe("getWindowsHomeDirFromWSL", () => { + beforeEach(() => _resetAllCaches()); + afterEach(() => _resetAllCaches()); + + test.skipIf(!inWSL)("returns a path under /mnt/", async () => { + const result = await getWindowsHomeDirFromWSL(); + expect(result).not.toBeNull(); + expect(result!).toMatch(/^\/mnt\//); + }); + + test.skipIf(!inWSL)("caches the result", async () => { + const first = await getWindowsHomeDirFromWSL(); + const second = await getWindowsHomeDirFromWSL(); + expect(first).toBe(second); + }); + + test("returns null when not in WSL", async () => { + if (!inWSL) { + const result = await getWindowsHomeDirFromWSL(); + expect(result).toBeNull(); + } + }); +}); diff --git a/tests/helpers/session-context.test.ts b/tests/helpers/session-context.test.ts index e8fd1889..c212af26 100644 --- a/tests/helpers/session-context.test.ts +++ b/tests/helpers/session-context.test.ts @@ -6,30 +6,32 @@ import { } from "../../src/helpers/session-context"; describe("encodeProjectPath", () => { - test("replaces forward slashes with dashes", () => { - expect(encodeProjectPath("/home/user/project")).toBe("-home-user-project"); + test("replaces forward slashes with dashes", async () => { + expect(await encodeProjectPath("/home/user/project")).toBe( + "-home-user-project" + ); }); - test("handles paths without slashes", () => { - expect(encodeProjectPath("project")).toBe("project"); + test("handles paths without slashes", async () => { + expect(await encodeProjectPath("project")).toBe("project"); }); - test("handles empty string", () => { - expect(encodeProjectPath("")).toBe(""); + test("handles empty string", async () => { + expect(await encodeProjectPath("")).toBe(""); }); - test("replaces multiple consecutive slashes", () => { - expect(encodeProjectPath("/a//b")).toBe("-a--b"); + test("replaces multiple consecutive slashes", async () => { + expect(await encodeProjectPath("/a//b")).toBe("-a--b"); }); - test("replaces backslashes with dashes (Windows paths)", () => { - expect(encodeProjectPath("C:\\Users\\user\\project")).toBe( + test("replaces backslashes with dashes (Windows paths)", async () => { + expect(await encodeProjectPath("C:\\Users\\user\\project")).toBe( "C:-Users-user-project" ); }); - test("handles mixed slashes", () => { - expect(encodeProjectPath("C:\\Users/user\\project")).toBe( + test("handles mixed slashes", async () => { + expect(await encodeProjectPath("C:\\Users/user\\project")).toBe( "C:-Users-user-project" ); }); diff --git a/tests/helpers/vscode-settings.test.ts b/tests/helpers/vscode-settings.test.ts index f34e66b7..0bd6b714 100644 --- a/tests/helpers/vscode-settings.test.ts +++ b/tests/helpers/vscode-settings.test.ts @@ -103,14 +103,15 @@ describe("addMarketplaceToUserSettings", () => { const URL = "https://user:token@plugins.archgate.dev/archgate.git"; /** Use the real path resolver so the test matches addMarketplaceToUserSettings */ - function settingsPath() { - return getVscodeUserSettingsPath(); + async function settingsPath() { + return await getVscodeUserSettingsPath(); } test("creates settings file with defaults when none exists", async () => { await addMarketplaceToUserSettings(URL); - const content = JSON.parse(await Bun.file(settingsPath()).text()); + const path = await settingsPath(); + const content = JSON.parse(await Bun.file(path).text()); expect(content["chat.plugins.marketplaces"]).toEqual([ "github/copilot-plugins", "github/awesome-copilot", @@ -119,15 +120,16 @@ describe("addMarketplaceToUserSettings", () => { }); test("merges JSONC settings and includes defaults when key absent", async () => { - mkdirSync(join(settingsPath(), ".."), { recursive: true }); + const path = await settingsPath(); + mkdirSync(join(path, ".."), { recursive: true }); await Bun.write( - settingsPath(), + path, `{ "git.autofetch": true, "chat.mcp.gallery.enabled": true, }` ); await addMarketplaceToUserSettings(URL); - const content = JSON.parse(await Bun.file(settingsPath()).text()); + const content = JSON.parse(await Bun.file(path).text()); expect(content["git.autofetch"]).toBe(true); expect(content["chat.plugins.marketplaces"]).toEqual([ "github/copilot-plugins", @@ -137,9 +139,10 @@ describe("addMarketplaceToUserSettings", () => { }); test("deduplicates when key already exists", async () => { - mkdirSync(join(settingsPath(), ".."), { recursive: true }); + const path = await settingsPath(); + mkdirSync(join(path, ".."), { recursive: true }); await Bun.write( - settingsPath(), + path, JSON.stringify({ "chat.plugins.marketplaces": ["https://other.git", URL], }) @@ -147,7 +150,7 @@ describe("addMarketplaceToUserSettings", () => { await addMarketplaceToUserSettings(URL); - const content = JSON.parse(await Bun.file(settingsPath()).text()); + const content = JSON.parse(await Bun.file(path).text()); expect(content["chat.plugins.marketplaces"]).toEqual([ "https://other.git", URL, From e6d3d55dc25b5001956576204b92db476b299676 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Tue, 17 Mar 2026 16:14:22 +0100 Subject: [PATCH 3/4] fix: update bun.lock to match package.json Co-Authored-By: Claude Opus 4.6 --- bun.lock | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/bun.lock b/bun.lock index 4ad0a9cd..78a8a584 100644 --- a/bun.lock +++ b/bun.lock @@ -19,9 +19,9 @@ "zod": "4.3.6", }, "optionalDependencies": { - "archgate-darwin-arm64": "0.11.0", - "archgate-linux-x64": "0.11.0", - "archgate-win32-x64": "0.11.0", + "archgate-darwin-arm64": "0.11.1", + "archgate-linux-x64": "0.11.1", + "archgate-win32-x64": "0.11.1", }, "peerDependencies": { "typescript": "^5", @@ -147,12 +147,6 @@ "ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], - "archgate-darwin-arm64": ["archgate-darwin-arm64@0.11.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fKkDfgPfiucngfRbP1p1nfTT08aKHBVX4L9JqfFPotaYorS8uLUThcQY33pJ1mESgFAXcqC5rLCmIYxTgPl0gA=="], - - "archgate-linux-x64": ["archgate-linux-x64@0.11.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JRvbWC4LySfj5EhcQFA7LlhDOpsvv7qU3GOuxQURwOm8+brjmI9Rk0BRrhowKDZcqk2QswC6xkvwZTJ53eGdvQ=="], - - "archgate-win32-x64": ["archgate-win32-x64@0.11.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dQ3t74V8BRJVFHERwymHKZBp/2FkxKgMxkhs/T/r+G5wmnbZmFml6ojX+LI47Ohp97NeTfgf1CRjeyrlQrSYFA=="], - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "array-ify": ["array-ify@1.0.0", "", {}, "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng=="], From 843eeeab76fb52c1dd0c2d3875c77e4cd0c6b403 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Tue, 17 Mar 2026 16:19:07 +0100 Subject: [PATCH 4/4] fix: reject lint warnings with --deny-warnings flag Adds --deny-warnings to the oxlint command so warnings are treated as errors. Suppresses the legitimate no-await-in-loop in the OAuth polling loop. Also adds .claude/worktrees to .gitignore. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 +++ package.json | 2 +- src/helpers/auth.ts | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 07aa9d0c..c6258bd5 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ coverage # Local MCP config .mcp.json +# Claude Code worktrees +.claude/worktrees + # Proto / Moon .moon/cache .moon/docker diff --git a/package.json b/package.json index 8dd2002f..a6fcb385 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "scripts": { "cli": "bun run src/cli.ts", "check": "bun run src/cli.ts check", - "lint": "oxlint .", + "lint": "oxlint --deny-warnings .", "typecheck": "tsc --build", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/src/helpers/auth.ts b/src/helpers/auth.ts index 041a3849..fbc82235 100644 --- a/src/helpers/auth.ts +++ b/src/helpers/auth.ts @@ -106,7 +106,7 @@ export async function pollForAccessToken( const deadline = Date.now() + expiresIn * 1000; let pollInterval = interval; - // eslint-disable-next-line no-await-in-loop -- sequential polling is required by RFC 8628 + /* oxlint-disable no-await-in-loop -- sequential polling is required by RFC 8628 */ while (Date.now() < deadline) { await Bun.sleep(pollInterval * 1000); @@ -148,6 +148,7 @@ export async function pollForAccessToken( ); } } + /* oxlint-enable no-await-in-loop */ throw new Error("Device code expired. Please try again."); }