Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions packages/bcode-browser/src/browser-execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down
60 changes: 60 additions & 0 deletions packages/bcode-browser/src/uv-locate.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> => {
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<string> => {
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"
Loading