Skip to content

Commit da1ffb4

Browse files
committed
Ensure PHP interception works on OSX
On OSX, we can't prepend to PATH, because a script called path_helpers prepends all standard paths to the PATH in every new shell, effectively overwriting us. To fix this, we edit each of the common shell startup config files, which gives us a hook into shells _after_ this happens. The hooks are behind an env var flag (HTTP_TOOLKIT_ACTIVE), so have no effect the rest of the time, and are cleared up when the terminal closes (n.b. on OSX we get proper process exit events that make this work)
1 parent 2dbf583 commit da1ffb4

File tree

1 file changed

+121
-3
lines changed

1 file changed

+121
-3
lines changed

src/interceptors/fresh-terminal.ts

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as _ from 'lodash';
22
import * as fs from 'fs';
3+
import * as os from 'os';
34
import * as path from 'path';
45
import * as util from 'util';
56
import { spawn, ChildProcess, SpawnOptions } from 'child_process';
@@ -20,6 +21,8 @@ const commandExists = (path: string): Promise<boolean> => ensureCommandExists(pa
2021

2122
const DEFAULT_GIT_BASH_PATH = 'C:/Program Files/git/git-bash.exe';
2223
const 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

2427
interface 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 = /.*\$HTTP_TOOLKIT_ACTIVE.*/;
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+
75182
const terminals: _.Dictionary<ChildProcess[] | undefined> = {}
76183

77184
export 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

Comments
 (0)