@@ -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+
690709fn opencode_desktop_settings_paths (
691710 platform : DesktopPlatform ,
692711 env : & OpencodeDesktopEnv ,
@@ -1278,8 +1297,8 @@ pub fn get_db_health(state: State<'_, AppState>) -> db::DbHealth {
12781297mod 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 \n echo 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