11import * as _ from 'lodash' ;
22import * as fs from 'fs' ;
3+ import * as os from 'os' ;
34import * as path from 'path' ;
45import * as util from 'util' ;
56import { spawn , ChildProcess , SpawnOptions } from 'child_process' ;
@@ -20,6 +21,8 @@ const commandExists = (path: string): Promise<boolean> => ensureCommandExists(pa
2021
2122const DEFAULT_GIT_BASH_PATH = 'C:/Program Files/git/git-bash.exe' ;
2223const PATH_VAR_SEPARATOR = process . platform === 'win32' ? ';' : ':' ;
24+ const SHELL = ( process . env . SHELL || '' ) . split ( '/' ) . slice ( - 1 ) [ 0 ] ;
25+ const OVERRIDE_BIN_PATH = path . join ( __dirname , 'terminal-wrappers' ) ;
2326
2427interface SpawnArgs {
2528 command : string ;
@@ -72,6 +75,110 @@ const getTerminalCommand = _.memoize(async (): Promise<SpawnArgs | null> => {
7275 return null ;
7376} ) ;
7477
78+ // Works in bash, zsh, dash, ksh, sh (not fish)
79+ const SH_SHELL_PATH_CONFIG = `
80+ # Do not edit (all lines including $HTTP_TOOLKIT_ACTIVE will be removed automatically)
81+ if [ -n "$HTTP_TOOLKIT_ACTIVE" ]; then export PATH="${ OVERRIDE_BIN_PATH } :$PATH"; fi` ;
82+ const FISH_SHELL_PATH_CONFIG = `
83+ # Do not edit (all lines including $HTTP_TOOLKIT_ACTIVE will be removed automatically)
84+ [ -n "$HTTP_TOOLKIT_ACTIVE" ]; and set -x PATH "${ OVERRIDE_BIN_PATH } " $PATH;` ;
85+ // Used to remove these lines from the config later
86+ const SHELL_PATH_CONFIG_MATCHER = / .* \$ H T T P _ T O O L K I T _ A C T I V E .* / ;
87+
88+ const appendOrCreateFile = util . promisify ( fs . appendFile ) ;
89+ const appendToFirstExisting = async ( paths : string [ ] , forceWrite : boolean , contents : string ) => {
90+ for ( let path of paths ) {
91+ // Small race here, but end result is ok either way
92+ if ( await canAccess ( path ) ) {
93+ return appendOrCreateFile ( path , contents ) ;
94+ }
95+ }
96+
97+ if ( forceWrite ) {
98+ // If force write is set, write the last file anyway
99+ return appendOrCreateFile ( paths . slice ( - 1 ) [ 0 ] , contents ) ;
100+ }
101+ } ;
102+
103+ // Find the relevant user shell config file, add the above line to it, so that
104+ // shells launched with HTTP_TOOLKIT_ACTIVE set use the interception PATH.
105+ const editShellStartupScripts = async ( ) => {
106+ await resetShellStartupScripts ( ) ;
107+
108+ // .profile is used by Dash, Bash sometimes, and by Sh:
109+ appendOrCreateFile ( path . join ( os . homedir ( ) , '.profile' ) , SH_SHELL_PATH_CONFIG )
110+ . catch ( reportError ) ;
111+
112+ // Bash uses some other files by preference, if they exist:
113+ appendToFirstExisting (
114+ [
115+ path . join ( os . homedir ( ) , '.bash_profile' ) ,
116+ path . join ( os . homedir ( ) , '.bash_login' )
117+ ] ,
118+ false , // Do nothing if they don't exist - it falls back to .profile
119+ SH_SHELL_PATH_CONFIG
120+ ) . catch ( reportError ) ;
121+
122+ // Zsh has its own files (both are actually used)
123+ appendToFirstExisting (
124+ [
125+ path . join ( os . homedir ( ) , '.zshenv' ) ,
126+ path . join ( os . homedir ( ) , '.zshrc' )
127+ ] ,
128+ SHELL === 'zsh' , // If you use zsh, we _always_ write a config file
129+ SH_SHELL_PATH_CONFIG
130+ ) . catch ( reportError ) ;
131+
132+ // Fish always uses the same config file
133+ appendToFirstExisting (
134+ [
135+ path . join ( os . homedir ( ) , '.config' , 'fish' , 'config.fish' ) ,
136+ ] ,
137+ SHELL === 'fish' || await canAccess ( path . join ( os . homedir ( ) , '.config' , 'fish' ) ) ,
138+ FISH_SHELL_PATH_CONFIG
139+ ) . catch ( reportError ) ;
140+ } ;
141+
142+ const readFile = util . promisify ( fs . readFile ) ;
143+ const writeFile = util . promisify ( fs . writeFile ) ;
144+ const renameFile = util . promisify ( fs . rename ) ;
145+ const removeMatchingInFile = async ( path : string , matcher : RegExp ) => {
146+ let fileLines : string [ ] ;
147+
148+ try {
149+ fileLines = ( await readFile ( path , 'utf8' ) ) . split ( '\n' ) ;
150+ } catch ( e ) {
151+ // Silently skip any files we can't read
152+ return ;
153+ }
154+
155+ // Drop all matching lines from the config file
156+ fileLines = fileLines . filter ( line => ! matcher . test ( line ) ) ;
157+ // Write & rename to ensure this is atomic, and avoid races here
158+ // as much as we reasonably can.
159+ const tempFile = path + Date . now ( ) + '.temp' ;
160+ await writeFile ( tempFile , fileLines . join ( '\n' ) ) ;
161+ return renameFile ( tempFile , path ) ;
162+ } ;
163+
164+ // Cleanup: strip our extra config line from all config files
165+ // Good to do for tidiness, not strictly necessary (the config does nothing
166+ // unless HTTP_TOOLKIT_ACTIVE is set anyway).
167+ const resetShellStartupScripts = ( ) => {
168+ // For each possible config file, remove our magic line, if present
169+ return Promise . all ( [
170+ path . join ( os . homedir ( ) , '.profile' ) ,
171+ path . join ( os . homedir ( ) , '.bash_profile' ) ,
172+ path . join ( os . homedir ( ) , '.bash_login' ) ,
173+ path . join ( os . homedir ( ) , '.zshenv' ) ,
174+ path . join ( os . homedir ( ) , '.zshrc' ) ,
175+ path . join ( os . homedir ( ) , '.config' , 'fish' , 'config.fish' ) ,
176+ ] . map ( ( configFile ) =>
177+ removeMatchingInFile ( configFile , SHELL_PATH_CONFIG_MATCHER )
178+ . catch ( reportError )
179+ ) ) ;
180+ } ;
181+
75182const terminals : _ . Dictionary < ChildProcess [ ] | undefined > = { }
76183
77184export class TerminalInterceptor implements Interceptor {
@@ -95,7 +202,16 @@ export class TerminalInterceptor implements Interceptor {
95202
96203 const { command, args, options } = terminalSpawnArgs ;
97204
98- const childProc = spawn ( command , args || [ ] , _ . assign ( options || { } , {
205+ // On OSX, our PATH override below doesn't work, because path_helper always runs and prepends
206+ // the real paths over the top. To fix this, we (very carefully!) rewrite shell startup
207+ // scripts, to reset PATH there.
208+ // This gets reset on exit, and is behind a flag so it won't affect other shells anyway.
209+ if ( process . platform === 'darwin' ) await editShellStartupScripts ( ) ;
210+
211+ const childProc = spawn (
212+ command ,
213+ ( args || [ ] ) ,
214+ _ . assign ( options || { } , {
99215 env : _ . assign ( { } , process . env , {
100216 'http_proxy' : `http://localhost:${ proxyPort } ` ,
101217 'HTTP_PROXY' : `http://localhost:${ proxyPort } ` ,
@@ -115,16 +231,18 @@ export class TerminalInterceptor implements Interceptor {
115231 // Trust cert for HTTPS requests from Git
116232 'GIT_SSL_CAINFO' : this . config . https . certPath ,
117233
234+ 'HTTP_TOOLKIT_ACTIVE' : 'true' ,
235+
118236 // Prepend our bin overrides into $PATH
119- 'PATH' : `${ path . join ( __dirname , 'terminal-wrappers' ) } ${ PATH_VAR_SEPARATOR } ${ process . env . PATH } `
237+ 'PATH' : `${ OVERRIDE_BIN_PATH } ${ PATH_VAR_SEPARATOR } ${ process . env . PATH } `
120238 } ) ,
121239 cwd : process . env . HOME || process . env . USERPROFILE
122240 } ) ) ;
123241
124242 terminals [ proxyPort ] = ( terminals [ proxyPort ] || [ ] ) . concat ( childProc ) ;
125243
126-
127244 const onTerminalClosed = ( ) => {
245+ if ( process . platform === 'darwin' ) resetShellStartupScripts ( ) ;
128246 terminals [ proxyPort ] = _ . reject ( terminals [ proxyPort ] , childProc ) ;
129247 } ;
130248 childProc . once ( 'exit' , onTerminalClosed ) ;
0 commit comments