11import { execFileSync } from "node:child_process" ;
2+ import { homedir } from "node:os" ;
23
34const PATH_CAPTURE_START = "__T3CODE_PATH_START__" ;
45const PATH_CAPTURE_END = "__T3CODE_PATH_END__" ;
@@ -7,12 +8,20 @@ const PATH_CAPTURE_COMMAND = [
78 "printenv PATH" ,
89 `printf '%s\n' '${ PATH_CAPTURE_END } '` ,
910] . join ( "; " ) ;
11+ const LOGIN_SHELL_TIMEOUT_MS = 750 ;
12+ const PATH_REPAIR_DEADLINE_MS = 2_000 ;
13+ const LOGIN_SHELL_ARG_SETS = [
14+ [ "-ilc" , PATH_CAPTURE_COMMAND ] ,
15+ [ "-lc" , PATH_CAPTURE_COMMAND ] ,
16+ ] as const ;
1017
1118type ExecFileSyncLike = (
1219 file : string ,
1320 args : ReadonlyArray < string > ,
1421 options : { encoding : "utf8" ; timeout : number } ,
1522) => string ;
23+ type LoginShellErrorReporter = ( shell : string , args : ReadonlyArray < string > , error : unknown ) => void ;
24+ type ShellPathResolveErrorReporter = ( shell : string , error : unknown ) => void ;
1625
1726export function extractPathFromShellOutput ( output : string ) : string | null {
1827 const startIndex = output . indexOf ( PATH_CAPTURE_START ) ;
@@ -29,10 +38,123 @@ export function extractPathFromShellOutput(output: string): string | null {
2938export function readPathFromLoginShell (
3039 shell : string ,
3140 execFile : ExecFileSyncLike = execFileSync ,
41+ onError ?: LoginShellErrorReporter ,
3242) : string | undefined {
33- const output = execFile ( shell , [ "-ilc" , PATH_CAPTURE_COMMAND ] , {
34- encoding : "utf8" ,
35- timeout : 5000 ,
36- } ) ;
37- return extractPathFromShellOutput ( output ) ?? undefined ;
43+ let lastError : unknown ;
44+ for ( const args of LOGIN_SHELL_ARG_SETS ) {
45+ try {
46+ const output = execFile ( shell , args , {
47+ encoding : "utf8" ,
48+ timeout : LOGIN_SHELL_TIMEOUT_MS ,
49+ } ) ;
50+ const resolvedPath = extractPathFromShellOutput ( output ) ?? undefined ;
51+ if ( resolvedPath ) {
52+ return resolvedPath ;
53+ }
54+ } catch ( error ) {
55+ lastError = error ;
56+ onError ?.( shell , args , error ) ;
57+ }
58+ }
59+
60+ if ( lastError ) {
61+ throw lastError ;
62+ }
63+ return undefined ;
64+ }
65+
66+ function uniqueShellCandidates ( candidates : ReadonlyArray < string | undefined > ) : string [ ] {
67+ const unique = new Set < string > ( ) ;
68+
69+ for ( const candidate of candidates ) {
70+ if ( typeof candidate !== "string" ) continue ;
71+ const normalized = candidate . trim ( ) ;
72+ if ( normalized . length === 0 || unique . has ( normalized ) ) continue ;
73+ unique . add ( normalized ) ;
74+ }
75+
76+ return [ ...unique ] ;
77+ }
78+
79+ export function defaultShellCandidates ( platform = process . platform ) : string [ ] {
80+ if ( platform === "linux" ) {
81+ return uniqueShellCandidates ( [ process . env . SHELL , "/bin/sh" ] ) ;
82+ }
83+
84+ if ( platform === "darwin" ) {
85+ return uniqueShellCandidates ( [ process . env . SHELL , "/bin/zsh" , "/bin/bash" ] ) ;
86+ }
87+
88+ return uniqueShellCandidates ( [
89+ process . env . SHELL ,
90+ "/bin/zsh" ,
91+ "/usr/bin/zsh" ,
92+ "/bin/bash" ,
93+ "/usr/bin/bash" ,
94+ ] ) ;
95+ }
96+
97+ const defaultShellPathErrorReporter : ShellPathResolveErrorReporter | undefined =
98+ process . env . T3CODE_DEBUG_SHELL_PATH === "1"
99+ ? ( shell , error ) => {
100+ const message = error instanceof Error ? error . message : String ( error ) ;
101+ console . warn ( `[shell] PATH resolution failed for ${ shell } : ${ message } ` ) ;
102+ }
103+ : undefined ;
104+
105+ export function resolvePathFromLoginShells (
106+ shells : ReadonlyArray < string > ,
107+ execFile : ExecFileSyncLike = execFileSync ,
108+ onError : ShellPathResolveErrorReporter | undefined = defaultShellPathErrorReporter ,
109+ ) : string | undefined {
110+ const deadline = Date . now ( ) + PATH_REPAIR_DEADLINE_MS ;
111+
112+ for ( const shell of shells ) {
113+ if ( Date . now ( ) >= deadline ) {
114+ return undefined ;
115+ }
116+
117+ try {
118+ const result = readPathFromLoginShell ( shell , execFile , ( _failedShell , _args , error ) => {
119+ onError ?.( shell , error ) ;
120+ } ) ;
121+ if ( result ) {
122+ return result ;
123+ }
124+ } catch {
125+ // Per-attempt failures are already reported via onError when enabled.
126+ }
127+ }
128+
129+ return undefined ;
130+ }
131+
132+ function pathEntries ( pathValue : string | undefined ) : Set < string > {
133+ return new Set (
134+ ( pathValue ?? "" )
135+ . split ( ":" )
136+ . map ( ( entry ) => entry . trim ( ) )
137+ . filter ( ( entry ) => entry . length > 0 ) ,
138+ ) ;
139+ }
140+
141+ export function shouldRepairPath (
142+ platform = process . platform ,
143+ pathValue = process . env . PATH ,
144+ homePath = process . env . HOME ?? homedir ( ) ,
145+ ) : boolean {
146+ if ( platform !== "darwin" && platform !== "linux" ) {
147+ return false ;
148+ }
149+
150+ const entries = pathEntries ( pathValue ) ;
151+ if ( entries . size === 0 ) {
152+ return true ;
153+ }
154+
155+ if ( platform === "darwin" ) {
156+ return ! entries . has ( "/opt/homebrew/bin" ) && ! entries . has ( "/usr/local/bin" ) ;
157+ }
158+
159+ return ! entries . has ( `${ homePath } /.local/bin` ) && ! entries . has ( "/usr/local/bin" ) ;
38160}
0 commit comments