Skip to content

Commit 104f7bc

Browse files
committed
fix(browser-execute): resolve uv to absolute path before spawn
Windows users hit ENOENT when uv is installed but %USERPROFILE%\.local\bin isn't on the bcode process PATH (User-PATH writes by the uv installer aren't picked up until full re-login by GUI-launched processes). Probe PATH then a per-platform allowlist; fall back to bare 'uv' so the existing UV_MISSING_HINT path still fires when uv is genuinely absent. Memoized via Effect.cached, bound once at make().
1 parent cd8ece3 commit 104f7bc

2 files changed

Lines changed: 67 additions & 2 deletions

File tree

packages/bcode-browser/src/browser-execute.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { Effect, Stream } from "effect"
1515
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
1616
import z from "zod"
1717
import { resolveHarnessDir } from "./harness"
18+
import { uvLocate } from "./uv-locate"
1819

1920
const DEFAULT_TIMEOUT_MS = 60 * 1000
2021
const MAX_TIMEOUT_MS = 10 * 60 * 1000
@@ -44,7 +45,9 @@ export interface ExecuteResult {
4445
}
4546

4647
const UV_MISSING_HINT =
47-
"uv is not installed or not on PATH. Install it once: curl -fsSL https://astral.sh/uv/install.sh | sh"
48+
"uv is not installed or not on PATH. Install it once: curl -fsSL https://astral.sh/uv/install.sh | sh " +
49+
"(Windows: irm https://astral.sh/uv/install.ps1 | iex). " +
50+
"If you just installed uv, restart your terminal so PATH picks it up."
4851

4952
// Spawn errors flow through effect's PlatformError; ENOENT lives on the wrapped
5053
// cause's `.code`. Walk the cause chain so we detect it regardless of nesting.
@@ -59,12 +62,14 @@ const isUvMissing = (err: unknown): boolean => {
5962

6063
export const make = Effect.fn("BrowserExecute.make")(function* () {
6164
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
65+
const locate = yield* uvLocate
6266

6367
const execute = (args: Parameters, ctx: ExecuteContext) =>
6468
Effect.gen(function* () {
6569
const harnessDir = yield* Effect.promise(() => resolveHarnessDir())
70+
const uv = yield* locate
6671
const proc = ChildProcess.make(
67-
"uv",
72+
uv,
6873
["run", "--project", harnessDir, "python", "run.py", "-c", args.python],
6974
{
7075
cwd: harnessDir,
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Resolve the absolute path to the `uv` executable.
2+
//
3+
// Why: `ChildProcess.make("uv", ...)` resolves bare names against
4+
// `process.env.PATH` only. On Windows the official uv installer writes
5+
// `%USERPROFILE%\.local\bin` into the *User* PATH registry key, which
6+
// GUI-launched processes (Cursor / VSCode terminal, double-clicked bcode.exe)
7+
// don't pick up until a full re-login. Result: `uv --version` works in the
8+
// user's shell but the bcode child process gets ENOENT.
9+
//
10+
// Probe order:
11+
// 1. Walk `process.env.PATH` (with platform-correct extensions on Windows).
12+
// 2. Fall back to a per-platform allowlist of well-known install dirs.
13+
// On miss, return the bare name "uv" so the caller's existing ENOENT path
14+
// (UV_MISSING_HINT, exit 127) keeps working.
15+
//
16+
// Memoized per-process via `Effect.cached` — yield once at service
17+
// construction to bind the cached effect, then yield it on each call to get
18+
// the resolved path. First browser_execute call pays the fs probe; subsequent
19+
// calls are free.
20+
//
21+
// Pure addition. Level 1.
22+
import { Effect } from "effect"
23+
import fs from "fs/promises"
24+
import os from "os"
25+
import path from "path"
26+
27+
const isWindows = process.platform === "win32"
28+
const EXTS = isWindows ? [".exe", ".cmd", ".bat", ""] : [""]
29+
30+
const allowlist = (() => {
31+
const home = os.homedir()
32+
if (isWindows)
33+
return [
34+
path.join(home, ".local", "bin"),
35+
path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "uv", "bin"),
36+
path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Programs", "uv"),
37+
]
38+
return [path.join(home, ".local", "bin"), path.join(home, ".cargo", "bin"), "/opt/homebrew/bin", "/usr/local/bin"]
39+
})()
40+
41+
const findIn = async (dir: string): Promise<string | null> => {
42+
for (const ext of EXTS) {
43+
const candidate = path.join(dir, `uv${ext}`)
44+
if (await fs.access(candidate).then(() => true, () => false)) return candidate
45+
}
46+
return null
47+
}
48+
49+
const probe = async (): Promise<string> => {
50+
const pathDirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean)
51+
for (const dir of [...pathDirs, ...allowlist]) {
52+
const hit = await findIn(dir)
53+
if (hit) return hit
54+
}
55+
return "uv"
56+
}
57+
58+
export const uvLocate = Effect.cached(Effect.promise(probe))
59+
60+
export * as UvLocate from "./uv-locate"

0 commit comments

Comments
 (0)