diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index f5ddaf15a4..8dbd1e2ba7 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -15,6 +15,9 @@ jobs: ci: name: node strategy: + # Report every platform's result instead of cancelling the others as soon + # as one fails. + fail-fast: false matrix: os: [windows-latest, macos-latest, ubuntu-latest] runs-on: ${{ matrix.os }} @@ -30,11 +33,52 @@ jobs: repository: PowerShell/PowerShellEditorServices path: PowerShellEditorServices + # TODO: Drop this once PowerShellEditorServices can restore again. Its + # nuget.config only lists an Azure DevOps feed whose package downloads + # return 401 (Unauthorized) to anonymous clients such as GitHub runners, + # so restoring the .NET reference packs fails before any tests run. Add + # nuget.org and map the reference packs to it (otherwise NuGet still + # downloads them from the feed that 401s). + - name: Add nuget.org for the PowerShellEditorServices reference packs + shell: pwsh + run: | + $config = @( + '' + '' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + '' + ) + Set-Content -Path PowerShellEditorServices/nuget.config -Value $config + - name: Checkout vscode-powershell uses: actions/checkout@v7 with: path: vscode-powershell + # TODO: Drop this once the PowerShell npm mirror has cached + # @vscode/test-electron 3.x. The mirror returns 401 for packages it has + # not cached (upstream access needs auth), and npm silently skips optional + # dependencies it cannot fetch, so `vscode-test` cannot resolve the newer + # package and the run dies before doing anything. + - name: Use npmjs instead of the PowerShell mirror + shell: pwsh + working-directory: vscode-powershell + run: (Get-Content .npmrc) -replace '^registry=.*', 'registry=https://registry.npmjs.org/' | Set-Content .npmrc + - name: Validate snippets JSON file shell: pwsh run: $null = ConvertFrom-Json -InputObject (Get-Content -Raw -Path './snippets/PowerShell.json') diff --git a/package-lock.json b/package-lock.json index f51c954422..a05c96e848 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,7 @@ "@ungap/structured-clone": "^1.3.1", "@vscode/debugprotocol": "^1.68.0", "@vscode/test-cli": "^0.0.12", - "@vscode/test-electron": "^2.5.2", + "@vscode/test-electron": "^3.1.0", "esbuild-register": "^3.6.0", "eslint": "^10.5.0", "eslint-config-prettier": "^10.1.8", @@ -1797,8 +1797,8 @@ } }, "node_modules/@vscode/test-electron": { - "version": "2.5.2", - "integrity": "sha1-99QHjoIwzpyUMi8qKcwWwXlUCF0=", + "version": "3.1.0", + "integrity": "sha1-Rrk9EY3NOzyJyq4pahP5p/XrY8U=", "license": "MIT", "optional": true, "dependencies": { @@ -1809,7 +1809,7 @@ "semver": "^7.6.2" }, "engines": { - "node": ">=16" + "node": ">=22" } }, "node_modules/@vscode/vsce": { diff --git a/package.json b/package.json index 75eaf159e8..9f6ba11536 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "@ungap/structured-clone": "^1.3.1", "@vscode/debugprotocol": "^1.68.0", "@vscode/test-cli": "^0.0.12", - "@vscode/test-electron": "^2.5.2", + "@vscode/test-electron": "^3.1.0", "esbuild-register": "^3.6.0", "eslint": "^10.5.0", "eslint-config-prettier": "^10.1.8", diff --git a/src/features/UpdatePowerShell.ts b/src/features/UpdatePowerShell.ts index 99532e1d13..b60b5773a0 100644 --- a/src/features/UpdatePowerShell.ts +++ b/src/features/UpdatePowerShell.ts @@ -8,34 +8,129 @@ import type { ILogger } from "../logging"; import type { IPowerShellVersionDetails } from "../session"; import { changeSetting } from "../settings"; -interface IUpdateMessageItem extends vscode.MessageItem { - id: number; +async function fetchJSON(url: string): Promise { + const response = await fetch(url); + if (!response.ok) return undefined; + return response.json(); +} + +/** Strip the 4th component from a WinGet version (e.g. "7.4.5.0" → "7.4.5"). */ +export function toTriple(v: string): string { + return v.split(".").slice(0, 3).join("."); +} + +/** Parse the PowerShell version out of `winget show` output. */ +export function parseWinGetShowOutput(output: string): string | undefined { + const match = /Version:\s*([\d.]+)/.exec(output); + return match ? toTriple(match[1]) : undefined; +} + +/** One entry from the winget-pkgs manifest directory listing. */ +export interface IWinGetManifestEntry { + name: string; + type: string; +} + +/** Get the newest PowerShell version from a manifest directory listing. */ +export function getLatestWinGetVersion( + entries: IWinGetManifestEntry[], +): string | undefined { + return entries + .filter(({ type }) => type === "dir") + .map(({ name }) => toTriple(name)) + .sort((a, b) => new SemVer(b).compare(a))[0]; +} + +/** What WinGet has for PowerShell on this machine, if anything. */ +export interface IWinGetStatus { + /** Whether the `winget` CLI was found. */ + installed: boolean; + /** The newest PowerShell version WinGet knows about, if known. */ + version?: string; +} + +/** A button of the update prompt. */ +export interface IUpdatePromptOption { + id: "winget" | "winget-progress" | "github" | "not-now" | "dont-show"; + title: string; +} + +/** The update prompt to show the user. */ +export interface IUpdatePrompt { + message: string; + options: IUpdatePromptOption[]; +} + +/** + * Build the update prompt's message and buttons. This is a pure function so it + * can be tested; the WinGet status must be detected beforehand. + */ +export function buildUpdatePrompt( + localVersion: string, + releaseTag: string, + isWindows: boolean, + winget: IWinGetStatus, +): IUpdatePrompt { + const releaseVersion = new SemVer(releaseTag); + const options: IUpdatePromptOption[] = []; + + // WinGet only exists on Windows, so don't offer it elsewhere. + if (isWindows) { + options.push({ + id: "winget", + title: winget.installed ? "Upgrade with WinGet" : "Install WinGet", + }); + if ( + winget.installed && + winget.version && + new SemVer(winget.version).compare(releaseVersion.version) < 0 + ) { + options.push({ + id: "winget-progress", + title: "View WinGet Progress", + }); + } + } + options.push( + { id: "github", title: "Open GitHub Release" }, + { id: "not-now", title: "Not Now" }, + { id: "dont-show", title: "Don't Show Again" }, + ); + + let message = + `PowerShell v${localVersion} is out-of-date.\n` + + `The latest version is v${releaseVersion.version}.`; + // Note when WinGet is relevant but not (yet) useful. + if (winget.installed && winget.version) { + const wingetVersion = new SemVer(winget.version); + if (wingetVersion.compare(localVersion) <= 0) { + message += `\n(WinGet hasn't caught up yet — currently v${winget.version}.)`; + } else if (wingetVersion.compare(releaseVersion.version) < 0) { + message += `\n(WinGet currently has v${winget.version}.)`; + } + } else if (isWindows) { + message += winget.version + ? `\n(WinGet is not installed. It offers v${winget.version}.)` + : `\n(WinGet, the Windows Package Manager, is not installed.)`; + } + message += `\nWould you like to upgrade?`; + + return { message, options }; +} + +/** Await a value/promise, and if non-nullish, pass it to `fn`. */ +async function whenSome( + value: T | undefined | null | Promise, + fn: (value: T) => void | Promise, +): Promise { + const resolved = await value; + if (resolved != null) await fn(resolved); } // 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 GitHubWebReleaseURL = - "https://github.com/PowerShell/PowerShell/releases/tag/"; - private static promptOptions: IUpdateMessageItem[] = [ - { - id: 0, - title: "Yes", - }, - { - id: 1, - title: "Not Now", - }, - { - id: 2, - title: "Don't Show Again", - }, - ]; private localVersion: SemVer; constructor( @@ -49,56 +144,47 @@ export class UpdatePowerShell { this.localVersion = new SemVer(versionDetails.commit); } + private skip(reason: string): false { + this.logger.writeDebug(reason); + return false; + } + private shouldCheckForUpdate(): boolean { // Respect user setting. const promptToUpdatePowerShell = vscode.workspace .getConfiguration("powershell") .get("promptToUpdatePowerShell", true); - if (!promptToUpdatePowerShell) { - this.logger.writeDebug( - "Setting 'promptToUpdatePowerShell' was false.", - ); - return false; - } + if (!promptToUpdatePowerShell) + return this.skip("Setting 'promptToUpdatePowerShell' was false."); // Respect environment configuration. - if (process.env.POWERSHELL_UPDATECHECK?.toLowerCase() === "off") { - this.logger.writeDebug( + if (process.env.POWERSHELL_UPDATECHECK?.toLowerCase() === "off") + return this.skip( "Environment variable 'POWERSHELL_UPDATECHECK' was 'Off'.", ); - return false; - } // Skip prompting when using Windows PowerShell for now. - if (this.localVersion.compare("6.0.0") === -1) { + if (this.localVersion.compare("6.0.0") === -1) // TODO: Maybe we should announce PowerShell Core? - this.logger.writeDebug( - "Not prompting to update Windows PowerShell.", - ); - return false; - } + return this.skip("Not prompting to update Windows PowerShell."); 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.writeDebug( - "Not prompting to update development build.", - ); - return false; - } + if (this.localVersion.prerelease[1].toString().length >= 40) + return this.skip("Not prompting to update development build."); // Skip if preview is a daily build. - if (daily.toLowerCase().startsWith("daily")) { - this.logger.writeDebug("Not prompting to update daily build."); - return false; - } + if ( + this.localVersion.prerelease[0] + .toString() + .toLowerCase() + .startsWith("daily") + ) + return this.skip("Not prompting to update daily build."); } // TODO: Check if network is available? @@ -107,17 +193,10 @@ export class UpdatePowerShell { } private async getRemoteVersion(url: string): Promise { - const response = await fetch(url); - if (!response.ok) { - return undefined; - } - // Looks like: - // { - // "ReleaseDate": "2022-10-20T22:01:38Z", - // "BlobName": "v7-2-7", - // "ReleaseTag": "v7.2.7" - // } - const data = await response.json(); + const data = await fetchJSON<{ + ReleaseTag: string; + }>(url); + if (!data) return undefined; this.logger.writeDebug( `Received from '${url}':\n${JSON.stringify(data, undefined, 2)}`, ); @@ -130,40 +209,21 @@ export class UpdatePowerShell { } this.logger.writeDebug("Checking for PowerShell update..."); - const tags: string[] = []; - if (process.env.POWERSHELL_UPDATECHECK?.toLowerCase() === "lts") { - // Only check for update to LTS. - this.logger.writeDebug("Checking for LTS update..."); - const tag = await this.getRemoteVersion( - UpdatePowerShell.LTSBuildInfoURL, - ); - if (tag != undefined) { - tags.push(tag); - } - } else { - // Check for update to stable. - this.logger.writeDebug("Checking for stable update..."); - const tag = await this.getRemoteVersion( - UpdatePowerShell.StableBuildInfoURL, - ); - if (tag != undefined) { - tags.push(tag); - } - - // Also check for a preview update. - if (this.localVersion.prerelease.length > 0) { - this.logger.writeDebug("Checking for preview update..."); - const tag = await this.getRemoteVersion( - UpdatePowerShell.PreviewBuildInfoURL, - ); - if (tag != undefined) { - tags.push(tag); - } - } - } - - for (const tag of tags) { - if (this.localVersion.compare(tag) === -1) { + const suffixes = + process.env.POWERSHELL_UPDATECHECK?.toLowerCase() === "lts" + ? ["lts"] + : this.localVersion.prerelease.length > 0 + ? ["stable", "preview"] + : ["stable"]; + this.logger.writeDebug( + `Checking for ${suffixes.join(" and ")} update...`, + ); + for (const tag of await Promise.all( + suffixes.map((s) => + this.getRemoteVersion(`https://aka.ms/pwsh-buildinfo-${s}`), + ), + )) { + if (tag != undefined && this.localVersion.compare(tag) === -1) { return tag; } } @@ -174,11 +234,9 @@ export class UpdatePowerShell { public async checkForUpdate(): Promise { try { - const tag = await this.maybeGetNewRelease(); - if (tag) { - await this.promptToUpdate(tag); - return; - } + await whenSome(this.maybeGetNewRelease(), (tag) => + this.promptToUpdate(tag), + ); } catch (err) { // Best effort. This probably failed to fetch the data from GitHub. this.logger.writeWarning( @@ -188,10 +246,11 @@ export class UpdatePowerShell { } private async openReleaseInBrowser(tag: string): Promise { - const url = vscode.Uri.parse( - UpdatePowerShell.GitHubWebReleaseURL + tag, + await vscode.env.openExternal( + vscode.Uri.parse( + `https://github.com/PowerShell/PowerShell/releases/tag/${tag}`, + ), ); - await vscode.env.openExternal(url); } private async promptToUpdate(tag: string): Promise { @@ -199,11 +258,58 @@ export class UpdatePowerShell { this.logger.write( `Prompting to update PowerShell v${this.localVersion.version} to v${releaseVersion.version}.`, ); + + const isWindows = process.platform === "win32"; + // Get the PowerShell version WinGet has, if WinGet exists here. + let winget: IWinGetStatus = { installed: false }; + if (isWindows) { + try { + const { execFile } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const { stdout } = await promisify(execFile)("winget", [ + "show", + "--id", + "Microsoft.PowerShell", + "-s", + "winget", + "--accept-source-agreements", + ]); + const version = parseWinGetShowOutput(stdout); + if (version !== undefined) { + winget = { installed: true, version }; + } + } catch { + // WinGet may not be installed — fall back to the GitHub API to + // ask which version it would offer. + try { + const entries = await fetchJSON( + "https://api.github.com/repos/microsoft/winget-pkgs/contents/manifests/m/Microsoft/PowerShell?per_page=100", + ); + const version = entries + ? getLatestWinGetVersion(entries) + : undefined; + this.logger.writeDebug( + `WinGet repo latest: ${version ?? "not found"}`, + ); + if (version !== undefined) { + winget = { installed: false, version }; + } + } catch { + // Best effort. + } + } + } + + const { message, options } = buildUpdatePrompt( + this.localVersion.version, + tag, + isWindows, + winget, + ); + const result = await vscode.window.showInformationMessage( - `PowerShell v${this.localVersion.version} is out-of-date. - The latest version is v${releaseVersion.version}. - Would you like to open the GitHub release in your browser?`, - ...UpdatePowerShell.promptOptions, + message, + ...options, ); // If the user cancels the notification. @@ -212,20 +318,135 @@ export class UpdatePowerShell { return; } - this.logger.writeDebug( - `User said '${UpdatePowerShell.promptOptions[result.id].title}'.`, - ); + this.logger.writeDebug(`User said '${result.title}'.`); switch (result.id) { - // Yes - case 0: + case "winget": + if (winget.installed) { + this.logger.write("Upgrading PowerShell via WinGet..."); + vscode.window + .createTerminal("PowerShell Upgrade (WinGet)") + .sendText( + "winget update --id Microsoft.PowerShell -e -s winget", + ); + } else { + // From: https://aka.ms/winget-docs + this.logger.write( + "Installing WinGet and upgrading PowerShell...", + ); + vscode.window + .createTerminal("Install WinGet & Upgrade PowerShell") + .sendText( + "$result = Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe -ErrorAction SilentlyContinue; if ($?) { winget update --id Microsoft.PowerShell -e -s winget } else { Write-Warning 'Failed to install WinGet. See https://aka.ms/winget-docs' }", + ); + } + break; + case "winget-progress": { + // Open the winget-pkgs issue or PR for the version WinGet is + // missing; find the open issue/PR mentioning the highest + // PowerShell version. + let statusUrl = + "https://github.com/microsoft/winget-pkgs/pulls?q=Microsoft.PowerShell+is:open"; + try { + let bestPR: string | undefined; + let bestIssue: string | undefined; + let bestPRVer: string | undefined; + let bestIssueVer: string | undefined; + for (const { + ver, + html_url, + pull_request, + } of await Promise.all( + ( + ( + await fetchJSON<{ + items?: { + title: string; + html_url: string; + pull_request?: unknown; + }[]; + }>( + "https://api.github.com/search/issues?q=Microsoft.PowerShell+repo:microsoft/winget-pkgs+is:open&sort=created&order=desc&per_page=30", + ) + )?.items ?? [] + ).map( + async ( + item, + ): Promise => { + const tm = + /(?:New version|Update|\[Update Request\]).*?(\d+\.\d+\.\d+)/i.exec( + item.title, + ); + if (tm) return { ...item, ver: tm[1] }; + // Try to extract version from the issue body. + try { + const bm = ( + await fetchJSON<{ + body?: string; + }>( + item.html_url.replace( + "https://github.com/", + "https://api.github.com/repos/", + ), + ) + )?.body?.match( + /Package Version.*?(\d+\.\d+\.\d+)/i, + ); + if (bm) { + return { ...item, ver: bm[1] }; + } + } catch { + // Best effort. + } + return item; + }, + ), + )) { + if (!ver) continue; + if (pull_request) { + if ( + !bestPRVer || + new SemVer(ver).compare(bestPRVer) > 0 + ) { + bestPRVer = ver; + bestPR = html_url; + } + } else if ( + !bestIssueVer || + new SemVer(ver).compare(bestIssueVer) > 0 + ) { + bestIssueVer = ver; + bestIssue = html_url; + } + } + // Prefer issue when linked to the PR for the same version. + const prVer = bestPRVer ? new SemVer(bestPRVer) : undefined; + const issueVer = bestIssueVer + ? new SemVer(bestIssueVer) + : undefined; + if (prVer && issueVer && prVer.compare(issueVer) === 0) { + statusUrl = bestIssue!; + } else if ( + prVer && + (!issueVer || prVer.compare(issueVer) > 0) + ) { + statusUrl = bestPR!; + } else if (issueVer) { + statusUrl = bestIssue!; + } + } catch { + // Fall back to generic search. + } + await vscode.env.openExternal(vscode.Uri.parse(statusUrl)); + break; + } + case "github": await this.openReleaseInBrowser(tag); break; - // Not Now - case 1: + case "not-now": + // Do nothing. break; - // Don't Show Again - case 2: + case "dont-show": await changeSetting( "promptToUpdatePowerShell", false, diff --git a/test/features/UpdatePowerShell.test.ts b/test/features/UpdatePowerShell.test.ts index df146b3081..bf6fc89e87 100644 --- a/test/features/UpdatePowerShell.test.ts +++ b/test/features/UpdatePowerShell.test.ts @@ -3,7 +3,13 @@ import assert from "assert"; import * as vscode from "vscode"; -import { UpdatePowerShell } from "../../src/features/UpdatePowerShell"; +import { + buildUpdatePrompt, + getLatestWinGetVersion, + parseWinGetShowOutput, + toTriple, + UpdatePowerShell, +} from "../../src/features/UpdatePowerShell"; import type { IPowerShellVersionDetails } from "../../src/session"; import { changeSetting } from "../../src/settings"; import { testLogger } from "../utils"; @@ -146,4 +152,129 @@ describe("UpdatePowerShell feature", function () { assert(tag?.startsWith("v7.6") || tag?.startsWith("v7.4")); }); }); + + describe("WinGet version detection", function () { + it("Strips the revision component of a WinGet version", function () { + assert.strictEqual(toTriple("7.5.3.0"), "7.5.3"); + assert.strictEqual(toTriple("7.5.3"), "7.5.3"); + }); + + it("Parses the version from 'winget show' output", function () { + assert.strictEqual( + parseWinGetShowOutput( + [ + "Found Microsoft PowerShell [Microsoft.PowerShell]", + "Version: 7.5.3.0", + "Publisher: Microsoft Corporation", + "Moniker: powershell", + ].join("\n"), + ), + "7.5.3", + ); + }); + + it("Returns undefined when 'winget show' has no version", function () { + assert.strictEqual( + parseWinGetShowOutput( + "No package found matching input criteria.", + ), + undefined, + ); + }); + + it("Gets the newest version from a winget-pkgs listing", function () { + assert.strictEqual( + getLatestWinGetVersion([ + { name: "7.4.5.0", type: "dir" }, + { name: "7.5.3.0", type: "dir" }, + { name: "7.6.0", type: "dir" }, + { name: "README.md", type: "file" }, + ]), + "7.6.0", + ); + }); + + it("Returns undefined for a listing without versions", function () { + assert.strictEqual( + getLatestWinGetVersion([ + { name: ".gitattributes", type: "file" }, + ]), + undefined, + ); + }); + }); + + describe("The update prompt", function () { + const titlesOf = (prompt: { options: { title: string }[] }): string[] => + prompt.options.map(({ title }) => title); + + it("Does not offer WinGet where it does not exist", function () { + const prompt = buildUpdatePrompt("7.5.2", "v7.5.3", false, { + installed: false, + }); + assert.deepStrictEqual(prompt.options, [ + { id: "github", title: "Open GitHub Release" }, + { id: "not-now", title: "Not Now" }, + { id: "dont-show", title: "Don't Show Again" }, + ]); + assert(!prompt.message.includes("WinGet")); + }); + + it("Names both versions and asks whether to upgrade", function () { + const prompt = buildUpdatePrompt("7.5.2", "v7.5.3", false, { + installed: false, + }); + assert(prompt.message.includes("PowerShell v7.5.2 is out-of-date")); + assert(prompt.message.includes("The latest version is v7.5.3")); + assert(prompt.message.includes("Would you like to upgrade?")); + }); + + it("Offers to install WinGet on Windows without it", function () { + const prompt = buildUpdatePrompt("7.5.2", "v7.5.3", true, { + installed: false, + }); + assert(titlesOf(prompt).includes("Install WinGet")); + assert(prompt.message.includes("is not installed")); + }); + + it("Mentions the version WinGet would offer when missing", function () { + const prompt = buildUpdatePrompt("7.5.2", "v7.5.3", true, { + installed: false, + version: "7.5.3", + }); + assert( + prompt.message.includes( + "WinGet is not installed. It offers v7.5.3", + ), + ); + }); + + it("Offers to upgrade with WinGet when it has the new version", function () { + const prompt = buildUpdatePrompt("7.5.2", "v7.5.3", true, { + installed: true, + version: "7.5.3", + }); + assert(titlesOf(prompt).includes("Upgrade with WinGet")); + assert(!titlesOf(prompt).includes("View WinGet Progress")); + assert(!prompt.message.includes("caught up")); + }); + + it("Offers WinGet progress and notes its older version", function () { + const prompt = buildUpdatePrompt("7.5.0", "v7.5.3", true, { + installed: true, + version: "7.5.1", + }); + assert(titlesOf(prompt).includes("View WinGet Progress")); + assert(prompt.message.includes("WinGet currently has v7.5.1")); + }); + + it("Says when WinGet has not caught up yet", function () { + assert( + buildUpdatePrompt("7.5.2", "v7.5.3", true, { + installed: true, + version: "7.4.0", + }).message.includes("hasn't caught up yet"), + ); + }); + }); });