Skip to content

Commit 3164695

Browse files
fix(dashboard): discover pnpm-installed OpenCode CLI on Windows (#149)
pnpm installs global bins at %LOCALAPPDATA%\pnpm\bin\opencode.cmd (its PATH-linked bin dir) and %LOCALAPPDATA%\pnpm\opencode.cmd (store root). Neither is on a GUI process's inherited PATH, so where.exe misses them, and neither was in the hardcoded candidate list — so a pnpm user's model dropdowns came up empty. Verified on windows-latest (win-opencode-probe.yml) that the real cause is discovery, not execution: Rust's Command launches a .cmd shim directly (exit 0), so the fix is purely adding the pnpm absolute paths to the candidate list — no cmd /C wrapper. The earlier hypothesis that .cmd shims can't be launched directly was disproven by the probe before any code shipped. Extracted the Windows branch into a pure, unit-tested windows_opencode_candidates helper. Added: pnpm candidate coverage, empty-env-var safety, and a Windows-only regression test asserting run_bounded_binary executes a .cmd shim. Co-authored-by: Alfonso [Magic Context] <288211368+alfonso-magic-context@users.noreply.github.com>
1 parent e1b3c06 commit 3164695

1 file changed

Lines changed: 96 additions & 88 deletions

File tree

packages/dashboard/src-tauri/src/commands.rs

Lines changed: 96 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -646,32 +646,11 @@ fn opencode_cli_candidates() -> Vec<String> {
646646
// GUI apps do not inherit every shell PATH, so keep this list in one place
647647
// for model discovery and install-state detection.
648648
if cfg!(target_os = "windows") {
649-
let userprofile = std::env::var("USERPROFILE").unwrap_or_default();
650-
let appdata = std::env::var("APPDATA").unwrap_or_default();
651-
let localappdata = std::env::var("LOCALAPPDATA").unwrap_or_default();
652-
let mut list = Vec::new();
653-
if !userprofile.is_empty() {
654-
list.push(format!("{}\\.opencode\\bin\\opencode.exe", userprofile));
655-
}
656-
if !appdata.is_empty() {
657-
list.push(format!("{}\\npm\\opencode.cmd", appdata));
658-
list.push(format!("{}\\npm\\opencode.exe", appdata));
659-
}
660-
if !localappdata.is_empty() {
661-
list.push(format!(
662-
"{}\\Microsoft\\WinGet\\Links\\opencode.exe",
663-
localappdata
664-
));
665-
}
666-
if !userprofile.is_empty() {
667-
list.push(format!("{}\\scoop\\shims\\opencode.exe", userprofile));
668-
}
669-
if !localappdata.is_empty() {
670-
list.push(format!("{}\\opencode\\bin\\opencode.exe", localappdata));
671-
}
672-
list.push("opencode".to_string());
673-
list.push("opencode.exe".to_string());
674-
list
649+
windows_opencode_candidates(
650+
std::env::var("USERPROFILE").ok().as_deref(),
651+
std::env::var("APPDATA").ok().as_deref(),
652+
std::env::var("LOCALAPPDATA").ok().as_deref(),
653+
)
675654
} else {
676655
let home = std::env::var("HOME").unwrap_or_default();
677656
vec![
@@ -687,6 +666,46 @@ fn opencode_cli_candidates() -> Vec<String> {
687666
}
688667
}
689668

669+
/// Windows OpenCode CLI candidates, as absolute paths tried directly (so they
670+
/// work even though a GUI launch does not inherit the user's shell PATH). Pure
671+
/// over its env inputs so it can be unit-tested without touching process env.
672+
///
673+
/// pnpm is the key #149 addition: pnpm installs global bins under
674+
/// `%LOCALAPPDATA%\pnpm\<name>.cmd` (its store) AND links them into
675+
/// `%LOCALAPPDATA%\pnpm\bin\<name>.cmd` (the dir it puts on PATH). Neither is on
676+
/// a GUI process's PATH, so `where.exe` misses them; listing the absolute paths
677+
/// here finds them. A direct `.cmd` launch works on Windows (verified), so no
678+
/// `cmd /C` wrapper is needed.
679+
fn windows_opencode_candidates(
680+
userprofile: Option<&str>,
681+
appdata: Option<&str>,
682+
localappdata: Option<&str>,
683+
) -> Vec<String> {
684+
let mut list = Vec::new();
685+
if let Some(p) = userprofile.filter(|s| !s.is_empty()) {
686+
list.push(format!("{p}\\.opencode\\bin\\opencode.exe"));
687+
}
688+
if let Some(p) = appdata.filter(|s| !s.is_empty()) {
689+
list.push(format!("{p}\\npm\\opencode.cmd"));
690+
list.push(format!("{p}\\npm\\opencode.exe"));
691+
}
692+
if let Some(p) = localappdata.filter(|s| !s.is_empty()) {
693+
// pnpm: both the bin-link dir (on PATH for shells) and the store root.
694+
list.push(format!("{p}\\pnpm\\bin\\opencode.cmd"));
695+
list.push(format!("{p}\\pnpm\\opencode.cmd"));
696+
list.push(format!("{p}\\Microsoft\\WinGet\\Links\\opencode.exe"));
697+
}
698+
if let Some(p) = userprofile.filter(|s| !s.is_empty()) {
699+
list.push(format!("{p}\\scoop\\shims\\opencode.exe"));
700+
}
701+
if let Some(p) = localappdata.filter(|s| !s.is_empty()) {
702+
list.push(format!("{p}\\opencode\\bin\\opencode.exe"));
703+
}
704+
list.push("opencode".to_string());
705+
list.push("opencode.exe".to_string());
706+
list
707+
}
708+
690709
fn opencode_desktop_settings_paths(
691710
platform: DesktopPlatform,
692711
env: &OpencodeDesktopEnv,
@@ -1278,8 +1297,8 @@ pub fn get_db_health(state: State<'_, AppState>) -> db::DbHealth {
12781297
mod tests {
12791298
use super::{
12801299
opencode_desktop_detected_for_env, parse_pi_models_output, pick_first_line,
1281-
prepare_embedding_probe_options, run_bounded_binary, DesktopPlatform, OpencodeDesktopEnv,
1282-
OPENCODE_DESKTOP_APP_IDS,
1300+
prepare_embedding_probe_options, run_bounded_binary, windows_opencode_candidates,
1301+
DesktopPlatform, OpencodeDesktopEnv, OPENCODE_DESKTOP_APP_IDS,
12831302
};
12841303
use crate::embedding_probe::EmbeddingProbeOutcome;
12851304
use std::path::{Path, PathBuf};
@@ -1425,73 +1444,62 @@ mod tests {
14251444
);
14261445
}
14271446

1428-
// Exploratory probe for #149: can the dashboard's discovery path execute a
1429-
// pnpm/npm `.cmd` shim at all? Rust's Command (like every CreateProcessW
1430-
// caller) does not run batch files directly, so a resolved `opencode.cmd`
1431-
// may fail to launch even when discovery FINDS it. Run on Windows CI with:
1432-
// cargo test --ignored --nocapture win_cmd_shim_execution_probe
1433-
// It prints the raw outcomes; the fix is then asserted by a real regression
1434-
// test once the mechanism is confirmed.
1447+
// #149 regression: the discovery path must be able to LAUNCH a `.cmd` shim
1448+
// (pnpm/npm install opencode as opencode.cmd). Verified on windows-latest
1449+
// that Rust's Command runs a `.cmd` directly, so the fix is purely adding the
1450+
// pnpm path to the candidate list (no `cmd /C` wrapper). This asserts that
1451+
// run_bounded_binary actually executes a `.cmd`, so a future regression (or a
1452+
// Rust/Windows behavior change) is caught. Windows-only.
14351453
#[cfg(windows)]
14361454
#[tokio::test]
1437-
#[ignore = "manual Windows discovery probe; run with --ignored --nocapture"]
1438-
async fn win_cmd_shim_execution_probe() {
1455+
async fn win_run_bounded_binary_executes_cmd_shim() {
14391456
let dir = tempfile::tempdir().expect("tempdir");
14401457
let shim = dir.path().join("opencode.cmd");
14411458
std::fs::write(&shim, "@echo off\r\necho probe-opencode 9.9.9\r\n").expect("write shim");
1442-
let shim_str = shim.to_string_lossy().to_string();
1443-
println!("\n[probe] shim at: {shim_str}");
1444-
1445-
// 1. What the REAL discovery path does: run_bounded_binary(<.cmd>, ...).
1446-
// It swallows the launch error, so a None here means "could not run".
1447-
let via_helper = run_bounded_binary(&shim_str, &["--version"]).await;
1448-
println!("[probe] run_bounded_binary(.cmd) -> {via_helper:?}");
1449-
1450-
// 2. Raw Command on the .cmd to expose the actual OS error kind.
1451-
let raw = tokio::process::Command::new(&shim_str)
1452-
.arg("--version")
1453-
.output()
1454-
.await;
1455-
match &raw {
1456-
Ok(o) => println!(
1457-
"[probe] raw Command(.cmd): launched status={} stdout={:?}",
1458-
o.status,
1459-
String::from_utf8_lossy(&o.stdout).trim()
1460-
),
1461-
Err(e) => println!(
1462-
"[probe] raw Command(.cmd): FAILED kind={:?} msg={e}",
1463-
e.kind()
1464-
),
1465-
}
1466-
1467-
// 3. The proposed fix path: cmd /C <.cmd>.
1468-
let via_cmd = tokio::process::Command::new("cmd")
1469-
.args(["/C", &shim_str, "--version"])
1470-
.output()
1471-
.await;
1472-
match &via_cmd {
1473-
Ok(o) => println!(
1474-
"[probe] cmd /C .cmd: launched status={} stdout={:?}",
1475-
o.status,
1476-
String::from_utf8_lossy(&o.stdout).trim()
1477-
),
1478-
Err(e) => println!("[probe] cmd /C .cmd: FAILED {e}"),
1479-
}
1459+
let out = run_bounded_binary(&shim.to_string_lossy(), &["--version"]).await;
1460+
assert_eq!(
1461+
out.as_deref().map(str::trim),
1462+
Some("probe-opencode 9.9.9"),
1463+
"run_bounded_binary must launch a .cmd shim directly (got {out:?})"
1464+
);
1465+
}
14801466

1481-
// 4. where.exe sanity: CI has full PATH, so this isolates the failure to
1482-
// execution rather than discovery (the GUI PATH-inheritance angle of
1483-
// #149 cannot be reproduced in a shell-launched CI runner).
1484-
let where_out = tokio::process::Command::new("where.exe")
1485-
.arg("cmd")
1486-
.output()
1487-
.await;
1488-
println!(
1489-
"[probe] where.exe cmd -> {:?}",
1490-
where_out
1491-
.ok()
1492-
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
1467+
#[test]
1468+
fn windows_candidates_include_pnpm_global_bin() {
1469+
// #149: pnpm installs global bins at %LOCALAPPDATA%\pnpm\bin\<name>.cmd
1470+
// (and the store root). Both must be in the candidate list so discovery
1471+
// finds a pnpm-installed opencode that is never on a GUI process's PATH.
1472+
let list = windows_opencode_candidates(
1473+
Some("C:\\Users\\me"),
1474+
Some("C:\\Users\\me\\AppData\\Roaming"),
1475+
Some("C:\\Users\\me\\AppData\\Local"),
1476+
);
1477+
assert!(
1478+
list.iter()
1479+
.any(|c| c == "C:\\Users\\me\\AppData\\Local\\pnpm\\bin\\opencode.cmd"),
1480+
"missing pnpm bin-link candidate; got {list:?}"
1481+
);
1482+
assert!(
1483+
list.iter()
1484+
.any(|c| c == "C:\\Users\\me\\AppData\\Local\\pnpm\\opencode.cmd"),
1485+
"missing pnpm store-root candidate; got {list:?}"
14931486
);
1494-
println!("[probe] done\n");
1487+
// Existing coverage stays: npm, winget, scoop, the stock installer, and
1488+
// the bare-name PATH fallbacks.
1489+
assert!(list
1490+
.iter()
1491+
.any(|c| c == "C:\\Users\\me\\AppData\\Roaming\\npm\\opencode.cmd"));
1492+
assert!(list.contains(&"opencode".to_string()));
1493+
}
1494+
1495+
#[test]
1496+
fn windows_candidates_skip_empty_env_vars() {
1497+
// Unset/empty env vars must not yield malformed leading-separator paths.
1498+
let list = windows_opencode_candidates(None, Some(""), Some("C:\\L"));
1499+
assert!(list.iter().all(|c| !c.starts_with('\\')), "got {list:?}");
1500+
assert!(list.iter().any(|c| c == "C:\\L\\pnpm\\bin\\opencode.cmd"));
1501+
// No APPDATA -> no npm candidates.
1502+
assert!(list.iter().all(|c| !c.contains("\\npm\\")));
14951503
}
14961504

14971505
#[test]

0 commit comments

Comments
 (0)