1// Copyright 2023 The Pigweed Authors 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); you may not 4// use this file except in compliance with the License. You may obtain a copy of 5// the License at 6// 7// https://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12// License for the specific language governing permissions and limitations under 13// the License. 14 15import * as fs from 'fs'; 16import * as path from 'path'; 17import * as process from 'process'; 18import * as os from 'os'; 19import { randomBytes } from 'crypto'; 20 21// Convert `exec` from callback style to promise style. 22import { exec as cbExec } from 'child_process'; 23import util from 'node:util'; 24const exec = util.promisify(cbExec); 25 26import * as vscode from 'vscode'; 27 28import { vendoredBazeliskPath } from './bazel'; 29import logger from './logging'; 30import { bazel_executable, settings } from './settings/vscode'; 31 32type InitScript = 'activate' | 'bootstrap'; 33 34/** 35 * Generate the configuration to launch an activated or bootstrapped terminal. 36 * 37 * @param initScript The name of the script to be sourced on launch 38 * @returns Options to be provided to terminal launch functions 39 */ 40function getShellConfig( 41 initScript: InitScript = 'activate', 42): vscode.TerminalOptions { 43 const shell = settings.terminalShell(); 44 45 return { 46 name: 'Pigweed Terminal', 47 shellPath: shell, 48 shellArgs: ['-c', `. ./${initScript}.sh; exec ${shell} -i`], 49 }; 50} 51 52/** 53 * Launch an activated terminal. 54 */ 55export function launchTerminal() { 56 const shellConfig = getShellConfig(); 57 logger.info(`Launching activated terminal with: ${shellConfig.shellPath}`); 58 vscode.window.createTerminal(shellConfig).show(); 59} 60 61/** 62 * Launch a activated terminal by bootstrapping it. 63 */ 64export function launchBootstrapTerminal() { 65 const shellConfig = getShellConfig(); 66 logger.info(`Launching bootstrapepd terminal with: ${shellConfig.shellPath}`); 67 vscode.window.createTerminal(getShellConfig('bootstrap')).show(); 68} 69 70/** 71 * Get the type of shell running in an integrated terminal, e.g. bash, zsh, etc. 72 * 73 * This is a bit roundabout; it grabs the shell pid because that seems to be 74 * the only useful piece of information we can get from the VSC API. Then we 75 * use `ps` to find the name of the binary associated with that pid. 76 */ 77async function getShellTypeFromTerminal( 78 terminal: vscode.Terminal, 79): Promise<string | undefined> { 80 const pid = await terminal.processId; 81 82 if (!pid) { 83 logger.error('Terminal has no pid'); 84 return; 85 } 86 87 logger.info(`Searching for shell with pid=${pid}`); 88 89 try { 90 const { stdout } = await exec(`ps -p ${pid} -o comm=`); 91 const processName = stdout.trim(); 92 93 if (processName) { 94 logger.info(`Found shell process: ${processName}`); 95 return path.basename(processName); 96 } 97 } catch (error) { 98 logger.info(`Could not get direct process info: ${error}`); 99 } 100 101 try { 102 let cmd: string; 103 104 switch (process.platform) { 105 case 'linux': { 106 try { 107 await exec('which pgrep'); 108 cmd = `pgrep -P ${pid} | xargs -r ps -o comm= -p`; 109 } catch { 110 cmd = `ps -e -o ppid=,comm= | grep "^[[:space:]]*${pid}"`; 111 } 112 break; 113 } 114 case 'darwin': { 115 cmd = `ps -A -o ppid=,pid=,comm= | grep "^[[:space:]]*${pid}"`; 116 break; 117 } 118 default: { 119 logger.error(`Platform not currently supported: ${process.platform}`); 120 return; 121 } 122 } 123 124 const { stdout } = await exec(cmd); 125 126 const processes = stdout 127 .split('\n') 128 .map((line) => line.trim()) 129 .filter((line) => line.length > 0); 130 131 // Find the shell process by pid and extract the process name 132 const shellProcesses = processes.filter((proc) => 133 /(?:sh|dash|bash|ksh|ash|zsh|fish)$/.test(proc), 134 ); 135 136 if (shellProcesses.length > 0) { 137 const shellName = path.basename( 138 shellProcesses[0].split(/\s+/).pop() || '', 139 ); 140 logger.info(`Found shell process: ${shellName}`); 141 return shellName; 142 } 143 144 logger.error(`Could not find process with pid=${pid}`); 145 logger.info(`Process tree: ${processes.join(', ')}`); 146 } catch (error) { 147 logger.error(`Failed to inspect process tree: ${error}`); 148 } 149} 150 151/** Prepend the path to Bazelisk into the active terminal's path. */ 152export async function patchBazeliskIntoTerminalPath( 153 terminal?: vscode.Terminal, 154): Promise<void> { 155 const bazeliskPath = bazel_executable.get() ?? vendoredBazeliskPath(); 156 157 if (!bazeliskPath) { 158 logger.error( 159 "Couldn't activate Bazelisk in terminal because none could be found", 160 ); 161 return; 162 } 163 164 // When using the vendored Bazelisk, the binary name won't be `bazelisk` -- 165 // it will be something like `bazelisk-darwin_arm64`. But the user expects 166 // to just run `bazelisk` in the terminal. So while this is not entirely 167 // ideal, we just create a symlink in the same directory if the binary name 168 // isn't plain `bazelisk`. 169 if (path.basename(bazeliskPath) !== 'bazelisk') { 170 try { 171 fs.symlink( 172 bazeliskPath, 173 path.join(path.dirname(bazeliskPath), 'bazelisk'), 174 (error) => { 175 const message = error 176 ? `${error.errno} ${error.message}` 177 : 'unknown error'; 178 throw new Error(message); 179 }, 180 ); 181 } catch (error: unknown) { 182 logger.error(`Failed to create Bazelisk symlink for ${bazeliskPath}`); 183 return; 184 } 185 } 186 187 // Should grab the currently active terminal or most recently active, if a 188 // specific terminal reference wasn't provided. 189 terminal = terminal ?? vscode.window.activeTerminal; 190 191 // If there's no terminal, create one 192 if (!terminal) { 193 terminal = vscode.window.createTerminal(); 194 } 195 196 if (!terminal) { 197 logger.error( 198 "Couldn't activate Bazelisk in terminal because no terminals could be found", 199 ); 200 return; 201 } 202 203 const shellType = await getShellTypeFromTerminal(terminal); 204 205 let cmd: string; 206 207 switch (shellType) { 208 case 'fish': { 209 cmd = `set -x --prepend PATH "${path.dirname(bazeliskPath)}"`; 210 break; 211 } 212 default: { 213 cmd = `export PATH="${path.dirname(bazeliskPath)}:$\{PATH}"`; 214 break; 215 } 216 } 217 218 logger.info(`Patching Bazelisk path into ${shellType} terminal`); 219 terminal.sendText(cmd, true); 220 terminal.show(); 221} 222 223/** 224 * This method runs the given commands in the active terminal 225 * and returns the output text. Currently, the API does not 226 * support reading terminal buffer so instead, we pipe output 227 * to a temp text file and then read it out once execution 228 * has completed. 229 */ 230export async function executeInTerminalAndGetStdout( 231 cmd: string, 232): Promise<string> { 233 const terminal = vscode.window.activeTerminal; 234 235 if (!terminal) { 236 throw new Error('No active terminal found.'); 237 } 238 239 const tmpDir = os.tmpdir(); 240 const randomOutputFileName = `vscode-terminal-output-${randomBytes( 241 8, 242 ).toString('hex')}.txt`; 243 const randomDoneFileName = `vscode-terminal-done-${randomBytes(8).toString( 244 'hex', 245 )}.txt`; 246 const tmpOutputFilePath = path.join(tmpDir, randomOutputFileName); 247 const tmpDoneFilePath = path.join(tmpDir, randomDoneFileName); 248 249 try { 250 // Construct the command to redirect output to the temp file and then touch the done file 251 const commandToExecute = `${cmd} &> ${tmpOutputFilePath} && touch ${tmpDoneFilePath}\n`; 252 253 terminal.sendText(commandToExecute); 254 255 // Wait for the done file to exist 256 while (!fs.existsSync(tmpDoneFilePath)) { 257 await new Promise((resolve) => setTimeout(resolve, 10)); 258 } 259 260 const output = fs.readFileSync(tmpOutputFilePath, 'utf-8'); 261 return output; 262 } catch (error) { 263 console.error('Error during command execution:', error); 264 throw error; 265 } finally { 266 // Delete the temporary files 267 fs.unlinkSync(tmpOutputFilePath); 268 fs.unlinkSync(tmpDoneFilePath); 269 } 270} 271