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