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
564564 const arch = os.arch() === "arm64" ? "arm64" : "x64";
565565 const binary = platform === "windows" ? "executor.exe" : "executor";
566566 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");
568568
569569 const isMusl = (() => {
570570 if (platform !== "linux") return false;
@@ -617,11 +617,16 @@ const extract = () => { · 46 unmodified lines
617617 return;
618618 }
619619
620− const command = [
620+ const psCommand = [
621621 "$ErrorActionPreference = 'Stop'",
622622 "Expand-Archive -LiteralPath '" + archivePath.replace(/'/g, "''") + "' -DestinationPath '" + runtimeDir.replace(/'/g, "''") + "' -Force",
623623 ].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+ }
625630 };
626631
627632 (async () => {
@@ -636,7 +641,7 @@ const extract = () => { · 8 unmodified lines
636641 if (fs.existsSync(legacyCachedBinary)) {
637642 fs.unlinkSync(legacyCachedBinary);
638643 }
639− fs.chmodSync(cachedBinary, 0o755);
644+ if (platform !== "windows") fs.chmodSync(cachedBinary, 0o755);
640645 fs.rmSync(archivePath, { force: true });
641646 console.log("executor: installed " + assetBase + " from GitHub Releases");
642647 } catch (error) {
apps/cloud/package.json+1−1
@@ -11,7 +11,7 @@ · 10 unmodified lines
1111 "db:generate": "drizzle-kit generate",
1212 "build": "vite build",
1313 "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",
1515 "cf-typegen": "wrangler types",
1616 "typecheck": "tsgo --noEmit",
1717 "test": "vitest run",
apps/desktop/src/main.ts+39−2
@@ -97,7 +97,44 @@ const installCli = (): void => { · 96 unmodified lines
9797 }
9898
9999 // 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+ }
101138
102139 const pathLine = `export PATH="${CLI_BIN_DIR}:$PATH"`;
103140 const profiles = [
@@ -503,7 +540,7 @@ const loadingHTML = (scopePath: string): string => { · 399 unmodified lines
503540 const subtle = isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.03)";
504541 const barColor = isDark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.4)";
505542 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;
507544
508545 return `<!DOCTYPE html>
509546 <html>
apps/local/src/web/shell.tsx+1−1
@@ -273,7 +273,7 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) { · 272 unmodified lines
273273 function ScopeLabel() {
274274 const { name } = useScopeInfo();
275275 // Show just the last folder name, with full path as tooltip
276− const parts = name.replace(/\/+$/, "").split("/");
276+ const parts = name.replace(/[/\\]+$/, "").split(/[/\\]/);
277277 const folder = parts[parts.length - 1] || name;
278278
279279 return (
package.json+1−1
@@ -53,7 +53,7 @@ · 52 unmodified lines
5353 "release:publish": "bun run --cwd apps/cli release:publish",
5454 "release:publish:packages": "bun run scripts/publish-packages.ts",
5555 "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",
5757 "prepare": "effect-language-service patch"
5858 },
5959 "dependencies": {},
packages/kernel/runtime-deno-subprocess/src/index.ts+5−4
@@ -83,9 +83,12 @@ const defaultDenoExecutable = (): string => { · 82 unmodified lines
8383 const configured = process.env.DENO_BIN?.trim();
8484 if (configured) return configured;
8585
86− const home = process.env.HOME?.trim();
86+ const isWindows = process.platform === "win32";
87+ const home = (process.env.HOME || process.env.USERPROFILE)?.trim();
8788 if (home) {
88− const installedPath = `${home}/.deno/bin/deno`;
89+ const installedPath = isWindows
90+ ? `${home}\\.deno\\bin\\deno.exe`
91+ : `${home}/.deno/bin/deno`;
8992 const result = spawnSync(installedPath, ["--version"], {
9093 stdio: "ignore",
9194 timeout: 5000,
@@ -102,8 +105,6 @@ const defaultDenoExecutable = (): string => { · 10 unmodified lines
102105
103106 const resolveWorkerScriptPath = (): string => {
104107 const moduleUrl = String(import.meta.url);
105− if (moduleUrl.startsWith("/")) return moduleUrl;
106−
107108 try {
108109 const workerUrl = new URL("./deno-subprocess-worker.mjs", moduleUrl);
109110 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
99
1010 const APP_NAME = "executor";
1111
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+};
1519
1620 const authDir = (overrideDir?: string): string => overrideDir ?? path.join(xdgDataHome(), APP_NAME);
1721
packages/plugins/file-secrets/src/xdg.test.ts+100−0
@@ -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+});
scripts/clean.ts+60−0
@@ -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+}