• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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