diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..c2f8f1e492 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,23 @@ +{ + // Format on save with Prettier + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.formatOnSaveMode": "file", + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + }, + + // Prettier settings matching project config + "prettier.requireConfig": true, + "prettier.useEditorConfig": false, + + // ESLint - check on save when available + "eslint.format.enable": false, + "eslint.validate": ["typescript"], + + // JavaScript / TypeScript + "javascript.format.enabled": false, + "javascript.validate.enabled": true, + "typescript.format.enabled": false, + "typescript.validate.enabled": true +} diff --git a/src/features/UpdatePowerShell.ts b/src/features/UpdatePowerShell.ts index 99532e1d13..35d818f8e8 100644 --- a/src/features/UpdatePowerShell.ts +++ b/src/features/UpdatePowerShell.ts @@ -12,30 +12,30 @@ 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"). */ +function toTriple(v: string): string { + return v.split(".").slice(0, 3).join("."); +} + +/** 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 +49,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 +98,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 +114,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 +139,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 +151,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 +163,134 @@ export class UpdatePowerShell { this.logger.write( `Prompting to update PowerShell v${this.localVersion.version} to v${releaseVersion.version}.`, ); + + // Dynamically build prompt options: add WinGet button if it has this version. + const options: IUpdateMessageItem[] = []; + let wingetProgressIndex = -1; + + // Check WinGet availability and version on Windows. + const isWindows = process.platform === "win32"; + let wingetVer: string | undefined; + let wingetInstalled = false; + if (isWindows) { + try { + const { execFile } = await import("node:child_process"); + await whenSome( + new Promise((resolve, reject) => { + execFile( + "winget", + [ + "show", + "--id", + "Microsoft.PowerShell", + "-s", + "winget", + "--accept-source-agreements", + ], + (error: unknown, stdout: unknown) => { + if (error) { + reject( + error instanceof Error + ? error + : new Error( + typeof error === "string" + ? error + : "unknown", + ), + ); + } else { + resolve(String(stdout)); + } + }, + ); + }).then((s) => /Version:\s*([\d.]+)/.exec(s)), + (match) => { + wingetVer = toTriple(match[1]); + wingetInstalled = true; + }, + ); + } catch { + // WinGet may not be installed — fall back to GitHub API. + try { + await whenSome( + fetchJSON[]>( + "https://api.github.com/repos/microsoft/winget-pkgs/contents/manifests/m/Microsoft/PowerShell?per_page=100", + ), + (entries) => { + wingetVer = entries + .filter(({ type }) => type === "dir") + .map(({ name }) => toTriple(name)) + .sort((a, b) => new SemVer(b).compare(a))[0]; + this.logger.writeDebug( + `WinGet repo latest: ${wingetVer || "not found"}`, + ); + }, + ); + } catch { + // Best effort. + } + } + } + + const addButton = (title: string): number => { + const idx = options.length; + options.push({ id: idx, title }); + return idx; + }; + + // Show WinGet button: "Upgrade with WinGet" when useful, + // "Install WinGet" when not detected on Windows. + let wingetAction: "upgrade" | "install" | undefined; + /* eslint-disable @typescript-eslint/no-unnecessary-condition */ + if ( + wingetVer && + wingetInstalled && + new SemVer(wingetVer).compare(this.localVersion) > 0 + ) { + wingetAction = "upgrade"; + } else if (!wingetInstalled && isWindows) { + wingetAction = "install"; + } + const wingetIndex = addButton( + wingetAction === "install" + ? "Install WinGet" + : "Upgrade with WinGet", + ); + if ( + wingetInstalled && + wingetVer && + new SemVer(wingetVer).compare(releaseVersion.version) < 0 + ) { + wingetProgressIndex = addButton("View WinGet Progress"); + } + + const [browserIndex, notNowIndex, dontShowIndex] = [ + "Open GitHub Release", + "Not Now", + "Don't Show Again", + ].map(addButton); + + // Build message with WinGet status when relevant. + let message = `PowerShell v${this.localVersion.version} is out-of-date. + The latest version is v${releaseVersion.version}.`; + if (wingetInstalled) { + message += + new SemVer(wingetVer!).compare(this.localVersion) <= 0 + ? `\n(WinGet hasn't caught up yet — currently v${wingetVer}.)` + : new SemVer(wingetVer!).compare(releaseVersion.version) < 0 + ? `\n(WinGet currently has v${wingetVer}.)` + : ""; + } else if (isWindows) { + message += wingetVer + ? `\n(WinGet is not installed. It offers v${wingetVer}.)` + : `\n(WinGet, the Windows Package Manager, is not installed.)`; + /* eslint-enable @typescript-eslint/no-unnecessary-condition */ + } + message += `\nWould you like to upgrade?`; + 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 +299,133 @@ export class UpdatePowerShell { return; } - this.logger.writeDebug( - `User said '${UpdatePowerShell.promptOptions[result.id].title}'.`, - ); + this.logger.writeDebug(`User said '${options[result.id].title}'.`); switch (result.id) { - // Yes - case 0: + case wingetIndex: + if (wingetAction === "install") { + // 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' }", + ); + } else { + this.logger.write("Upgrading PowerShell via WinGet..."); + vscode.window + .createTerminal("PowerShell Upgrade (WinGet)") + .sendText( + "winget update --id Microsoft.PowerShell -e -s winget", + ); + } + break; + case wingetProgressIndex: { + // 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 { + const parsed = 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; + }, + ), + ); + + let bestPR: string | undefined; + let bestIssue: string | undefined; + let bestPRVer: string | undefined; + let bestIssueVer: string | undefined; + for (const { ver, html_url, pull_request } of parsed) { + 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 browserIndex: await this.openReleaseInBrowser(tag); break; - // Not Now - case 1: + case notNowIndex: + // Do nothing. break; - // Don't Show Again - case 2: + case dontShowIndex: await changeSetting( "promptToUpdatePowerShell", false,