fix: locate the Devin CLI on Windows
KNOWN_BINS only listed Unix/macOS paths, and whichDevin() always shelled out to /usr/bin/which. On Windows neither resolves, so the provider could not find `devin.exe` even when it was installed and on PATH. The model catalog therefore never loaded and the provider silently fell back to the four hard-coded FALLBACK_MODELS entries. - Probe the documented Windows install locations (%LOCALAPPDATA%\devin\ cli\bin\devin.exe, the Devin Desktop bundled CLI for both per-user and machine-wide installs, plus two fallbacks). - Build the candidate list lazily so a DEVIN_CLI value set after module load is still honoured. - Use `where` instead of /usr/bin/which on Windows, and accept multiple result lines, since `where` prints one path per line. All existing Unix/macOS paths are preserved. Tested on Windows 11, Node 24.5.0, pi 0.87.1, Devin CLI 3000.11.3: `pi --list-models devin` goes from 4 models to 52 with DEVIN_CLI unset. `tsc --noEmit` and the existing test suite both pass.
This commit is contained in:
+44
-17
@@ -6,20 +6,38 @@ import { promisify } from "node:util";
|
|||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
const KNOWN_BINS = [
|
function candidateBins(): string[] {
|
||||||
process.env.DEVIN_CLI,
|
const localAppData = process.env.LOCALAPPDATA ?? join(homedir(), "AppData/Local");
|
||||||
join(homedir(), ".local/bin/devin"),
|
return [
|
||||||
join(homedir(), ".devin/bin/devin"),
|
// Explicit override always wins. Read lazily so a value set after module
|
||||||
"/opt/homebrew/bin/devin",
|
// load (e.g. by the host process) is still honoured.
|
||||||
"/usr/local/bin/devin",
|
process.env.DEVIN_CLI,
|
||||||
"/Applications/Devin.app/Contents/Resources/app/extensions/windsurf/devin/bin/devin",
|
// ── Windows ──────────────────────────────────────────────────────────
|
||||||
].filter((p): p is string => Boolean(p));
|
// Official installer location (%LOCALAPPDATA%\devin\cli\bin\devin.exe)
|
||||||
|
join(localAppData, "devin/cli/bin/devin.exe"),
|
||||||
|
// Devin Desktop (Windsurf-based) bundled CLI, per-user install
|
||||||
|
join(localAppData, "Programs/Devin/resources/app/extensions/windsurf/devin/bin/devin.exe"),
|
||||||
|
// Devin Desktop bundled CLI, machine-wide install
|
||||||
|
process.env.ProgramFiles &&
|
||||||
|
join(process.env.ProgramFiles, "Devin/resources/app/extensions/windsurf/devin/bin/devin.exe"),
|
||||||
|
// Set by the official Windows installer when it completes normally.
|
||||||
|
process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, "devin/bin/devin.exe"),
|
||||||
|
// Manual fallback for installs that could not finish while Devin was running.
|
||||||
|
join(homedir(), ".devin-cli/devin.exe"),
|
||||||
|
// ── Unix / macOS ─────────────────────────────────────────────────────
|
||||||
|
join(homedir(), ".local/bin/devin"),
|
||||||
|
join(homedir(), ".devin/bin/devin"),
|
||||||
|
"/opt/homebrew/bin/devin",
|
||||||
|
"/usr/local/bin/devin",
|
||||||
|
"/Applications/Devin.app/Contents/Resources/app/extensions/windsurf/devin/bin/devin",
|
||||||
|
].filter((p): p is string => Boolean(p));
|
||||||
|
}
|
||||||
|
|
||||||
let cachedBin: string | null | undefined;
|
let cachedBin: string | null | undefined;
|
||||||
|
|
||||||
export function findDevinBin(): string | null {
|
export function findDevinBin(): string | null {
|
||||||
if (cachedBin !== undefined) return cachedBin;
|
if (cachedBin !== undefined) return cachedBin;
|
||||||
for (const bin of KNOWN_BINS) {
|
for (const bin of candidateBins()) {
|
||||||
if (existsSync(bin)) {
|
if (existsSync(bin)) {
|
||||||
cachedBin = bin;
|
cachedBin = bin;
|
||||||
return bin;
|
return bin;
|
||||||
@@ -36,15 +54,24 @@ export function clearDevinBinCache(): void {
|
|||||||
export async function whichDevin(): Promise<string | null> {
|
export async function whichDevin(): Promise<string | null> {
|
||||||
const known = findDevinBin();
|
const known = findDevinBin();
|
||||||
if (known) return known;
|
if (known) return known;
|
||||||
try {
|
// Windows has no `which`; use `where` and probe the returned paths directly.
|
||||||
const { stdout } = await execFileAsync("/usr/bin/which", ["devin"], { timeout: 5_000 });
|
const isWindows = process.platform === "win32";
|
||||||
const path = stdout.trim();
|
const locator = isWindows ? "where" : "/usr/bin/which";
|
||||||
if (path && existsSync(path)) {
|
const candidates = isWindows ? ["devin.exe", "devin.cmd", "devin"] : ["devin"];
|
||||||
cachedBin = path;
|
for (const candidate of candidates) {
|
||||||
return path;
|
try {
|
||||||
|
const { stdout } = await execFileAsync(locator, [candidate], { timeout: 5_000 });
|
||||||
|
// `where` may return several lines; take the first existing one.
|
||||||
|
for (const line of stdout.split(/\r?\n/)) {
|
||||||
|
const path = line.trim();
|
||||||
|
if (path && existsSync(path)) {
|
||||||
|
cachedBin = path;
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// not on PATH
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// not on PATH
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user