* 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 | const arch = os.arch() === "arm64" ? "arm64" : "x64"; | 564 | const arch = os.arch() === "arm64" ? "arm64" : "x64"; |
| 565 | const binary = platform === "windows" ? "executor.exe" : "executor"; | 565 | const binary = platform === "windows" ? "executor.exe" : "executor"; |
| 566 | const cachedBinary = path.join(runtimeDir, binary); | 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 | const isMusl = (() => { | 569 | const isMusl = (() => { |
| 570 | if (platform !== "linux") return false; | 570 | if (platform !== "linux") return false; |
| @@ -617,11 +617,16 @@ const extract = () => { · 46 unmodified lines | |||
| 617 | return; | 617 | return; |
| 618 | } | 618 | } |
| 619 | 619 | ||
| 620 | const command = [ | 620 | const psCommand = [ |
| 621 | "$ErrorActionPreference = 'Stop'", | 621 | "$ErrorActionPreference = 'Stop'", |
| 622 | "Expand-Archive -LiteralPath '" + archivePath.replace(/'/g, "''") + "' -DestinationPath '" + runtimeDir.replace(/'/g, "''") + "' -Force", | 622 | "Expand-Archive -LiteralPath '" + archivePath.replace(/'/g, "''") + "' -DestinationPath '" + runtimeDir.replace(/'/g, "''") + "' -Force", |
| 623 | ].join("; "); | 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 | (async () => { | 632 | (async () => { |
| @@ -636,7 +641,7 @@ const extract = () => { · 8 unmodified lines | |||
| 636 | if (fs.existsSync(legacyCachedBinary)) { | 641 | if (fs.existsSync(legacyCachedBinary)) { |
| 637 | fs.unlinkSync(legacyCachedBinary); | 642 | fs.unlinkSync(legacyCachedBinary); |
| 638 | } | 643 | } |
| 639 | fs.chmodSync(cachedBinary, 0o755); | 644 | if (platform !== "windows") fs.chmodSync(cachedBinary, 0o755); |
| 640 | fs.rmSync(archivePath, { force: true }); | 645 | fs.rmSync(archivePath, { force: true }); |
| 641 | console.log("executor: installed " + assetBase + " from GitHub Releases"); | 646 | console.log("executor: installed " + assetBase + " from GitHub Releases"); |
| 642 | } catch (error) { | 647 | } catch (error) { |
| @@ -11,7 +11,7 @@ · 10 unmodified lines | |||
| 11 | "db:generate": "drizzle-kit generate", | 11 | "db:generate": "drizzle-kit generate", |
| 12 | "build": "vite build", | 12 | "build": "vite build", |
| 13 | "preview": "vite preview", | 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 | "cf-typegen": "wrangler types", | 15 | "cf-typegen": "wrangler types", |
| 16 | "typecheck": "tsgo --noEmit", | 16 | "typecheck": "tsgo --noEmit", |
| 17 | "test": "vitest run", | 17 | "test": "vitest run", |
| @@ -97,7 +97,44 @@ const installCli = (): void => { · 96 unmodified lines | |||
| 97 | } | 97 | } |
| 98 | 98 | ||
| 99 | // Patch shell profiles with PATH | 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 | const pathLine = `export PATH="${CLI_BIN_DIR}:$PATH"`; | 139 | const pathLine = `export PATH="${CLI_BIN_DIR}:$PATH"`; |
| 103 | const profiles = [ | 140 | const profiles = [ |
| @@ -503,7 +540,7 @@ const loadingHTML = (scopePath: string): string => { · 399 unmodified lines | |||
| 503 | const subtle = isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.03)"; | 540 | const subtle = isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.03)"; |
| 504 | const barColor = isDark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.4)"; | 541 | const barColor = isDark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.4)"; |
| 505 | const barTrack = isDark ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.06)"; | 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 | return `<!DOCTYPE html> | 545 | return `<!DOCTYPE html> |
| 509 | <html> | 546 | <html> |
| @@ -273,7 +273,7 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) { · 272 unmodified lines | |||
| 273 | function ScopeLabel() { | 273 | function ScopeLabel() { |
| 274 | const { name } = useScopeInfo(); | 274 | const { name } = useScopeInfo(); |
| 275 | // Show just the last folder name, with full path as tooltip | 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 | const folder = parts[parts.length - 1] || name; | 277 | const folder = parts[parts.length - 1] || name; |
| 278 | 278 | ||
| 279 | return ( | 279 | return ( |
| @@ -53,7 +53,7 @@ · 52 unmodified lines | |||
| 53 | "release:publish": "bun run --cwd apps/cli release:publish", | 53 | "release:publish": "bun run --cwd apps/cli release:publish", |
| 54 | "release:publish:packages": "bun run scripts/publish-packages.ts", | 54 | "release:publish:packages": "bun run scripts/publish-packages.ts", |
| 55 | "release:publish:packages:dry-run": "bun run scripts/publish-packages.ts --dry-run", | 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 | "prepare": "effect-language-service patch" | 57 | "prepare": "effect-language-service patch" |
| 58 | }, | 58 | }, |
| 59 | "dependencies": {}, | 59 | "dependencies": {}, |
| @@ -83,9 +83,12 @@ const defaultDenoExecutable = (): string => { · 82 unmodified lines | |||
| 83 | const configured = process.env.DENO_BIN?.trim(); | 83 | const configured = process.env.DENO_BIN?.trim(); |
| 84 | if (configured) return configured; | 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 | if (home) { | 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 | const result = spawnSync(installedPath, ["--version"], { | 92 | const result = spawnSync(installedPath, ["--version"], { |
| 90 | stdio: "ignore", | 93 | stdio: "ignore", |
| 91 | timeout: 5000, | 94 | timeout: 5000, |
| @@ -102,8 +105,6 @@ const defaultDenoExecutable = (): string => { · 10 unmodified lines | |||
| 102 | 105 | ||
| 103 | const resolveWorkerScriptPath = (): string => { | 106 | const resolveWorkerScriptPath = (): string => { |
| 104 | const moduleUrl = String(import.meta.url); | 107 | const moduleUrl = String(import.meta.url); |
| 105 | if (moduleUrl.startsWith("/")) return moduleUrl; | ||
| 106 | |||
| 107 | try { | 108 | try { |
| 108 | const workerUrl = new URL("./deno-subprocess-worker.mjs", moduleUrl); | 109 | const workerUrl = new URL("./deno-subprocess-worker.mjs", moduleUrl); |
| 109 | if (workerUrl.protocol === "file:") return fileURLToPath(workerUrl); | 110 | if (workerUrl.protocol === "file:") return fileURLToPath(workerUrl); |
| @@ -9,9 +9,13 @@ import * as path from "node:path"; · 8 unmodified lines | |||
| 9 | 9 | ||
| 10 | const APP_NAME = "executor"; | 10 | const APP_NAME = "executor"; |
| 11 | 11 | ||
| 12 | const xdgDataHome = (): string => | 12 | export const xdgDataHome = (): string => { |
| 13 | process.env.XDG_DATA_HOME?.trim() || | 13 | if (process.env.XDG_DATA_HOME?.trim()) return process.env.XDG_DATA_HOME.trim(); |
| 14 | path.join(process.env.HOME || process.env.USERPROFILE || "~", ".local", "share"); | 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 | const authDir = (overrideDir?: string): string => overrideDir ?? path.join(xdgDataHome(), APP_NAME); | 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 | } | ||