1+ import fs from 'fs'
12import os from 'os'
23
34import open from 'open'
5+ import { isWsl , powerShellPathFromWsl } from 'wsl-utils'
46
57import { getCliEnv } from './env'
68import { logger } from './logger'
@@ -11,12 +13,30 @@ import { logger } from './logger'
1113 * On headless Linux (no DISPLAY or WAYLAND_DISPLAY), calling `open()` spawns
1214 * `xdg-open` which can crash the entire process — even inside a try/catch —
1315 * because the child process may trigger fatal signals. This wrapper detects
14- * headless environments and skips the call entirely.
16+ * headless environments and skips the call entirely. WSL is exempt: there
17+ * `open()` goes through powershell.exe, which needs no display.
18+ *
19+ * On WSL, `open()` spawns powershell.exe from the Windows mount. If Windows
20+ * interop is disabled (no access to /mnt/c), that spawn fails with ENOENT.
21+ * Under Bun the failure is delivered before the `open()` promise resolves and
22+ * cannot be caught from here — not by try/catch, and not by an 'error'
23+ * listener attached afterward — so the only reliable defense is checking that
24+ * powershell.exe exists before calling `open()`. `wsl-utils` is what `open`
25+ * itself uses to build the path, so the check matches its behavior exactly.
1526 *
1627 * @returns `true` if the browser was (likely) opened, `false` if skipped.
1728 */
1829export async function safeOpen ( url : string ) : Promise < boolean > {
19- if ( os . platform ( ) === 'linux' ) {
30+ if ( isWsl ) {
31+ const powershellPath = await powerShellPathFromWsl ( )
32+ if ( ! fs . existsSync ( powershellPath ) ) {
33+ logger . warn (
34+ { powershellPath } ,
35+ 'WSL detected but powershell.exe is not accessible (Windows interop disabled?). Skipping browser open.' ,
36+ )
37+ return false
38+ }
39+ } else if ( os . platform ( ) === 'linux' ) {
2040 const env = getCliEnv ( )
2141 const hasDisplay = Boolean ( env . DISPLAY || env . WAYLAND_DISPLAY )
2242 if ( ! hasDisplay ) {
@@ -28,7 +48,13 @@ export async function safeOpen(url: string): Promise<boolean> {
2848 }
2949
3050 try {
31- await open ( url )
51+ const subprocess = await open ( url )
52+ // With the default `wait: false`, spawn failures can surface on the
53+ // child's 'error' event after the promise resolves; without a listener
54+ // they become uncaught exceptions that kill the process.
55+ subprocess . once ( 'error' , ( err ) => {
56+ logger . error ( err , 'Failed to open browser' )
57+ } )
3258 return true
3359 } catch ( err ) {
3460 logger . error ( err , 'Failed to open browser' )
0 commit comments