diff --git a/.vsts-ci/templates/ci-general.yml b/.vsts-ci/templates/ci-general.yml index f759d5838d..4cae1126e5 100644 --- a/.vsts-ci/templates/ci-general.yml +++ b/.vsts-ci/templates/ci-general.yml @@ -70,6 +70,7 @@ steps: inputs: targetType: inline script: | + Get-ChildItem env: Get-Module -ListAvailable Pester Install-Module InvokeBuild -Scope CurrentUser -Force Install-Module platyPS -Scope CurrentUser -Force diff --git a/src/features/Console.ts b/src/features/Console.ts index 6569fe1b9e..445bafa5fc 100644 --- a/src/features/Console.ts +++ b/src/features/Console.ts @@ -5,7 +5,7 @@ import vscode = require("vscode"); import { NotificationType, RequestType } from "vscode-languageclient"; import { LanguageClient } from "vscode-languageclient/node"; import { ICheckboxQuickPickItem, showCheckboxQuickPick } from "../controls/checkboxQuickPick"; -import { Logger } from "../logging"; +import { ILogger } from "../logging"; import { getSettings } from "../settings"; import { LanguageClientConsumer } from "../languageClientConsumer"; @@ -170,7 +170,7 @@ export class ConsoleFeature extends LanguageClientConsumer { private commands: vscode.Disposable[]; private handlers: vscode.Disposable[] = []; - constructor(private logger: Logger) { + constructor(private logger: ILogger) { super(); this.commands = [ vscode.commands.registerCommand("PowerShell.RunSelection", async () => { diff --git a/src/features/DebugSession.ts b/src/features/DebugSession.ts index db79bce515..fddcba4c8c 100644 --- a/src/features/DebugSession.ts +++ b/src/features/DebugSession.ts @@ -12,7 +12,7 @@ import { getPlatformDetails, OperatingSystem } from "../platform"; import { PowerShellProcess } from "../process"; import { IEditorServicesSessionDetails, SessionManager, SessionStatus } from "../session"; import { getSettings } from "../settings"; -import { Logger } from "../logging"; +import { ILogger } from "../logging"; import { LanguageClientConsumer } from "../languageClientConsumer"; import path = require("path"); import utils = require("../utils"); @@ -65,7 +65,7 @@ export class DebugSessionFeature extends LanguageClientConsumer }, }; - constructor(context: ExtensionContext, private sessionManager: SessionManager, private logger: Logger) { + constructor(context: ExtensionContext, private sessionManager: SessionManager, private logger: ILogger) { super(); // Register a debug configuration provider context.subscriptions.push(vscode.debug.registerDebugConfigurationProvider("PowerShell", this)); @@ -359,7 +359,7 @@ export class PickPSHostProcessFeature extends LanguageClientConsumer { private waitingForClientToken?: vscode.CancellationTokenSource; private getLanguageClientResolve?: (value: LanguageClient) => void; - constructor(private logger: Logger) { + constructor(private logger: ILogger) { super(); this.command = @@ -485,7 +485,7 @@ export class PickRunspaceFeature extends LanguageClientConsumer { private waitingForClientToken?: vscode.CancellationTokenSource; private getLanguageClientResolve?: (value: LanguageClient) => void; - constructor(private logger: Logger) { + constructor(private logger: ILogger) { super(); this.command = vscode.commands.registerCommand("PowerShell.PickRunspace", (processId) => { diff --git a/src/features/ExtensionCommands.ts b/src/features/ExtensionCommands.ts index 8399a24996..8a1862fe53 100644 --- a/src/features/ExtensionCommands.ts +++ b/src/features/ExtensionCommands.ts @@ -9,7 +9,7 @@ import { Position, Range, RequestType } from "vscode-languageclient"; import { LanguageClient } from "vscode-languageclient/node"; -import { Logger } from "../logging"; +import { ILogger } from "../logging"; import { getSettings, validateCwdSetting } from "../settings"; import { LanguageClientConsumer } from "../languageClientConsumer"; @@ -149,7 +149,7 @@ export class ExtensionCommandsFeature extends LanguageClientConsumer { private handlers: vscode.Disposable[] = []; private extensionCommands: IExtensionCommand[] = []; - constructor(private logger: Logger) { + constructor(private logger: ILogger) { super(); this.commands = [ vscode.commands.registerCommand("PowerShell.ShowAdditionalCommands", async () => { diff --git a/src/features/ExternalApi.ts b/src/features/ExternalApi.ts index 8c2f0af25b..63332bad70 100644 --- a/src/features/ExternalApi.ts +++ b/src/features/ExternalApi.ts @@ -4,7 +4,7 @@ import * as vscode from "vscode"; import { v4 as uuidv4 } from "uuid"; import { LanguageClientConsumer } from "../languageClientConsumer"; -import { Logger } from "../logging"; +import { ILogger } from "../logging"; import { SessionManager } from "../session"; export interface IExternalPowerShellDetails { @@ -39,7 +39,7 @@ export class ExternalApiFeature extends LanguageClientConsumer implements IPower constructor( private extensionContext: vscode.ExtensionContext, private sessionManager: SessionManager, - private logger: Logger) { + private logger: ILogger) { super(); } diff --git a/src/features/GetCommands.ts b/src/features/GetCommands.ts index 845ff9d586..e74846d546 100644 --- a/src/features/GetCommands.ts +++ b/src/features/GetCommands.ts @@ -4,7 +4,7 @@ import * as vscode from "vscode"; import { RequestType0 } from "vscode-languageclient"; import { LanguageClient } from "vscode-languageclient/node"; -import { Logger } from "../logging"; +import { ILogger } from "../logging"; import { LanguageClientConsumer } from "../languageClientConsumer"; import { getSettings } from "../settings"; @@ -30,7 +30,7 @@ export class GetCommandsFeature extends LanguageClientConsumer { private commandsExplorerProvider: CommandsExplorerProvider; private commandsExplorerTreeView: vscode.TreeView; - constructor(private logger: Logger) { + constructor(private logger: ILogger) { super(); this.commands = [ vscode.commands.registerCommand("PowerShell.RefreshCommandsExplorer", diff --git a/src/features/NewFileOrProject.ts b/src/features/NewFileOrProject.ts index fc284e804d..65e5f780a2 100644 --- a/src/features/NewFileOrProject.ts +++ b/src/features/NewFileOrProject.ts @@ -5,7 +5,7 @@ import vscode = require("vscode"); import { RequestType } from "vscode-languageclient"; import { LanguageClient } from "vscode-languageclient/node"; import { LanguageClientConsumer } from "../languageClientConsumer"; -import { Logger } from "../logging"; +import { ILogger } from "../logging"; export class NewFileOrProjectFeature extends LanguageClientConsumer { @@ -13,7 +13,7 @@ export class NewFileOrProjectFeature extends LanguageClientConsumer { private command: vscode.Disposable; private waitingForClientToken?: vscode.CancellationTokenSource; - constructor(private logger: Logger) { + constructor(private logger: ILogger) { super(); this.command = vscode.commands.registerCommand("PowerShell.NewProjectFromTemplate", async () => { diff --git a/src/features/PesterTests.ts b/src/features/PesterTests.ts index f515cd1857..b2ced26211 100644 --- a/src/features/PesterTests.ts +++ b/src/features/PesterTests.ts @@ -3,7 +3,7 @@ import * as path from "path"; import vscode = require("vscode"); -import { Logger } from "../logging"; +import { ILogger } from "../logging"; import { SessionManager } from "../session"; import { getSettings, chosenWorkspace, validateCwdSetting } from "../settings"; import utils = require("../utils"); @@ -17,7 +17,7 @@ export class PesterTestsFeature implements vscode.Disposable { private commands: vscode.Disposable[]; private invokePesterStubScriptPath: string; - constructor(private sessionManager: SessionManager, private logger: Logger) { + constructor(private sessionManager: SessionManager, private logger: ILogger) { this.invokePesterStubScriptPath = path.resolve(__dirname, "../modules/PowerShellEditorServices/InvokePesterStub.ps1"); this.commands = [ // File context-menu command - Run Pester Tests diff --git a/src/features/RunCode.ts b/src/features/RunCode.ts index bb6f6b44f5..b260bbf43c 100644 --- a/src/features/RunCode.ts +++ b/src/features/RunCode.ts @@ -3,7 +3,7 @@ import vscode = require("vscode"); import { SessionManager } from "../session"; -import { Logger } from "../logging"; +import { ILogger } from "../logging"; import { getSettings, chosenWorkspace, validateCwdSetting } from "../settings"; enum LaunchType { @@ -14,7 +14,7 @@ enum LaunchType { export class RunCodeFeature implements vscode.Disposable { private command: vscode.Disposable; - constructor(private sessionManager: SessionManager, private logger: Logger) { + constructor(private sessionManager: SessionManager, private logger: ILogger) { this.command = vscode.commands.registerCommand( "PowerShell.RunCode", async (runInDebugger: boolean, scriptToRun: string, args: string[]) => { diff --git a/src/features/UpdatePowerShell.ts b/src/features/UpdatePowerShell.ts index 206371191f..88c2c46e6d 100644 --- a/src/features/UpdatePowerShell.ts +++ b/src/features/UpdatePowerShell.ts @@ -3,92 +3,35 @@ import { spawn } from "child_process"; import * as fs from "fs"; // TODO: Remove, but it's for a stream. -import fetch, { RequestInit } from "node-fetch"; +import fetch from "node-fetch"; import * as os from "os"; import * as path from "path"; -import * as semver from "semver"; +import { SemVer } from "semver"; import * as stream from "stream"; import * as util from "util"; -import { MessageItem, ProgressLocation, window } from "vscode"; +import vscode = require("vscode"); -import { LanguageClient } from "vscode-languageclient/node"; -import { Logger } from "../logging"; -import { SessionManager } from "../session"; -import { changeSetting } from "../settings"; -import { isLinux, isMacOS, isWindows } from "../utils"; -import { EvaluateRequestType } from "./Console"; +import { ILogger } from "../logging"; +import { IPowerShellVersionDetails, SessionManager } from "../session"; +import { changeSetting, Settings } from "../settings"; +import { isWindows } from "../utils"; const streamPipeline = util.promisify(stream.pipeline); -const PowerShellGitHubReleasesUrl = - "https://api.github.com/repos/PowerShell/PowerShell/releases/latest"; -const PowerShellGitHubPreReleasesUrl = - "https://api.github.com/repos/PowerShell/PowerShell/releases"; - -export class GitHubReleaseInformation { - public static async FetchLatestRelease(preview: boolean): Promise { - const requestConfig: RequestInit = {}; - - // For CI. This prevents GitHub from rate limiting us. - if (process.env.PS_TEST_GITHUB_API_USERNAME && process.env.PS_TEST_GITHUB_API_PAT) { - const authHeaderValue = Buffer - .from(`${process.env.PS_TEST_GITHUB_API_USERNAME}:${process.env.PS_TEST_GITHUB_API_PAT}`) - .toString("base64"); - requestConfig.headers = { - Authorization: `Basic ${authHeaderValue}`, - }; - } - - // Fetch the latest PowerShell releases from GitHub. - const response = await fetch( - preview ? PowerShellGitHubPreReleasesUrl : PowerShellGitHubReleasesUrl, - requestConfig); - - if (!response.ok) { - const json = await response.json(); - throw new Error(json.message || json || "response was not ok."); - } - - // For preview, we grab all the releases and then grab the first prerelease. - const releaseJson = preview - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ? (await response.json()).find((release: any) => release.prerelease) - : await response.json(); - - return new GitHubReleaseInformation( - releaseJson.tag_name, releaseJson.assets); - } - - public version: semver.SemVer; - public isPreview = false; - // TODO: Establish a type for the assets. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public assets: any[]; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public constructor(version: string | semver.SemVer, assets: any[] = []) { - this.version = semver.parse(version)!; - - if (semver.prerelease(this.version)) { - this.isPreview = true; - } - - this.assets = assets; - } -} - -interface IUpdateMessageItem extends MessageItem { +interface IUpdateMessageItem extends vscode.MessageItem { id: number; } -export async function InvokePowerShellUpdateCheck( - sessionManager: SessionManager, - languageServerClient: LanguageClient, - localVersion: semver.SemVer, - arch: string, - release: GitHubReleaseInformation, - logger: Logger) { - const options: IUpdateMessageItem[] = [ +// This attempts to mirror PowerShell's `UpdatesNotification.cs` logic as much as +// possibly, documented at: +// https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_update_notifications +export class UpdatePowerShell { + private static LTSBuildInfoURL = "https://aka.ms/pwsh-buildinfo-lts"; + private static StableBuildInfoURL = "https://aka.ms/pwsh-buildinfo-stable"; + private static PreviewBuildInfoURL = "https://aka.ms/pwsh-buildinfo-preview"; + private static GitHubAPIReleaseURL = "https://api.github.com/repos/PowerShell/PowerShell/releases/tags/"; + private static GitHubWebReleaseURL = "https://github.com/PowerShell/PowerShell/releases/tag/"; + private static promptOptions: IUpdateMessageItem[] = [ { id: 0, title: "Yes", @@ -102,103 +45,229 @@ export async function InvokePowerShellUpdateCheck( title: "Don't Show Again", }, ]; - - // If our local version is up-to-date, we can return early. - if (semver.compare(localVersion, release.version) >= 0) { - logger.writeDiagnostic("PowerShell is up-to-date!"); - return; + private localVersion: SemVer; + private architecture: string; + + constructor( + private sessionManager: SessionManager, + private sessionSettings: Settings, + private logger: ILogger, + versionDetails: IPowerShellVersionDetails) { + // We use the commit field as it's like + // '7.3.0-preview.3-508-g07175ae0ff8eb7306fe0b0fc7d...' which translates + // to SemVer. The version handler in PSES handles Windows PowerShell and + // just returns the first three fields like '5.1.22621'. + this.localVersion = new SemVer(versionDetails.commit); + this.architecture = versionDetails.architecture.toLowerCase(); } - const commonText = `You have an old version of PowerShell (${localVersion.raw - }). The current latest release is ${release.version.raw - }.`; + private shouldCheckForUpdate(): boolean { + // Respect user setting. + if (!this.sessionSettings.promptToUpdatePowerShell) { + this.logger.writeDiagnostic("Setting 'promptToUpdatePowerShell' was false."); + return false; + } + + // Respect environment configuration. + if (process.env.POWERSHELL_UPDATECHECK?.toLowerCase() === "off") { + this.logger.writeDiagnostic("Environment variable 'POWERSHELL_UPDATECHECK' was 'Off'."); + return false; + } + + // Skip prompting when using Windows PowerShell for now. + if (this.localVersion.compare("6.0.0") === -1) { + // TODO: Maybe we should announce PowerShell Core? + this.logger.writeDiagnostic("Not offering to update Windows PowerShell."); + return false; + } + + if (this.localVersion.prerelease.length > 1) { + // Daily builds look like '7.3.0-daily20221206.1' which split to + // ['daily20221206', '1'] and development builds look like + // '7.3.0-preview.3-508-g07175...' which splits to ['preview', + // '3-508-g0717...']. The ellipsis is hiding a 40 char hash. + const daily = this.localVersion.prerelease[0].toString(); + const commit = this.localVersion.prerelease[1].toString(); + + // Skip if PowerShell is self-built, that is, this contains a commit hash. + if (commit.length >= 40) { + this.logger.writeDiagnostic("Not offering to update development build."); + return false; + } - // Cannot auto-install for Linux or Windows that isn't x86 or x64. - if (isLinux || (isWindows && (arch !== "X86" && arch !== "X64"))) { - void logger.writeAndShowInformation(`${commonText} We recommend updating to the latest version.`); - return; + // Skip if preview is a daily build. + if (daily.toLowerCase().startsWith("daily")) { + this.logger.writeDiagnostic("Not offering to update daily build."); + return false; + } + } + + // TODO: Check if network is available? + // TODO: Only check once a week. + this.logger.writeDiagnostic("Should check for PowerShell update."); + return true; } - const result = await window.showInformationMessage( - `${commonText} Would you like to update the version? ${isMacOS ? "(Homebrew is required on macOS)" - : "(This will close ALL pwsh terminals running in this Visual Studio Code session)" - }`, ...options); + private async getRemoteVersion(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error("Failed to get remote version!"); + } + // Looks like: + // { + // "ReleaseDate": "2022-10-20T22:01:38Z", + // "BlobName": "v7-2-7", + // "ReleaseTag": "v7.2.7" + // } + const data = await response.json(); + this.logger.writeDiagnostic(`From '${url}' received:\n${data}`); + return data.ReleaseTag; + } + + private async maybeGetNewRelease(): Promise { + if (!this.shouldCheckForUpdate()) { + return undefined; + } + + const tags: string[] = []; + if (process.env.POWERSHELL_UPDATECHECK?.toLowerCase() === "lts") { + // Only check for update to LTS. + this.logger.writeDiagnostic("Checking for LTS update."); + tags.push(await this.getRemoteVersion(UpdatePowerShell.LTSBuildInfoURL)); + } else { + // Check for update to stable. + this.logger.writeDiagnostic("Checking for stable update."); + tags.push(await this.getRemoteVersion(UpdatePowerShell.StableBuildInfoURL)); + + // Also check for a preview update. + if (this.localVersion.prerelease.length > 0) { + this.logger.writeDiagnostic("Checking for preview update."); + tags.push(await this.getRemoteVersion(UpdatePowerShell.PreviewBuildInfoURL)); + } + } + + for (const tag of tags) { + if (this.localVersion.compare(tag) === -1) { + this.logger.writeDiagnostic(`Offering to update PowerShell to ${tag}.`); + return tag; + } + } - // If the user cancels the notification. - if (!result) { - logger.writeDiagnostic("User canceled PowerShell update prompt."); - return; + return undefined; } - // Yes choice. - switch (result.id) { - // Yes choice. - case 0: - if (isWindows) { - const msiMatcher = arch === "X86" ? - "win-x86.msi" : "win-x64.msi"; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const asset = release.assets.filter((a: any) => a.name.indexOf(msiMatcher) >= 0)[0]; - const msiDownloadPath = path.join(os.tmpdir(), asset.name); - - const res = await fetch(asset.browser_download_url); - if (!res.ok) { - throw new Error("unable to fetch MSI"); + public async checkForUpdate() { + try { + const tag = await this.maybeGetNewRelease(); + if (tag) { + return await this.installUpdate(tag); } + } catch (err) { + // Best effort. This probably failed to fetch the data from GitHub. + this.logger.writeWarning(err instanceof Error ? err.message : "unknown"); + } + } + + private async openReleaseInBrowser(tag: string) { + const url = vscode.Uri.parse(UpdatePowerShell.GitHubWebReleaseURL + tag); + await vscode.env.openExternal(url); + } + + private async updateWindows(tag: string) { + let msiMatcher: string; + if (this.architecture === "x64") { + msiMatcher = "win-x64.msi"; + } else if (this.architecture === "x86") { + msiMatcher = "win-x86.msi"; + } else { + // We shouldn't get here, but do something sane anyway. + return this.openReleaseInBrowser(tag); + } + + let response = await fetch(UpdatePowerShell.GitHubAPIReleaseURL + tag); + if (!response.ok) { + throw new Error("Failed to fetch GitHub release info!"); + } + const release = await response.json(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const asset = release.assets.filter((a: any) => a.name.indexOf(msiMatcher) >= 0)[0]; + const msiDownloadPath = path.join(os.tmpdir(), asset.name); + + response = await fetch(asset.browser_download_url); + if (!response.ok) { + throw new Error("Failed to fetch MSI!"); + } - await window.withProgress({ - title: "Downloading PowerShell Installer...", - location: ProgressLocation.Notification, - cancellable: false, - }, - async () => { - // Streams the body of the request to a file. - await streamPipeline(res.body, fs.createWriteStream(msiDownloadPath)); - }); - - // Stop the session because Windows likes to hold on to files. - logger.writeDiagnostic("MSI downloaded, stopping session and closing terminals!"); - await sessionManager.stop(); - - // Close all terminals with the name "pwsh" in the current VS Code session. - // This will encourage folks to not close the instance of VS Code that spawned - // the MSI process. - for (const terminal of window.terminals) { - if (terminal.name === "pwsh") { - terminal.dispose(); - } + const progressOptions = { + title: "Downloading PowerShell Installer...", + location: vscode.ProgressLocation.Notification, + cancellable: false, + }; + // Streams the body of the request to a file. + await vscode.window.withProgress(progressOptions, + async () => { await streamPipeline(response.body, fs.createWriteStream(msiDownloadPath)); }); + + // Stop the session because Windows likes to hold on to files. + this.logger.writeDiagnostic("MSI downloaded, stopping session and closing terminals!"); + await this.sessionManager.stop(); + + // Close all terminals with the name "pwsh" in the current VS Code session. + // This will encourage folks to not close the instance of VS Code that spawned + // the MSI process. + for (const terminal of vscode.window.terminals) { + if (terminal.name === "pwsh") { + terminal.dispose(); } + } - // Invoke the MSI via cmd. - logger.writeDiagnostic(`Running '${msiDownloadPath}' to update PowerShell...`); - const msi = spawn("msiexec", ["/i", msiDownloadPath]); - - msi.on("close", () => { - // Now that the MSI is finished, restart the session. - logger.writeDiagnostic("MSI installation finished, restarting session."); - void sessionManager.start(); - fs.unlinkSync(msiDownloadPath); - }); - - } else if (isMacOS) { - const script = release.isPreview - ? "brew upgrade --cask powershell-preview" - : "brew upgrade --cask powershell"; - - logger.writeDiagnostic(`Running '${script}' to update PowerShell...`); - await languageServerClient.sendRequest(EvaluateRequestType, { - expression: script, - }); + // Invoke the MSI via cmd. + this.logger.writeDiagnostic(`Running '${msiDownloadPath}' to update PowerShell...`); + const msi = spawn("msiexec", ["/i", msiDownloadPath]); + + msi.on("close", () => { + // Now that the MSI is finished, restart the session. + this.logger.writeDiagnostic("MSI installation finished, restarting session."); + void this.sessionManager.start(); + fs.unlinkSync(msiDownloadPath); + }); + } + + private async installUpdate(tag: string) { + const releaseVersion = new SemVer(tag); + const result = await vscode.window.showInformationMessage( + `You have an old version of PowerShell (${this.localVersion.version}). The current latest release is ${releaseVersion.version}. + Would you like to update the version? ${isWindows + ? "This will close ALL pwsh terminals running in this VS Code session!" + : "We can't update you automatically, but we can open the latest release in your browser!" +}`, ...UpdatePowerShell.promptOptions); + + // If the user cancels the notification. + if (!result) { + this.logger.writeDiagnostic("User canceled PowerShell update prompt."); + return; } - break; + this.logger.writeDiagnostic(`User said '${UpdatePowerShell.promptOptions[result.id].title}'.`); - // Never choice. - case 2: - await changeSetting("promptToUpdatePowerShell", false, true, logger); - break; - default: - break; + switch (result.id) { + // Yes + case 0: + if (isWindows && (this.architecture === "x64" || this.architecture === "x86")) { + await this.updateWindows(tag); + } else { + await this.openReleaseInBrowser(tag); + } + break; + // Not Now + case 1: + break; + // Don't Show Again + case 2: + await changeSetting("promptToUpdatePowerShell", false, true, this.logger); + break; + default: + break; + } } } diff --git a/src/logging.ts b/src/logging.ts index fd33a303b2..877d35e59c 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -19,12 +19,19 @@ export enum LogLevel { * This will allow for easy mocking of the logger during unit tests. */ export interface ILogger { + getLogFilePath(baseName: string): vscode.Uri; + updateLogLevel(logLevelName: string): void; write(message: string, ...additionalMessages: string[]): void; + writeAndShowInformation(message: string, ...additionalMessages: string[]): Promise; writeDiagnostic(message: string, ...additionalMessages: string[]): void; writeVerbose(message: string, ...additionalMessages: string[]): void; writeWarning(message: string, ...additionalMessages: string[]): void; writeAndShowWarning(message: string, ...additionalMessages: string[]): Promise; writeError(message: string, ...additionalMessages: string[]): void; + writeAndShowError(message: string, ...additionalMessages: string[]): Promise; + writeAndShowErrorWithActions( + message: string, + actions: { prompt: string; action: (() => Promise) | undefined }[]): Promise; } export class Logger implements ILogger { diff --git a/src/platform.ts b/src/platform.ts index d98a0fd7ca..b0d2c1daa8 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -5,7 +5,7 @@ import * as os from "os"; import * as path from "path"; import * as process from "process"; import { integer } from "vscode-languageserver-protocol"; -import { Logger } from "./logging"; +import { ILogger } from "./logging"; import { PowerShellAdditionalExePathSettings } from "./settings"; // This uses require so we can rewire it in unit tests! @@ -86,7 +86,7 @@ export class PowerShellExeFinder { private platformDetails: IPlatformDetails, // Additional configured PowerShells private additionalPowerShellExes: PowerShellAdditionalExePathSettings, - private logger: Logger) { } + private logger: ILogger) { } /** * Returns the first available PowerShell executable found in the search order. diff --git a/src/process.ts b/src/process.ts index 7c113260aa..c0b94663d6 100644 --- a/src/process.ts +++ b/src/process.ts @@ -4,7 +4,7 @@ import cp = require("child_process"); import path = require("path"); import vscode = require("vscode"); -import { Logger } from "./logging"; +import { ILogger } from "./logging"; import Settings = require("./settings"); import utils = require("./utils"); import { IEditorServicesSessionDetails } from "./session"; @@ -24,7 +24,7 @@ export class PowerShellProcess { public exePath: string, private bundledModulesPath: string, private title: string, - private logger: Logger, + private logger: ILogger, private startPsesArgs: string, private sessionFilePath: vscode.Uri, private sessionSettings: Settings.Settings) { diff --git a/src/session.ts b/src/session.ts index 4927e5f08a..78e7d33b7d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -3,11 +3,10 @@ import net = require("net"); import path = require("path"); -import * as semver from "semver"; import vscode = require("vscode"); import TelemetryReporter, { TelemetryEventProperties, TelemetryEventMeasurements } from "@vscode/extension-telemetry"; import { Message } from "vscode-jsonrpc"; -import { Logger } from "./logging"; +import { ILogger } from "./logging"; import { PowerShellProcess } from "./process"; import { Settings, changeSetting, getSettings, getEffectiveConfigurationTarget, validateCwdSetting } from "./settings"; import utils = require("./utils"); @@ -19,12 +18,13 @@ import { } from "vscode-languageclient"; import { LanguageClient, StreamInfo } from "vscode-languageclient/node"; -import { GitHubReleaseInformation, InvokePowerShellUpdateCheck } from "./features/UpdatePowerShell"; +import { UpdatePowerShell } from "./features/UpdatePowerShell"; import { getPlatformDetails, IPlatformDetails, IPowerShellExeDetails, OperatingSystem, PowerShellExeFinder } from "./platform"; import { LanguageClientConsumer } from "./languageClientConsumer"; +import { SemVer } from "semver"; export enum SessionStatus { NeverStarted, @@ -55,17 +55,11 @@ export interface IEditorServicesSessionDetails { export interface IPowerShellVersionDetails { version: string; - displayVersion: string; edition: string; + commit: string; architecture: string; } -export interface IRunspaceDetails { - powerShellVersion: IPowerShellVersionDetails; - runspaceType: RunspaceType; - connectionString: string; -} - export type IReadSessionFileCallback = (details: IEditorServicesSessionDetails) => void; export const SendKeyPressNotificationType = @@ -103,7 +97,7 @@ export class SessionManager implements Middleware { constructor( private extensionContext: vscode.ExtensionContext, private sessionSettings: Settings, - private logger: Logger, + private logger: ILogger, private documentSelector: DocumentSelector, hostName: string, hostVersion: string, @@ -716,39 +710,9 @@ Type 'help' to get help. // We haven't "started" until we're done getting the version information. this.started = true; + const updater = new UpdatePowerShell(this, this.sessionSettings, this.logger, this.versionDetails); // NOTE: We specifically don't want to wait for this. - void this.checkForPowerShellUpdate(); - } - - private async checkForPowerShellUpdate() { - // If the user opted to not check for updates, then don't. - if (!this.sessionSettings.promptToUpdatePowerShell) { - return; - } - - const localVersion = semver.parse(this.versionDetails!.version); - if (semver.lt(localVersion!, "6.0.0")) { - // Skip prompting when using Windows PowerShell for now. - return; - } - - try { - // Fetch the latest PowerShell releases from GitHub. - const isPreRelease = !!semver.prerelease(localVersion!); - const release: GitHubReleaseInformation = - await GitHubReleaseInformation.FetchLatestRelease(isPreRelease); - - await InvokePowerShellUpdateCheck( - this, - this.languageClient!, - localVersion!, - this.versionDetails!.architecture, - release, - this.logger); - } catch (err) { - // Best effort. This probably failed to fetch the data from GitHub. - this.logger.writeWarning(err instanceof Error ? err.message : "unknown"); - } + void updater.checkForUpdate(); } private createStatusBarItem(): vscode.LanguageStatusItem { @@ -765,12 +729,9 @@ Type 'help' to get help. this.languageStatusItem.detail = "PowerShell"; if (this.versionDetails !== undefined) { - const version = this.versionDetails.architecture === "x86" - ? `${this.versionDetails.displayVersion} (${this.versionDetails.architecture})` - : this.versionDetails.displayVersion; - - this.languageStatusItem.text = "$(terminal-powershell) " + version; - this.languageStatusItem.detail += " " + version; + const semver = new SemVer(this.versionDetails.version); + this.languageStatusItem.text = `$(terminal-powershell) ${semver.major}.${semver.minor}`; + this.languageStatusItem.detail += ` ${this.versionDetails.commit} (${this.versionDetails.architecture.toLowerCase()})`; } if (statusText) { @@ -866,8 +827,8 @@ Type 'help' to get help. const powerShellSessionName = currentPowerShellExe ? currentPowerShellExe.displayName : - `PowerShell ${this.versionDetails.displayVersion} ` + - `(${this.versionDetails.architecture}) ${this.versionDetails.edition} Edition ` + + `PowerShell ${this.versionDetails.version} ` + + `(${this.versionDetails.architecture.toLowerCase()}) ${this.versionDetails.edition} Edition ` + `[${this.versionDetails.version}]`; sessionText = `Current session: ${powerShellSessionName}`; diff --git a/src/settings.ts b/src/settings.ts index 549304ffe2..8c1f962529 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -4,7 +4,7 @@ import vscode = require("vscode"); import utils = require("./utils"); import os = require("os"); -import { Logger } from "./logging"; +import { ILogger } from "./logging"; // TODO: Quite a few of these settings are unused in the client and instead // exist just for the server. Those settings do not need to be represented in @@ -193,7 +193,7 @@ export async function changeSetting( // eslint-disable-next-line @typescript-eslint/no-explicit-any newValue: any, configurationTarget: vscode.ConfigurationTarget | boolean | undefined, - logger: Logger | undefined): Promise { + logger: ILogger | undefined): Promise { logger?.writeDiagnostic(`Changing '${settingName}' at scope '${configurationTarget} to '${newValue}'`); @@ -209,7 +209,7 @@ export async function changeSetting( let hasPrompted = false; export let chosenWorkspace: vscode.WorkspaceFolder | undefined = undefined; -export async function validateCwdSetting(logger: Logger): Promise { +export async function validateCwdSetting(logger: ILogger): Promise { let cwd: string | undefined = vscode.workspace.getConfiguration(utils.PowerShellLanguageId).get("cwd"); // Only use the cwd setting if it exists. diff --git a/test/features/UpdatePowerShell.test.ts b/test/features/UpdatePowerShell.test.ts index 3dacaf1793..f78494cac0 100644 --- a/test/features/UpdatePowerShell.test.ts +++ b/test/features/UpdatePowerShell.test.ts @@ -2,26 +2,140 @@ // Licensed under the MIT License. import * as assert from "assert"; -import { GitHubReleaseInformation } from "../../src/features/UpdatePowerShell"; +import { UpdatePowerShell } from "../../src/features/UpdatePowerShell"; +import { Settings } from "../../src/settings"; +import { IPowerShellVersionDetails } from "../../src/session"; +import { testLogger } from "../utils"; describe("UpdatePowerShell feature", function () { + let currentUpdateSetting: string | undefined; + const settings = new Settings(); + before(function () { - // NOTE: GitHub API is rate limited in CI - if (process.env.TF_BUILD) { this.skip(); } + currentUpdateSetting = process.env.POWERSHELL_UPDATECHECK; + }); + + beforeEach(function () { + settings.promptToUpdatePowerShell = true; + process.env.POWERSHELL_UPDATECHECK = "Default"; }); - it("Gets the latest version", async function () { - const release: GitHubReleaseInformation = await GitHubReleaseInformation.FetchLatestRelease(false); - assert.strictEqual(release.isPreview, false, "expected to not be preview."); - assert.strictEqual( - release.version.prerelease.length === 0, true, "expected to not have preview in version."); - assert.strictEqual(release.assets.length > 0, true, "expected to have assets."); + after(function () { + process.env.POWERSHELL_UPDATECHECK = currentUpdateSetting; }); - it("Gets the latest preview version", async function () { - const release: GitHubReleaseInformation = await GitHubReleaseInformation.FetchLatestRelease(true); - assert.strictEqual(release.isPreview, true, "expected to be preview."); - assert.strictEqual(release.version.prerelease.length > 0, true, "expected to have preview in version."); - assert.strictEqual(release.assets.length > 0, true, "expected to have assets."); + describe("When it should check for an update", function () { + it("Won't check if 'promptToUpdatePowerShell' is false", function () { + settings.promptToUpdatePowerShell = false; + const version: IPowerShellVersionDetails = { + "version": "7.3.0", + "edition": "Core", + "commit": "7.3.0", + "architecture": "X64" + }; + // @ts-expect-error testing doesn't require all arguments. + const updater = new UpdatePowerShell(undefined, settings, testLogger, version); + // @ts-expect-error method is private. + assert(!updater.shouldCheckForUpdate()); + }); + + it("Won't check for Windows PowerShell", function () { + const version: IPowerShellVersionDetails = { + "version": "5.1.22621", + "edition": "Desktop", + "commit": "5.1.22621", + "architecture": "X64" + }; + // @ts-expect-error testing doesn't require all arguments. + const updater = new UpdatePowerShell(undefined, settings, testLogger, version); + // @ts-expect-error method is private. + assert(!updater.shouldCheckForUpdate()); + }); + + it("Won't check for a development build of PowerShell", function () { + const version: IPowerShellVersionDetails = { + "version": "7.3.0-preview.3", + "edition": "Core", + "commit": "7.3.0-preview.3-508-g07175ae0ff8eb7306fe0b0fc7d19bdef4fbf2d67", + "architecture": "Arm64" + }; + // @ts-expect-error testing doesn't require all arguments. + const updater = new UpdatePowerShell(undefined, settings, testLogger, version); + // @ts-expect-error method is private. + assert(!updater.shouldCheckForUpdate()); + }); + + it("Won't check for a daily build of PowerShell", function () { + const version: IPowerShellVersionDetails = { + "version": "7.3.0-daily20221206.1", + "edition": "Core", + "commit": "7.3.0-daily20221206.1", + "architecture": "Arm64" + }; + // @ts-expect-error testing doesn't require all arguments. + const updater = new UpdatePowerShell(undefined, settings, testLogger, version); + // @ts-expect-error method is private. + assert(!updater.shouldCheckForUpdate()); + }); + + it("Won't check if POWERSHELL_UPDATECHECK is 'Off'", function () { + process.env.POWERSHELL_UPDATECHECK = "Off"; + const version: IPowerShellVersionDetails = { + "version": "7.3.0", + "edition": "Core", + "commit": "7.3.0", + "architecture": "X64" + }; + // @ts-expect-error testing doesn't require all arguments. + const updater = new UpdatePowerShell(undefined, settings, testLogger, version); + // @ts-expect-error method is private. + assert(!updater.shouldCheckForUpdate()); + }); + + it ("Should otherwise check to update PowerShell", function () { + const version: IPowerShellVersionDetails = { + "version": "7.3.0", + "edition": "Core", + "commit": "7.3.0", + "architecture": "X64" + }; + // @ts-expect-error testing doesn't require all arguments. + const updater = new UpdatePowerShell(undefined, settings, testLogger, version); + // @ts-expect-error method is private. + assert(updater.shouldCheckForUpdate()); + }); + }); + + describe("Which version it gets", function () { + it("Would update to LTS", async function() { + process.env.POWERSHELL_UPDATECHECK = "LTS"; + const version: IPowerShellVersionDetails = { + "version": "7.0.0", + "edition": "Core", + "commit": "7.0.0", + "architecture": "X64" + }; + // @ts-expect-error testing doesn't require all arguments. + const updater = new UpdatePowerShell(undefined, settings, testLogger, version); + // @ts-expect-error method is private. + const tag: string | undefined = await updater.maybeGetNewRelease(); + // NOTE: This will need to be updated each new major LTS. + assert(tag?.startsWith("v7.2")); + }); + + it("Would update to stable", async function() { + const version: IPowerShellVersionDetails = { + "version": "7.0.0", + "edition": "Core", + "commit": "7.0.0", + "architecture": "X64" + }; + // @ts-expect-error testing doesn't require all arguments. + const updater = new UpdatePowerShell(undefined, settings, testLogger, version); + // @ts-expect-error method is private. + const tag: string | undefined = await updater.maybeGetNewRelease(); + // NOTE: This will need to be updated each new major stable. + assert(tag?.startsWith("v7.3")); + }); }); }); diff --git a/test/utils.ts b/test/utils.ts index 528973c862..781244dbca 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -3,6 +3,7 @@ import * as path from "path"; import * as vscode from "vscode"; +import { ILogger } from "../src/logging"; import { IPowerShellExtensionClient } from "../src/features/ExternalApi"; // This lets us test the rest of our path assumptions against the baseline of @@ -12,6 +13,46 @@ export const rootPath = path.resolve(__dirname, "../../"); const packageJSON: any = require(path.resolve(rootPath, "package.json")); export const extensionId = `${packageJSON.publisher}.${packageJSON.name}`; +export class TestLogger implements ILogger { + getLogFilePath(_baseName: string): vscode.Uri { + return vscode.Uri.file(""); + } + updateLogLevel(_logLevelName: string): void { + return; + } + write(_message: string, ..._additionalMessages: string[]): void { + return; + } + writeAndShowInformation(_message: string, ..._additionalMessages: string[]): Promise { + return Promise.resolve(); + } + writeDiagnostic(_message: string, ..._additionalMessages: string[]): void { + return; + } + writeVerbose(_message: string, ..._additionalMessages: string[]): void { + return; + } + writeWarning(_message: string, ..._additionalMessages: string[]): void { + return; + } + writeAndShowWarning(_message: string, ..._additionalMessages: string[]): Promise { + return Promise.resolve(); + } + writeError(_message: string, ..._additionalMessages: string[]): void { + return; + } + writeAndShowError(_message: string, ..._additionalMessages: string[]): Promise { + return Promise.resolve(); + } + writeAndShowErrorWithActions( + _message: string, + _actions: { prompt: string; action: (() => Promise) | undefined }[]): Promise { + return Promise.resolve(); + } +} + +export const testLogger = new TestLogger(); + export async function ensureExtensionIsActivated(): Promise { const extension = vscode.extensions.getExtension(extensionId); if (!extension!.isActive) { await extension!.activate(); }