diff --git a/packages/bcode-browser/src/browser-execute.ts b/packages/bcode-browser/src/browser-execute.ts index 8c849bd95..ca4d23418 100644 --- a/packages/bcode-browser/src/browser-execute.ts +++ b/packages/bcode-browser/src/browser-execute.ts @@ -15,6 +15,7 @@ import { Effect, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import z from "zod" import { resolveHarnessDir } from "./harness" +import { uvLocate } from "./uv-locate" const DEFAULT_TIMEOUT_MS = 60 * 1000 const MAX_TIMEOUT_MS = 10 * 60 * 1000 @@ -44,7 +45,9 @@ export interface ExecuteResult { } const UV_MISSING_HINT = - "uv is not installed or not on PATH. Install it once: curl -fsSL https://astral.sh/uv/install.sh | sh" + "uv is not installed or not on PATH. Install it once: curl -fsSL https://astral.sh/uv/install.sh | sh " + + "(Windows: irm https://astral.sh/uv/install.ps1 | iex). " + + "If you just installed uv, restart your terminal so PATH picks it up." // Spawn errors flow through effect's PlatformError; ENOENT lives on the wrapped // cause's `.code`. Walk the cause chain so we detect it regardless of nesting. @@ -59,12 +62,14 @@ const isUvMissing = (err: unknown): boolean => { export const make = Effect.fn("BrowserExecute.make")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const locate = yield* uvLocate const execute = (args: Parameters, ctx: ExecuteContext) => Effect.gen(function* () { const harnessDir = yield* Effect.promise(() => resolveHarnessDir()) + const uv = yield* locate const proc = ChildProcess.make( - "uv", + uv, ["run", "--project", harnessDir, "python", "run.py", "-c", args.python], { cwd: harnessDir, diff --git a/packages/bcode-browser/src/uv-locate.ts b/packages/bcode-browser/src/uv-locate.ts new file mode 100644 index 000000000..7e2ed1563 --- /dev/null +++ b/packages/bcode-browser/src/uv-locate.ts @@ -0,0 +1,60 @@ +// Resolve the absolute path to the `uv` executable. +// +// Why: `ChildProcess.make("uv", ...)` resolves bare names against +// `process.env.PATH` only. On Windows the official uv installer writes +// `%USERPROFILE%\.local\bin` into the *User* PATH registry key, which +// GUI-launched processes (Cursor / VSCode terminal, double-clicked bcode.exe) +// don't pick up until a full re-login. Result: `uv --version` works in the +// user's shell but the bcode child process gets ENOENT. +// +// Probe order: +// 1. Walk `process.env.PATH` (with platform-correct extensions on Windows). +// 2. Fall back to a per-platform allowlist of well-known install dirs. +// On miss, return the bare name "uv" so the caller's existing ENOENT path +// (UV_MISSING_HINT, exit 127) keeps working. +// +// Memoized per-process via `Effect.cached` — yield once at service +// construction to bind the cached effect, then yield it on each call to get +// the resolved path. First browser_execute call pays the fs probe; subsequent +// calls are free. +// +// Pure addition. Level 1. +import { Effect } from "effect" +import fs from "fs/promises" +import os from "os" +import path from "path" + +const isWindows = process.platform === "win32" +const EXTS = isWindows ? [".exe", ".cmd", ".bat", ""] : [""] + +const allowlist = (() => { + const home = os.homedir() + if (isWindows) + return [ + path.join(home, ".local", "bin"), + path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "uv", "bin"), + path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Programs", "uv"), + ] + return [path.join(home, ".local", "bin"), path.join(home, ".cargo", "bin"), "/opt/homebrew/bin", "/usr/local/bin"] +})() + +const findIn = async (dir: string): Promise => { + for (const ext of EXTS) { + const candidate = path.join(dir, `uv${ext}`) + if (await fs.access(candidate).then(() => true, () => false)) return candidate + } + return null +} + +const probe = async (): Promise => { + const pathDirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean) + for (const dir of [...pathDirs, ...allowlist]) { + const hit = await findIn(dir) + if (hit) return hit + } + return "uv" +} + +export const uvLocate = Effect.cached(Effect.promise(probe)) + +export * as UvLocate from "./uv-locate"