blob: 8b11e48fbaba54c536cf122c4be383a327584967 [file] [log] [blame]
/* eslint-disable no-prototype-builtins */
/* eslint-disable prefer-const */
/*---------------------------------------------------------
* Copyright 2020 The Go Authors. All rights reserved.
* Licensed under the MIT License. See LICENSE in the project root for license information.
*--------------------------------------------------------*/
'use strict';
import cp = require('child_process');
import fs = require('fs');
import moment = require('moment');
import os = require('os');
import path = require('path');
import { promisify } from 'util';
import { getGoConfig, IsInCloudIDE } from './config';
import { toolInstallationEnvironment } from './goEnv';
import { logVerbose } from './goLogging';
import { addGoStatus, goEnvStatusbarItem, outputChannel, removeGoStatus } from './goStatus';
import { getFromGlobalState, getFromWorkspaceState, updateGlobalState, updateWorkspaceState } from './stateUtils';
import {
getBinPath,
getCheckForToolsUpdatesConfig,
getGoVersion,
getTempFilePath,
GoVersion,
rmdirRecursive
} from './util';
import {
correctBinname,
executableFileExists,
fixDriveCasingInWindows,
getBinPathFromEnvVar,
getCurrentGoRoot,
dirExists
} from './utils/pathUtils';
import vscode = require('vscode');
import WebRequest = require('web-request');
export class GoEnvironmentOption {
public static fromQuickPickItem({ description, label }: vscode.QuickPickItem): GoEnvironmentOption {
return new GoEnvironmentOption(description, label);
}
constructor(public binpath: string, public label: string) {}
public toQuickPickItem(): vscode.QuickPickItem {
return {
label: this.label,
description: this.binpath
};
}
}
export let terminalCreationListener: vscode.Disposable;
let environmentVariableCollection: vscode.EnvironmentVariableCollection;
export function setEnvironmentVariableCollection(env: vscode.EnvironmentVariableCollection) {
environmentVariableCollection = env;
}
// QuickPickItem names for chooseGoEnvironment menu.
const CLEAR_SELECTION = '$(clear-all) Clear selection';
const CHOOSE_FROM_FILE_BROWSER = '$(folder) Choose from file browser';
function canChooseGoEnvironment() {
// if there is no workspace, show GOROOT with message
if (!vscode.workspace.name) {
return { ok: false, reason: 'Switching Go version is not yet supported in single-file mode.' };
}
if (getGoConfig().get('goroot')) {
return { ok: false, reason: 'Switching Go version when "go.goroot" is set is unsupported.' };
}
if (process.env['GOROOT']) {
return { ok: false, reason: 'Switching Go version when process.env["GOROOT"] is set is unsupported.' };
}
return { ok: true };
}
/**
* Present a command palette menu to the user to select their go binary
*/
export async function chooseGoEnvironment() {
if (!goEnvStatusbarItem) {
return;
}
const { ok, reason } = canChooseGoEnvironment();
if (!ok) {
vscode.window.showInformationMessage(`GOROOT: ${getCurrentGoRoot()}. ${reason}`);
return;
}
// fetch default go and uninstalled go versions
let defaultOption: GoEnvironmentOption;
let uninstalledOptions: GoEnvironmentOption[];
let goSDKOptions: GoEnvironmentOption[];
try {
[defaultOption, uninstalledOptions, goSDKOptions] = await Promise.all([
getDefaultGoOption(),
fetchDownloadableGoVersions(),
getSDKGoOptions()
]);
} catch (e) {
vscode.window.showErrorMessage(e.message);
return;
}
// create quick pick items
const uninstalledQuickPicks = uninstalledOptions.map((op) => op.toQuickPickItem());
const defaultQuickPick = defaultOption ? [defaultOption.toQuickPickItem()] : [];
const goSDKQuickPicks = goSDKOptions.map((op) => op.toQuickPickItem());
// dedup options by eliminating duplicate paths (description)
const clearOption: vscode.QuickPickItem = { label: CLEAR_SELECTION };
const filePickerOption: vscode.QuickPickItem = {
label: CHOOSE_FROM_FILE_BROWSER,
description: 'Select the go binary to use'
};
// TODO(hyangah): Add separators after clearOption if github.com/microsoft/vscode#74967 is resolved.
const options = [
filePickerOption,
clearOption,
...defaultQuickPick,
...goSDKQuickPicks,
...uninstalledQuickPicks
].reduce((opts, nextOption) => {
if (opts.find((op) => op.description === nextOption.description || op.label === nextOption.label)) {
return opts;
}
return [...opts, nextOption];
}, [] as vscode.QuickPickItem[]);
// get user's selection, return if none was made
const selection = await vscode.window.showQuickPick<vscode.QuickPickItem>(options);
if (!selection) {
return;
}
// update currently selected go
try {
await setSelectedGo(GoEnvironmentOption.fromQuickPickItem(selection));
} catch (e) {
vscode.window.showErrorMessage(e.message);
}
}
/**
* update the selected go path and label in the workspace state
*/
export async function setSelectedGo(goOption: GoEnvironmentOption, promptReload = true): Promise<boolean> {
if (!goOption) {
return false;
}
// if the selected go version is not installed, install it
if (goOption.binpath?.startsWith('go get')) {
// start a loading indicator
await downloadGo(goOption);
} else if (goOption.label === CLEAR_SELECTION) {
if (!getSelectedGo()) {
return false; // do nothing.
}
await updateWorkspaceState('selectedGo', undefined);
} else if (goOption.label === CHOOSE_FROM_FILE_BROWSER) {
const currentGOROOT = getCurrentGoRoot();
const defaultUri = currentGOROOT ? vscode.Uri.file(path.join(currentGOROOT, 'bin')) : undefined;
const newGoUris = await vscode.window.showOpenDialog({
canSelectFiles: true,
canSelectFolders: false,
canSelectMany: false,
defaultUri
});
if (!newGoUris || newGoUris.length !== 1) {
return false;
}
const newGoBin = fixDriveCasingInWindows(newGoUris[0].fsPath);
const oldGoBin = fixDriveCasingInWindows(path.join(defaultUri.fsPath, correctBinname('go')));
if (newGoBin === oldGoBin) {
return false;
}
if (!executableFileExists(newGoBin)) {
vscode.window.showErrorMessage(`${newGoBin} is not an executable`);
return false;
}
const newGo = await getGoVersion(newGoBin);
if (!newGo || !newGo.isValid()) {
vscode.window.showErrorMessage(`failed to get "${newGoBin} version", invalid Go binary`);
return false;
}
await updateWorkspaceState('selectedGo', new GoEnvironmentOption(newGo.binaryPath, formatGoVersion(newGo)));
} else {
// check that the given binary is not already at the beginning of the PATH
const go = await getGoVersion();
if (!!go && (go.binaryPath === goOption.binpath || 'Go ' + go.format() === goOption.label)) {
return false;
}
await updateWorkspaceState('selectedGo', goOption);
}
// prompt the user to reload the window.
// promptReload defaults to true and should only be false for tests.
if (promptReload) {
const choice = await vscode.window.showInformationMessage(
'Please reload the window to finish applying Go version changes.',
'Reload Window'
);
if (choice === 'Reload Window') {
await vscode.commands.executeCommand('workbench.action.reloadWindow');
}
}
goEnvStatusbarItem.text = 'Go: reload required';
goEnvStatusbarItem.command = 'workbench.action.reloadWindow';
return true;
}
// downloadGo downloads the specified go version available in dl.golang.org.
async function downloadGo(goOption: GoEnvironmentOption) {
const execFile = promisify(cp.execFile);
await vscode.window.withProgress(
{
title: `Downloading ${goOption.label}`,
location: vscode.ProgressLocation.Notification
},
async () => {
outputChannel.show();
outputChannel.clear();
outputChannel.appendLine('Finding Go executable for downloading');
const goExecutable = getBinPath('go');
if (!goExecutable) {
outputChannel.appendLine('Could not find Go executable.');
throw new Error('Could not find Go tool.');
}
// TODO(bcloud) dedup repeated logic below which comes from
// https://github.com/golang/vscode-go/blob/bc23fa854192d04200c8e4f74dca18d2c3021b46/src/goInstallTools.ts#L184
// Install tools in a temporary directory, to avoid altering go.mod files.
const mkdtemp = promisify(fs.mkdtemp);
const toolsTmpDir = await mkdtemp(getTempFilePath('go-tools-'));
let tmpGoModFile: string;
// Write a temporary go.mod file to avoid version conflicts.
tmpGoModFile = path.join(toolsTmpDir, 'go.mod');
const writeFile = promisify(fs.writeFile);
await writeFile(tmpGoModFile, 'module tools');
// use the current go executable to download the new version
const env = {
...toolInstallationEnvironment(),
GO111MODULE: 'on'
};
const [, ...args] = goOption.binpath.split(' ');
outputChannel.appendLine(`Running ${goExecutable} ${args.join(' ')}`);
try {
await execFile(goExecutable, args, {
env,
cwd: toolsTmpDir
});
} catch (getErr) {
outputChannel.appendLine(`Error finding Go: ${getErr}`);
throw new Error('Could not find Go version.');
}
// run `goX.X download`
const newExecutableName = args[1].split('/')[2];
const goXExecutable = getBinPath(newExecutableName);
outputChannel.appendLine(`Running: ${goXExecutable} download`);
try {
await execFile(goXExecutable, ['download'], { env, cwd: toolsTmpDir });
} catch (downloadErr) {
outputChannel.appendLine(`Error finishing installation: ${downloadErr}`);
throw new Error('Could not download Go version.');
}
outputChannel.appendLine('Finding newly downloaded Go');
const sdkPath = path.join(os.homedir(), 'sdk');
if (!(await dirExists(sdkPath))) {
outputChannel.appendLine(`SDK path does not exist: ${sdkPath}`);
throw new Error(`SDK path does not exist: ${sdkPath}`);
}
const readdir = promisify(fs.readdir);
const subdirs = await readdir(sdkPath);
const dir = subdirs.find((subdir) => subdir === newExecutableName);
if (!dir) {
outputChannel.appendLine('Could not find newly downloaded Go');
throw new Error('Could not install Go version.');
}
const binpath = path.join(sdkPath, dir, 'bin', correctBinname('go'));
const newOption = new GoEnvironmentOption(binpath, goOption.label);
await updateWorkspaceState('selectedGo', newOption);
// remove tmp directories
outputChannel.appendLine('Cleaning up...');
rmdirRecursive(toolsTmpDir);
outputChannel.appendLine('Success!');
}
);
}
// PATH value cached before addGoRuntimeBaseToPath modified.
let defaultPathEnv = '';
function pathEnvVarName(): string | undefined {
if (process.env.hasOwnProperty('PATH')) {
return 'PATH';
} else if (process.platform === 'win32' && process.env.hasOwnProperty('Path')) {
return 'Path';
} else {
return;
}
}
// addGoRuntimeBaseToPATH adds the given path to the front of the PATH environment variable.
// It removes duplicates.
// TODO: can we avoid changing PATH but utilize toolExecutionEnv?
export function addGoRuntimeBaseToPATH(newGoRuntimeBase: string) {
if (!newGoRuntimeBase) {
return;
}
const goCfg = getGoConfig();
if (!goCfg.get('terminal.activateEnvironment')) {
return;
}
const pathEnvVar = pathEnvVarName();
if (!pathEnvVar) {
logVerbose("couldn't find PATH property in process.env");
return;
}
if (!defaultPathEnv) {
// cache the default value
defaultPathEnv = <string>process.env[pathEnvVar];
}
logVerbose(`addGoRuntimeBase(${newGoRuntimeBase}) when PATH=${defaultPathEnv}`);
// calling this multiple times will override the previous value.
// environmentVariableCollection.clear();
if (process.platform !== 'darwin') {
environmentVariableCollection?.prepend(pathEnvVar, newGoRuntimeBase + path.delimiter);
} else {
// When '-l' or '--login' flags are set, the terminal will invoke a login
// shell again and the paths from the user's login shell will be prepended
// again in front of the path mutated by environmentVariableCollection API.
// That causes the mutated path to be ignored which we don't want.
// So, let's not use the API if those flags are set, but go with the old way
// -- i.e. send the export shell command.
// See the open issue and the discussion here:
// https://github.com/microsoft/vscode/issues/99878#issuecomment-642808852
const terminalShellArgs = <string[]>(
(vscode.workspace.getConfiguration('terminal.integrated.shellArgs').get('osx') || [])
);
if (terminalShellArgs.includes('-l') || terminalShellArgs.includes('--login')) {
for (const term of vscode.window.terminals) {
updateIntegratedTerminal(term);
}
if (terminalCreationListener) {
terminalCreationListener.dispose();
}
terminalCreationListener = vscode.window.onDidOpenTerminal(updateIntegratedTerminal);
} else {
environmentVariableCollection?.prepend(pathEnvVar, newGoRuntimeBase + path.delimiter);
}
}
let pathVars = defaultPathEnv.split(path.delimiter);
pathVars = pathVars.filter((p) => p !== newGoRuntimeBase);
pathVars.unshift(newGoRuntimeBase);
process.env[pathEnvVar] = pathVars.join(path.delimiter);
}
// Clear terminal PATH environment modification previously installed
// using addGoRuntimeBaseToPATH.
// In particular, changes to vscode.EnvironmentVariableCollection persist across
// vscode sessions, so when we decide not to mutate PATH, we need to clear
// the preexisting changes.
export function clearGoRuntimeBaseFromPATH() {
if (terminalCreationListener) {
const l = terminalCreationListener;
terminalCreationListener = undefined;
l.dispose();
}
const pathEnvVar = pathEnvVarName();
if (!pathEnvVar) {
logVerbose("couldn't find PATH property in process.env");
return;
}
environmentVariableCollection?.delete(pathEnvVar);
}
/**
* update the PATH variable in the given terminal to default to the currently selected Go
*/
export async function updateIntegratedTerminal(terminal: vscode.Terminal): Promise<void> {
if (!terminal) {
return;
}
const gorootBin = path.join(getCurrentGoRoot(), 'bin');
const defaultGoRuntime = getBinPathFromEnvVar('go', defaultPathEnv, false);
if (defaultGoRuntime && gorootBin === path.dirname(defaultGoRuntime)) {
return;
}
// append the goroot to the beginning of the PATH so it takes precedence
// TODO: add support for more terminal names
// this assumes all non-windows shells are bash-like.
if (terminal.name.toLowerCase() === 'cmd') {
terminal.sendText(`set PATH=${gorootBin};%Path%`, true);
terminal.sendText('cls');
} else if (['powershell', 'pwsh'].includes(terminal.name.toLowerCase())) {
terminal.sendText(`$env:Path="${gorootBin};$env:Path"`, true);
terminal.sendText('clear');
} else if (terminal.name.toLowerCase() === 'fish') {
terminal.sendText(`set -gx PATH ${gorootBin} $PATH`);
terminal.sendText('clear');
} else if (['bash', 'sh', 'zsh', 'ksh'].includes(terminal.name.toLowerCase())) {
terminal.sendText(`export PATH=${gorootBin}:$PATH`, true);
terminal.sendText('clear');
}
}
/**
* retreive the current selected Go from the workspace state
*/
export function getSelectedGo(): GoEnvironmentOption {
return getFromWorkspaceState('selectedGo');
}
/**
* return reference to the statusbar item
*/
export function getGoEnvironmentStatusbarItem(): vscode.StatusBarItem {
return goEnvStatusbarItem;
}
export function formatGoVersion(version?: GoVersion): string {
if (!version || !version.isValid()) {
return 'Go (unknown)';
}
const versionStr = version.format(true);
const versionWords = versionStr.split(' ');
if (versionWords.length > 1 && versionWords[0] === 'devel') {
// go devel +hash or go devel go1.17-hash
return versionWords[1].startsWith('go') ? `Go ${versionWords[1].slice(2)}` : `Go ${versionWords[1]}`;
} else {
return `Go ${versionWords[0]}`;
}
}
async function getSDKGoOptions(): Promise<GoEnvironmentOption[]> {
// get list of Go versions
const sdkPath = path.join(os.homedir(), 'sdk');
if (!(await dirExists(sdkPath))) {
return [];
}
const readdir = promisify(fs.readdir);
const subdirs = await readdir(sdkPath);
// the dir happens to be the version, which will be used as the label
// the path is assembled and used as the description
return subdirs.map(
(dir: string) =>
new GoEnvironmentOption(path.join(sdkPath, dir, 'bin', correctBinname('go')), dir.replace('go', 'Go '))
);
}
export async function getDefaultGoOption(): Promise<GoEnvironmentOption | undefined> {
// make goroot default to go.goroot
const goroot = getCurrentGoRoot();
if (!goroot) {
return undefined;
}
// set Go version and command
const version = await getGoVersion();
return new GoEnvironmentOption(path.join(goroot, 'bin', correctBinname('go')), formatGoVersion(version));
}
/**
* make a web request to get versions of Go
*/
interface GoVersionWebResult {
version: string;
stable: boolean;
files: {
filename: string;
os: string;
arch: string;
version: string;
sha256: string;
size: number;
kind: string;
}[];
}
async function fetchDownloadableGoVersions(): Promise<GoEnvironmentOption[]> {
// fetch information about what Go versions are available to install
let webResults;
try {
webResults = await WebRequest.json<GoVersionWebResult[]>('https://golang.org/dl/?mode=json');
} catch (error) {
return [];
}
if (!webResults) {
return [];
}
// turn the web result into GoEnvironmentOption model
return webResults.reduce((opts, result: GoVersionWebResult) => {
// TODO: allow downloading from different sites
const dlPath = `go get golang.org/dl/${result.version}`;
const label = result.version.replace('go', 'Go ');
return [...opts, new GoEnvironmentOption(dlPath, label)];
}, []);
}
export const latestGoVersionKey = 'latestGoVersions';
const oneday = 60 * 60 * 24 * 1000; // 24 hours in milliseconds
export async function getLatestGoVersions(): Promise<GoEnvironmentOption[]> {
const timeout = oneday;
const now = moment.now();
let results: GoEnvironmentOption[];
// Check if we can use cached results
const cachedResults = getFromGlobalState(latestGoVersionKey);
if (cachedResults && now - cachedResults.timestamp < timeout) {
results = cachedResults.goVersions;
} else {
// fetch the latest supported Go versions
try {
// fetch the latest Go versions and cache the results
results = await fetchDownloadableGoVersions();
await updateGlobalState(latestGoVersionKey, {
timestamp: now,
goVersions: results
});
} catch (e) {
// hardcode the latest versions of Go in case golang.dl is unavailable
// TODO(hyangah): consider to remove these hardcoded versions and instead
// show error notification if necessary.
results = [
new GoEnvironmentOption('go get golang.org/dl/go1.17.1', 'Go 1.17.1'),
new GoEnvironmentOption('go get golang.org/dl/go1.16.8', 'Go 1.16.8')
];
}
}
return results;
}
const STATUS_BAR_ITEM_NAME = 'Go Notification';
const dismissedGoVersionUpdatesKey = 'dismissedGoVersionUpdates';
export async function offerToInstallLatestGoVersion() {
if (IsInCloudIDE) {
return;
}
const goConfig = getGoConfig();
const checkForUpdate = getCheckForToolsUpdatesConfig(goConfig);
if (checkForUpdate === 'off' || checkForUpdate === 'local') {
// 'proxy' or misconfiguration..
return;
}
let options = await getLatestGoVersions();
// filter out Go versions the user has already dismissed
let dismissedOptions: GoEnvironmentOption[];
dismissedOptions = await getFromGlobalState(dismissedGoVersionUpdatesKey);
if (dismissedOptions) {
options = options.filter((version) => !dismissedOptions.find((x) => x.label === version.label));
}
// compare to current go version.
const currentVersion = await getGoVersion();
if (currentVersion) {
options = options.filter((version) => currentVersion.lt(version.label));
}
// notify user that there is a newer version of Go available
if (options.length > 0) {
addGoStatus(
STATUS_BAR_ITEM_NAME,
'Go Update Available',
'go.promptforgoinstall',
'A newer version of Go is available'
);
vscode.commands.registerCommand('go.promptforgoinstall', () => {
const download = {
title: 'Download',
async command() {
await vscode.env.openExternal(vscode.Uri.parse('https://golang.org/dl/'));
}
};
const neverAgain = {
title: "Don't Show Again",
async command() {
// mark these versions as seen
dismissedOptions = await getFromGlobalState(dismissedGoVersionUpdatesKey);
if (!dismissedOptions) {
dismissedOptions = [];
}
options.forEach((version) => {
dismissedOptions.push(version);
});
await updateGlobalState(dismissedGoVersionUpdatesKey, dismissedOptions);
}
};
let versionsText: string;
if (options.length > 1) {
versionsText = `${options
.map((x) => x.label)
.reduce((prev, next) => {
return prev + ' and ' + next;
})} are available`;
} else {
versionsText = `${options[0].label} is available`;
}
vscode.window
.showInformationMessage(
`${versionsText}. You are currently using ${formatGoVersion(currentVersion)}.`,
download,
neverAgain
)
.then((selection) => {
// TODO: should we removeGoStatus if user has closed the notification
// without any action? It's kind of a feature now - without selecting
// neverAgain, user can hide this statusbar item.
removeGoStatus(STATUS_BAR_ITEM_NAME);
selection?.command();
});
});
}
}