* fix: Windows compatibility across postinstall, desktop, plugins, and scripts * fix: clean script gracefully skips locked files on Windows * test(file-secrets): add vitest coverage for xdgDataHome Exports `xdgDataHome` and adds 9 parametrized vitest cases covering: - XDG_DATA_HOME override (all platforms, with whitespace trimming) - posix $HOME/.local/share fallback + default when HOME is unset - win32 LOCALAPPDATA → APPDATA → USERPROFILE\AppData\Local chain Replaces the ad-hoc scripts/_test-windows.mjs verification script, which was never wired into CI and re-implemented the logic inline instead of testing the real code. * fix(desktop): guard registry PATH update against parse failures If `reg query HKCU\Environment /v Path` exits non-zero for any reason other than 'value not present' (exit 1), or if the output doesn't match the expected `Path REG(_EXPAND)?_SZ <value>` shape, bail out instead of writing. Previously a parse failure would silently treat current PATH as empty and overwrite it with only the executor bin dir. ---------
Unified · Split
| @@ -564,7 +564,7 @@ const platform = platformMap[os.platform()] || os.platform(); · 563 unmodified lines | ||
| 564 | 564 | const arch = os.arch() === "arm64" ? "arm64" : "x64"; |
| 565 | 565 | const binary = platform === "windows" ? "executor.exe" : "executor"; |
| 566 | 566 | const cachedBinary = path.join(runtimeDir, binary); |
| 567 | −const legacyCachedBinary = path.join(binDir, platform === "win32" ? ".executor.exe" : ".executor"); | |
| 567 | +const legacyCachedBinary = path.join(binDir, platform === "windows" ? ".executor.exe" : ".executor"); | |
| 568 | 568 | |
| 569 | 569 | const isMusl = (() => { |
| 570 | 570 | if (platform !== "linux") return false; |
| @@ -617,11 +617,16 @@ const extract = () => { · 46 unmodified lines | ||
| 617 | 617 | return; |
| 618 | 618 | } |
| 619 | 619 | |
| 620 | − const command = [ | |
| 620 | + const psCommand = [ | |
| 621 | 621 | "$ErrorActionPreference = 'Stop'", |
| 622 | 622 | "Expand-Archive -LiteralPath '" + archivePath.replace(/'/g, "''") + "' -DestinationPath '" + runtimeDir.replace(/'/g, "''") + "' -Force", |
| 623 | 623 | ].join("; "); |
| 624 | − run("powershell.exe", ["-NoLogo", "-NoProfile", "-Command", command]); | |
| 624 | + const psArgs = ["-NoLogo", "-NoProfile", "-Command", psCommand]; | |
| 625 | + // Prefer pwsh (PowerShell 7+) which reliably has Expand-Archive; fall back to powershell.exe | |
| 626 | + const pwshResult = childProcess.spawnSync("pwsh", psArgs, { stdio: "inherit" }); | |
| 627 | + if (pwshResult.error || (typeof pwshResult.status === "number" && pwshResult.status !== 0)) { | |
| 628 | + run("powershell.exe", psArgs); | |
| 629 | + } | |
| 625 | 630 | }; |
| 626 | 631 | |
| 627 | 632 | (async () => { |
| @@ -636,7 +641,7 @@ const extract = () => { · 8 unmodified lines | ||
| 636 | 641 | if (fs.existsSync(legacyCachedBinary)) { |
| 637 | 642 | fs.unlinkSync(legacyCachedBinary); |
| 638 | 643 | } |
| 639 | − fs.chmodSync(cachedBinary, 0o755); | |
| 644 | + if (platform !== "windows") fs.chmodSync(cachedBinary, 0o755); | |
| 640 | 645 | fs.rmSync(archivePath, { force: true }); |
| 641 | 646 | console.log("executor: installed " + assetBase + " from GitHub Releases"); |
| 642 | 647 | } catch (error) { |
| @@ -11,7 +11,7 @@ · 10 unmodified lines | ||
| 11 | 11 | "db:generate": "drizzle-kit generate", |
| 12 | 12 | "build": "vite build", |
| 13 | 13 | "preview": "vite preview", |
| 14 | − "deploy": "op run --env-file=.env.production -- sh -c 'vite build && wrangler deploy'", | |
| 14 | + "deploy": "op run --env-file=.env.production -- bun run build && op run --env-file=.env.production -- wrangler deploy", | |
| 15 | 15 | "cf-typegen": "wrangler types", |
| 16 | 16 | "typecheck": "tsgo --noEmit", |
| 17 | 17 | "test": "vitest run", |
| @@ -97,7 +97,44 @@ const installCli = (): void => { · 96 unmodified lines | ||
| 97 | 97 | } |
| 98 | 98 | |
| 99 | 99 | // Patch shell profiles with PATH |
| 100 | − if (process.platform === "win32") return; | |
| 100 | + if (process.platform === "win32") { | |
| 101 | + // Add bin dir to the user PATH via registry so new terminals pick it up | |
| 102 | + const result = spawn( | |
| 103 | + "reg", | |
| 104 | + ["query", "HKCU\\Environment", "/v", "Path"], | |
| 105 | + { stdio: "pipe" }, | |
| 106 | + ); | |
| 107 | + let out = ""; | |
| 108 | + result.stdout?.on("data", (d: Buffer) => (out += d.toString())); | |
| 109 | + result.on("close", (code) => { | |
| 110 | + // Exit codes: 0 = value exists, 1 = value missing (treat as empty). | |
| 111 | + // Anything else (2 = access denied, etc.) is an unexpected failure — | |
| 112 | + // bail rather than risk writing a malformed PATH. | |
| 113 | + if (code !== 0 && code !== 1) { | |
| 114 | + console.warn(`executor: reg query failed (code ${code}), skipping PATH update`); | |
| 115 | + return; | |
| 116 | + } | |
| 117 | + let current = ""; | |
| 118 | + if (code === 0) { | |
| 119 | + const match = out.match(/Path\s+REG(?:_EXPAND)?_SZ\s+(.+)/i); | |
| 120 | + if (!match) { | |
| 121 | + // reg query succeeded but the output didn't parse — this means the | |
| 122 | + // format changed and we can't safely rewrite PATH. Bail. | |
| 123 | + console.warn("executor: could not parse reg query output, skipping PATH update"); | |
| 124 | + return; | |
| 125 | + } | |
| 126 | + current = match[1].trim(); | |
| 127 | + } | |
| 128 | + if (!current.toLowerCase().includes(CLI_BIN_DIR.toLowerCase())) { | |
| 129 | + const updated = current ? `${current};${CLI_BIN_DIR}` : CLI_BIN_DIR; | |
| 130 | + spawn("reg", [ | |
| 131 | + "add", "HKCU\\Environment", "/v", "Path", | |
| 132 | + "/t", "REG_EXPAND_SZ", "/d", updated, "/f", | |
| 133 | + ]); | |
| 134 | + } | |
| 135 | + }); | |
| 136 | + return; | |
| 137 | + } | |
| 101 | 138 | |
| 102 | 139 | const pathLine = `export PATH="${CLI_BIN_DIR}:$PATH"`; |
| 103 | 140 | const profiles = [ |
| @@ -503,7 +540,7 @@ const loadingHTML = (scopePath: string): string => { · 399 unmodified lines | ||
| 503 | 540 | const subtle = isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.03)"; |
| 504 | 541 | const barColor = isDark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.4)"; |
| 505 | 542 | const barTrack = isDark ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.06)"; |
| 506 | − const folder = scopePath.replace(/\/+$/, "").split("/").pop() || scopePath; | |
| 543 | + const folder = basename(scopePath.replace(/[/\\]+$/, "")) || scopePath; | |
| 507 | 544 | |
| 508 | 545 | return `<!DOCTYPE html> |
| 509 | 546 | <html> |
| @@ -273,7 +273,7 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) { · 272 unmodified lines | ||
| 273 | 273 | function ScopeLabel() { |
| 274 | 274 | const { name } = useScopeInfo(); |
| 275 | 275 | // Show just the last folder name, with full path as tooltip |
| 276 | − const parts = name.replace(/\/+$/, "").split("/"); | |
| 276 | + const parts = name.replace(/[/\\]+$/, "").split(/[/\\]/); | |
| 277 | 277 | const folder = parts[parts.length - 1] || name; |
| 278 | 278 | |
| 279 | 279 | return ( |
| @@ -53,7 +53,7 @@ · 52 unmodified lines | ||
| 53 | 53 | "release:publish": "bun run --cwd apps/cli release:publish", |
| 54 | 54 | "release:publish:packages": "bun run scripts/publish-packages.ts", |
| 55 | 55 | "release:publish:packages:dry-run": "bun run scripts/publish-packages.ts --dry-run", |
| 56 | − "clean": "rm -rf node_modules .turbo .astro dist && find apps packages -maxdepth 5 \\( -name node_modules -o -name dist -o -name .turbo -o -name .output -o -name .astro -o -name '*.tsbuildinfo' \\) -not -path '*/node_modules/*' -exec rm -rf {} +", | |
| 56 | + "clean": "bun run scripts/clean.ts", | |
| 57 | 57 | "prepare": "effect-language-service patch" |
| 58 | 58 | }, |
| 59 | 59 | "dependencies": {}, |
| @@ -83,9 +83,12 @@ const defaultDenoExecutable = (): string => { · 82 unmodified lines | ||
| 83 | 83 | const configured = process.env.DENO_BIN?.trim(); |
| 84 | 84 | if (configured) return configured; |
| 85 | 85 | |
| 86 | − const home = process.env.HOME?.trim(); | |
| 86 | + const isWindows = process.platform === "win32"; | |
| 87 | + const home = (process.env.HOME || process.env.USERPROFILE)?.trim(); | |
| 87 | 88 | if (home) { |
| 88 | − const installedPath = `${home}/.deno/bin/deno`; | |
| 89 | + const installedPath = isWindows | |
| 90 | + ? `${home}\\.deno\\bin\\deno.exe` | |
| 91 | + : `${home}/.deno/bin/deno`; | |
| 89 | 92 | const result = spawnSync(installedPath, ["--version"], { |
| 90 | 93 | stdio: "ignore", |
| 91 | 94 | timeout: 5000, |
| @@ -102,8 +105,6 @@ const defaultDenoExecutable = (): string => { · 10 unmodified lines | ||
| 102 | 105 | |
| 103 | 106 | const resolveWorkerScriptPath = (): string => { |
| 104 | 107 | const moduleUrl = String(import.meta.url); |
| 105 | − if (moduleUrl.startsWith("/")) return moduleUrl; | |
| 106 | − | |
| 107 | 108 | try { |
| 108 | 109 | const workerUrl = new URL("./deno-subprocess-worker.mjs", moduleUrl); |
| 109 | 110 | if (workerUrl.protocol === "file:") return fileURLToPath(workerUrl); |
| @@ -9,9 +9,13 @@ import * as path from "node:path"; · 8 unmodified lines | ||
| 9 | 9 | |
| 10 | 10 | const APP_NAME = "executor"; |
| 11 | 11 | |
| 12 | −const xdgDataHome = (): string => | |
| 13 | − process.env.XDG_DATA_HOME?.trim() || | |
| 14 | − path.join(process.env.HOME || process.env.USERPROFILE || "~", ".local", "share"); | |
| 12 | +export const xdgDataHome = (): string => { | |
| 13 | + if (process.env.XDG_DATA_HOME?.trim()) return process.env.XDG_DATA_HOME.trim(); | |
| 14 | + if (process.platform === "win32") { | |
| 15 | + return process.env.LOCALAPPDATA || process.env.APPDATA || path.join(process.env.USERPROFILE || "~", "AppData", "Local"); | |
| 16 | + } | |
| 17 | + return path.join(process.env.HOME || "~", ".local", "share"); | |
| 18 | +}; | |
| 15 | 19 | |
| 16 | 20 | const authDir = (overrideDir?: string): string => overrideDir ?? path.join(xdgDataHome(), APP_NAME); |
| 17 | 21 | |
| @@ -0,0 +1,100 @@ | ||
| 1 | +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; | |
| 2 | + | |
| 3 | +import { xdgDataHome } from "./index"; | |
| 4 | + | |
| 5 | +const ENV_KEYS = ["XDG_DATA_HOME", "LOCALAPPDATA", "APPDATA", "USERPROFILE", "HOME"] as const; | |
| 6 | + | |
| 7 | +function stubPlatform(platform: NodeJS.Platform) { | |
| 8 | + Object.defineProperty(process, "platform", { value: platform, configurable: true }); | |
| 9 | +} | |
| 10 | + | |
| 11 | +function clearEnv() { | |
| 12 | + for (const key of ENV_KEYS) vi.stubEnv(key, ""); | |
| 13 | +} | |
| 14 | + | |
| 15 | +describe("xdgDataHome", () => { | |
| 16 | + const originalPlatform = process.platform; | |
| 17 | + | |
| 18 | + beforeEach(() => { | |
| 19 | + clearEnv(); | |
| 20 | + }); | |
| 21 | + | |
| 22 | + afterEach(() => { | |
| 23 | + vi.unstubAllEnvs(); | |
| 24 | + Object.defineProperty(process, "platform", { | |
| 25 | + value: originalPlatform, | |
| 26 | + configurable: true, | |
| 27 | + }); | |
| 28 | + }); | |
| 29 | + | |
| 30 | + test("prefers XDG_DATA_HOME when set on any platform", () => { | |
| 31 | + vi.stubEnv("XDG_DATA_HOME", "/custom/xdg"); | |
| 32 | + stubPlatform("linux"); | |
| 33 | + expect(xdgDataHome()).toBe("/custom/xdg"); | |
| 34 | + stubPlatform("win32"); | |
| 35 | + expect(xdgDataHome()).toBe("/custom/xdg"); | |
| 36 | + stubPlatform("darwin"); | |
| 37 | + expect(xdgDataHome()).toBe("/custom/xdg"); | |
| 38 | + }); | |
| 39 | + | |
| 40 | + test("ignores empty / whitespace-only XDG_DATA_HOME", () => { | |
| 41 | + vi.stubEnv("XDG_DATA_HOME", " "); | |
| 42 | + vi.stubEnv("HOME", "/home/rhys"); | |
| 43 | + stubPlatform("linux"); | |
| 44 | + expect(xdgDataHome()).toBe("/home/rhys/.local/share"); | |
| 45 | + }); | |
| 46 | + | |
| 47 | + test("trims whitespace around XDG_DATA_HOME", () => { | |
| 48 | + vi.stubEnv("XDG_DATA_HOME", " /trimmed/xdg "); | |
| 49 | + stubPlatform("linux"); | |
| 50 | + expect(xdgDataHome()).toBe("/trimmed/xdg"); | |
| 51 | + }); | |
| 52 | + | |
| 53 | + describe("on posix", () => { | |
| 54 | + beforeEach(() => stubPlatform("linux")); | |
| 55 | + | |
| 56 | + test("falls back to $HOME/.local/share", () => { | |
| 57 | + vi.stubEnv("HOME", "/home/rhys"); | |
| 58 | + expect(xdgDataHome()).toBe("/home/rhys/.local/share"); | |
| 59 | + }); | |
| 60 | + | |
| 61 | + test("defaults to ~/.local/share when HOME is unset", () => { | |
| 62 | + expect(xdgDataHome()).toBe("~/.local/share"); | |
| 63 | + }); | |
| 64 | + }); | |
| 65 | + | |
| 66 | + describe("on windows", () => { | |
| 67 | + beforeEach(() => stubPlatform("win32")); | |
| 68 | + | |
| 69 | + test("prefers LOCALAPPDATA", () => { | |
| 70 | + vi.stubEnv("LOCALAPPDATA", "C:\\Users\\rhys\\AppData\\Local"); | |
| 71 | + vi.stubEnv("APPDATA", "C:\\Users\\rhys\\AppData\\Roaming"); | |
| 72 | + vi.stubEnv("USERPROFILE", "C:\\Users\\rhys"); | |
| 73 | + expect(xdgDataHome()).toBe("C:\\Users\\rhys\\AppData\\Local"); | |
| 74 | + }); | |
| 75 | + | |
| 76 | + test("falls back to APPDATA when LOCALAPPDATA is unset", () => { | |
| 77 | + vi.stubEnv("APPDATA", "C:\\Users\\rhys\\AppData\\Roaming"); | |
| 78 | + vi.stubEnv("USERPROFILE", "C:\\Users\\rhys"); | |
| 79 | + expect(xdgDataHome()).toBe("C:\\Users\\rhys\\AppData\\Roaming"); | |
| 80 | + }); | |
| 81 | + | |
| 82 | + test("falls back to USERPROFILE\\AppData\\Local when both are unset", () => { | |
| 83 | + vi.stubEnv("USERPROFILE", "C:\\Users\\rhys"); | |
| 84 | + // The helper uses node:path.join which normalizes separators to the | |
| 85 | + // runtime platform, so on a POSIX test runner we can't assert the | |
| 86 | + // exact separator — just that all three segments are present. | |
| 87 | + const result = xdgDataHome(); | |
| 88 | + expect(result).toContain("C:\\Users\\rhys"); | |
| 89 | + expect(result).toContain("AppData"); | |
| 90 | + expect(result).toContain("Local"); | |
| 91 | + }); | |
| 92 | + | |
| 93 | + test("defaults USERPROFILE to ~ when everything is unset", () => { | |
| 94 | + const result = xdgDataHome(); | |
| 95 | + expect(result.startsWith("~")).toBe(true); | |
| 96 | + expect(result).toContain("AppData"); | |
| 97 | + expect(result).toContain("Local"); | |
| 98 | + }); | |
| 99 | + }); | |
| 100 | +}); | |
| @@ -0,0 +1,60 @@ | ||
| 1 | +#!/usr/bin/env bun | |
| 2 | +import fs from "node:fs"; | |
| 3 | +import path from "node:path"; | |
| 4 | +import { fileURLToPath } from "node:url"; | |
| 5 | + | |
| 6 | +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); | |
| 7 | + | |
| 8 | +const rootTargets = ["node_modules", ".turbo", ".astro", "dist"]; | |
| 9 | +for (const name of rootTargets) { | |
| 10 | + const p = path.join(root, name); | |
| 11 | + if (fs.existsSync(p)) { | |
| 12 | + try { | |
| 13 | + fs.rmSync(p, { recursive: true, force: true }); | |
| 14 | + console.log("removed", p); | |
| 15 | + } catch (e: any) { | |
| 16 | + console.warn("skipped (in use?):", p, "-", e.message); | |
| 17 | + } | |
| 18 | + } | |
| 19 | +} | |
| 20 | + | |
| 21 | +const nestedTargets = new Set(["node_modules", "dist", ".turbo", ".output", ".astro"]); | |
| 22 | +const nestedGlobs = /\.tsbuildinfo$/; | |
| 23 | +const searchRoots = ["apps", "packages"]; | |
| 24 | +const maxDepth = 5; | |
| 25 | + | |
| 26 | +function clean(dir: string, depth: number) { | |
| 27 | + if (depth > maxDepth) return; | |
| 28 | + let entries: fs.Dirent[]; | |
| 29 | + try { | |
| 30 | + entries = fs.readdirSync(dir, { withFileTypes: true }); | |
| 31 | + } catch { | |
| 32 | + return; | |
| 33 | + } | |
| 34 | + for (const entry of entries) { | |
| 35 | + const full = path.join(dir, entry.name); | |
| 36 | + if (entry.isDirectory()) { | |
| 37 | + if (nestedTargets.has(entry.name)) { | |
| 38 | + try { | |
| 39 | + fs.rmSync(full, { recursive: true, force: true }); | |
| 40 | + console.log("removed", full); | |
| 41 | + } catch (e: any) { | |
| 42 | + console.warn("skipped (in use?):", full, "-", e.message); | |
| 43 | + } | |
| 44 | + } else { | |
| 45 | + clean(full, depth + 1); | |
| 46 | + } | |
| 47 | + } else if (entry.isFile() && nestedGlobs.test(entry.name)) { | |
| 48 | + try { | |
| 49 | + fs.rmSync(full, { force: true }); | |
| 50 | + console.log("removed", full); | |
| 51 | + } catch (e: any) { | |
| 52 | + console.warn("skipped (in use?):", full, "-", e.message); | |
| 53 | + } | |
| 54 | + } | |
| 55 | + } | |
| 56 | +} | |
| 57 | + | |
| 58 | +for (const dir of searchRoots) { | |
| 59 | + clean(path.join(root, dir), 1); | |
| 60 | +} | |