History

fix: Windows compatibility across postinstall, desktop, plugins, and scripts (#211)

* 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. ---------

Tito committed 7 months ago

Commit 800fcc01 parent a3fa7c19 files · +223 −16Browse files

Unified · Split

9 files
  1. apps/cli/src/build.ts +9 −4
  2. apps/cloud/package.json +1 −1
  3. apps/desktop/src/main.ts +39 −2
  4. apps/local/src/web/shell.tsx +1 −1
  5. package.json +1 −1
  6. packages/kernel/runtime-deno-subprocess/src/index.ts +5 −4
  7. packages/plugins/file-secrets/src/index.ts +7 −3
  8. packages/plugins/file-secrets/src/xdg.test.ts +100 −0
  9. scripts/clean.ts +60 −0
apps/cli/src/build.ts+9−4
@@ -564,7 +564,7 @@ const platform = platformMap[os.platform()] || os.platform(); · 563 unmodified lines
564const arch = os.arch() === "arm64" ? "arm64" : "x64";564const arch = os.arch() === "arm64" ? "arm64" : "x64";
565const binary = platform === "windows" ? "executor.exe" : "executor";565const binary = platform === "windows" ? "executor.exe" : "executor";
566const cachedBinary = path.join(runtimeDir, binary);566const cachedBinary = path.join(runtimeDir, binary);
567const legacyCachedBinary = path.join(binDir, platform === "win32" ? ".executor.exe" : ".executor");567const legacyCachedBinary = path.join(binDir, platform === "windows" ? ".executor.exe" : ".executor");
568 568
569const isMusl = (() => {569const 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) {
apps/cloud/package.json+1−1
@@ -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",
apps/desktop/src/main.ts+39−2
@@ -97,7 +97,44 @@ const installCli = (): void => { · 96 unmodified lines
97 }97 }
98 98
99 // Patch shell profiles with PATH99 // 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>
apps/local/src/web/shell.tsx+1−1
@@ -273,7 +273,7 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) { · 272 unmodified lines
273function ScopeLabel() {273function ScopeLabel() {
274 const { name } = useScopeInfo();274 const { name } = useScopeInfo();
275 // Show just the last folder name, with full path as tooltip275 // 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 (
package.json+1−1
@@ -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": {},
packages/kernel/runtime-deno-subprocess/src/index.ts+5−4
@@ -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
103const resolveWorkerScriptPath = (): string => {106const 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);
packages/plugins/file-secrets/src/index.ts+7−3
@@ -9,9 +9,13 @@ import * as path from "node:path"; · 8 unmodified lines
9 9
10const APP_NAME = "executor";10const APP_NAME = "executor";
11 11
12const xdgDataHome = (): string =>12export 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
16const authDir = (overrideDir?: string): string => overrideDir ?? path.join(xdgDataHome(), APP_NAME);20const authDir = (overrideDir?: string): string => overrideDir ?? path.join(xdgDataHome(), APP_NAME);
17 21
packages/plugins/file-secrets/src/xdg.test.ts+100−0
@@ -0,0 +1,100 @@
1import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2
3import { xdgDataHome } from "./index";
4
5const ENV_KEYS = ["XDG_DATA_HOME", "LOCALAPPDATA", "APPDATA", "USERPROFILE", "HOME"] as const;
6
7function stubPlatform(platform: NodeJS.Platform) {
8 Object.defineProperty(process, "platform", { value: platform, configurable: true });
9}
10
11function clearEnv() {
12 for (const key of ENV_KEYS) vi.stubEnv(key, "");
13}
14
15describe("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});
scripts/clean.ts+60−0
@@ -0,0 +1,60 @@
1#!/usr/bin/env bun
2import fs from "node:fs";
3import path from "node:path";
4import { fileURLToPath } from "node:url";
5
6const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
7
8const rootTargets = ["node_modules", ".turbo", ".astro", "dist"];
9for (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
21const nestedTargets = new Set(["node_modules", "dist", ".turbo", ".output", ".astro"]);
22const nestedGlobs = /\.tsbuildinfo$/;
23const searchRoots = ["apps", "packages"];
24const maxDepth = 5;
25
26function 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
58for (const dir of searchRoots) {
59 clean(path.join(root, dir), 1);
60}