diff --git a/package.json b/package.json index 28be4137..e3aa4159 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,11 @@ "command": "biome.restartLspServer", "title": "Restart LSP Server", "category": "Biome" + }, + { + "command": "biome.clearVersionsCache", + "title": "Clear versions cache", + "category": "Biome" } ], "menus": { @@ -149,6 +154,7 @@ "@biomejs/biome": "^1.2.2", "@types/node": "^18.17.5", "@types/resolve": "^1.20.2", + "@types/semver": "^7.5.4", "@types/vscode": "^1.80.0", "@vscode/vsce": "^2.20.1", "esbuild": "^0.19.2", @@ -156,6 +162,8 @@ }, "dependencies": { "resolve": "^1.22.4", + "semver": "^7.5.4", + "undici": "^5.27.2", "vscode-languageclient": "^8.1.0" }, "vsce": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 251a8e16..a56c8ac3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ dependencies: resolve: specifier: ^1.22.4 version: 1.22.4 + semver: + specifier: ^7.5.4 + version: 7.5.4 + undici: + specifier: ^5.27.2 + version: 5.27.2 vscode-languageclient: specifier: ^8.1.0 version: 8.1.0 @@ -22,6 +28,9 @@ devDependencies: '@types/resolve': specifier: ^1.20.2 version: 1.20.2 + '@types/semver': + specifier: ^7.5.4 + version: 7.5.4 '@types/vscode': specifier: ^1.80.0 version: 1.80.0 @@ -303,6 +312,11 @@ packages: dev: true optional: true + /@fastify/busboy@2.0.0: + resolution: {integrity: sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ==} + engines: {node: '>=14'} + dev: false + /@types/node@18.17.5: resolution: {integrity: sha512-xNbS75FxH6P4UXTPUJp/zNPq6/xsfdJKussCWNOnz4aULWIRwMgP1LgaB5RiBnMX1DPCYenuqGZfnIAx5mbFLA==} dev: true @@ -311,6 +325,10 @@ packages: resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} dev: true + /@types/semver@7.5.4: + resolution: {integrity: sha512-MMzuxN3GdFwskAnb6fz0orFvhfqi752yjaXylr0Rp4oDg5H0Zn1IuyRhDVvYOwAXoJirx2xuS16I3WjxnAIHiQ==} + dev: true + /@types/vscode@1.80.0: resolution: {integrity: sha512-qK/CmOdS2o7ry3k6YqU4zD3R2AYlJfbwBoSbKpBoP+GpXNE+0NEgJOli4n0bm0diK5kfBnchgCEj4igQz/44Hg==} dev: true @@ -1089,6 +1107,13 @@ packages: resolution: {integrity: sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==} dev: true + /undici@5.27.2: + resolution: {integrity: sha512-iS857PdOEy/y3wlM3yRp+6SNQQ6xU0mmZcwRSriqk+et/cwWAtwmIGf6WkoDN2EK/AMdCO/dfXzIwi+rFMrjjQ==} + engines: {node: '>=14.0'} + dependencies: + '@fastify/busboy': 2.0.0 + dev: false + /url-join@4.0.1: resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} dev: true diff --git a/src/commands/index.ts b/src/commands/index.ts index 72515dae..5fe77077 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,6 +1,9 @@ // list of commands available in the VS Code extension export enum Commands { + StopServer = "biome.stopServer", RestartLspServer = "biome.restartLspServer", SyntaxTree = "biome.syntaxTree", ServerStatus = "biome.serverStatus", + UpdateBiome = "biome.updateBiome", + ChangeVersion = "biome.changeVersion", } diff --git a/src/downloader.ts b/src/downloader.ts new file mode 100644 index 00000000..0ced4678 --- /dev/null +++ b/src/downloader.ts @@ -0,0 +1,186 @@ +import { chmodSync } from "node:fs"; +import { coerce, rcompare } from "semver"; +import { fetch } from "undici"; +import { + ExtensionContext, + ProgressLocation, + Uri, + commands, + window, + workspace, +} from "vscode"; +import { Commands } from "./commands"; + +export const selectAndDownload = async ( + context: ExtensionContext, +): Promise => { + const versions = await window.withProgress( + { + location: ProgressLocation.Notification, + title: "Fetching Biome versions", + cancellable: false, + }, + async () => { + return await getVersions(context); + }, + ); + + const version = await askVersion(versions); + + if (!version) { + return undefined; + } + + return await window.withProgress( + { + location: ProgressLocation.Notification, + title: `Downloading Biome ${version}`, + cancellable: false, + }, + async () => { + await commands.executeCommand(Commands.StopServer); + await download(version, context); + await commands.executeCommand(Commands.RestartLspServer); + return version; + }, + ); +}; + +export const updateToLatest = async (context: ExtensionContext) => { + await window.withProgress( + { + location: ProgressLocation.Notification, + title: "Updating Biome version", + cancellable: false, + }, + async () => { + const versions = await getVersions(context); + const version = versions[0]; + await commands.executeCommand(Commands.StopServer); + await download(version, context); + await commands.executeCommand(Commands.RestartLspServer); + }, + ); +}; + +/** + * Download the Biome CLI from GitHub + * + * @param version The version to download + */ +const download = async (version: string, context: ExtensionContext) => { + const releases = (await ( + await fetch( + `https://api.github.com/repos/biomejs/biome/releases/tags/cli/v${version}`, + ) + ).json()) as { + assets: { name: string; browser_download_url: string }[]; + }; + + const platformArch = `${process.platform}-${process.arch}`; + + // Find the asset for the current platform + const asset = releases.assets.find( + (asset) => + asset.name === + `biome-${platformArch}${process.platform === "win32" ? ".exe" : ""}`, + ); + + if (!asset) { + window.showErrorMessage( + `The specified version is not available for your platform/architecture (${platformArch}).`, + ); + return; + } + + let bin: ArrayBuffer; + try { + const blob = await fetch(asset.browser_download_url); + bin = await blob.arrayBuffer(); + } catch { + window.showErrorMessage( + `Could not download the binary for your platform/architecture (${platformArch}).`, + ); + return; + } + + // Write binary file to disk + await workspace.fs.writeFile( + Uri.joinPath( + context.globalStorageUri, + "server", + `biome${process.platform === "win32" ? ".exe" : ""}`, + ), + new Uint8Array(bin), + ); + + // Make biome executable + chmodSync( + Uri.joinPath( + context.globalStorageUri, + "server", + `biome${process.platform === "win32" ? ".exe" : ""}`, + ).fsPath, + 0o755, + ); + + // Record latest version + await context.globalState.update("bundled_biome_version", version); +}; + +/** + * Display the VS Code prompt for selection the version + */ +const askVersion = async (versions: string[]): Promise => { + const options = versions.map((version, index) => ({ + label: version, + description: index === 0 ? "(latest)" : "", + })); + + const result = await window.showQuickPick(options, { + placeHolder: "Select the version of the biome CLI to install", + }); + + return result?.label; +}; + +/** + * Retrieves the list of versions of the CLI. + * + * The calls to the API are cached for 1 hour to prevent hitting the rate limit. + */ +export const getVersions = async ( + context: ExtensionContext, +): Promise => { + const cachedVersions = context.globalState.get<{ + expires_at: Date; + versions: string[]; + }>("biome_versions_cache"); + + // If the cache exists and is still valid, return it + if (cachedVersions && new Date(cachedVersions.expires_at) > new Date()) { + return cachedVersions.versions; + } + + const releases = (await ( + await fetch( + "https://api.github.com/repos/biomejs/biome/releases?per_page=100", + ) + ).json()) as { tag_name: string }[]; + + const versions = releases + .filter((release) => release.tag_name.startsWith("cli/")) + .map((release) => release.tag_name.replace("cli/", "")) + .map((release) => coerce(release)) + .sort((a, b) => rcompare(a, b)) + .filter((release) => release?.version !== null) + .map((release) => release?.version); + + // Cache the result for 1 hour + await context.globalState.update("biome_versions_cache", { + expires_at: new Date(Date.now() + 60 * 60 * 1000), + versions, + }); + + return versions; +}; diff --git a/src/main.ts b/src/main.ts index 6c2489d3..80e9726f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,12 +1,13 @@ import { type ChildProcess, spawn } from "child_process"; import { type Socket, connect } from "net"; import { dirname, isAbsolute } from "path"; -import { TextDecoder, promisify } from "util"; +import { promisify } from "util"; import { ExtensionContext, OutputChannel, TextEditor, Uri, + commands, languages, window, workspace, @@ -27,6 +28,7 @@ import { setContextValue } from "./utils"; import resolveImpl = require("resolve/async"); import { createRequire } from "module"; import type * as resolve from "resolve"; +import { selectAndDownload, updateToLatest } from "./downloader"; const resolveAsync = promisify( resolveImpl, @@ -44,6 +46,36 @@ export async function activate(context: ExtensionContext) { .getConfiguration("biome") .get("requireConfiguration"); + commands.registerCommand(Commands.StopServer, async () => { + if (!client) { + return; + } + try { + await client.stop(); + } catch (error) { + client.error("Stopping client failed", error, "force"); + } + }); + + commands.registerCommand(Commands.RestartLspServer, async () => { + if (!client) { + return; + } + try { + if (client.isRunning()) { + await client.restart(); + } else { + await client.start(); + } + } catch (error) { + client.error("Restarting client failed", error, "force"); + } + }); + + commands.registerCommand("biome.clearVersionsCache", async () => { + await context.globalState.update("biome_versions_cache", undefined); + }); + // If the extension requires a configuration file to be present, we attempt to // locate it. If a config file cannot be found, we do not go any further. if (requiresConfiguration) { @@ -61,25 +93,37 @@ export async function activate(context: ExtensionContext) { ); } - const command = await getServerPath(context, outputChannel); + let server = await getServerPath(context, outputChannel); - if (!command) { - await window.showErrorMessage( - "The Biome extensions doesn't ship with prebuilt binaries for your platform yet. " + - "You can still use it by cloning the biomejs/biome repo from GitHub to build the LSP " + - "yourself and use it with this extension with the biome.lspBin setting", + if (!server.command) { + const action = await window.showWarningMessage( + "Could not find Biome in your dependencies. Either add the @biomejs/biome package to your dependencies, or download the Biome binary.", + "Ok", + "Download Biome", ); - return; + + if (action === "Download Biome") { + if (!(await selectAndDownload(context))) { + return; + } + } + + server = await getServerPath(context, outputChannel); + + if (!server.command) { + return; + } } - outputChannel.appendLine(`Using Biome from ${command}`); + outputChannel.appendLine(`Using Biome from ${server.command}`); - const statusBar = new StatusBar(); + const statusBar = new StatusBar(context); + await statusBar.setUsingBundledBiome(server.bundled); const serverOptions: ServerOptions = createMessageTransports.bind( undefined, outputChannel, - command, + server.command, ); const documentSelector: DocumentFilter[] = [ @@ -112,21 +156,31 @@ export async function activate(context: ExtensionContext) { // we are now in a biome project setContextValue(IN_BIOME_PROJECT, true); + commands.registerCommand(Commands.UpdateBiome, async (version: string) => { + const result = await window.showInformationMessage( + `Are you sure you want to update Biome (bundled) to ${version} ?`, + { + modal: true, + }, + "Update", + "Cancel", + ); + + if (result === "Update") { + await updateToLatest(context); + statusBar.checkForUpdates(); + } + }); + + commands.registerCommand(Commands.ChangeVersion, async () => { + await selectAndDownload(context); + statusBar.checkForUpdates(); + }); + session.registerCommand(Commands.SyntaxTree, syntaxTree(session)); session.registerCommand(Commands.ServerStatus, () => { traceOutputChannel.show(); }); - session.registerCommand(Commands.RestartLspServer, async () => { - try { - if (client.isRunning()) { - await client.restart(); - } else { - await client.start(); - } - } catch (error) { - client.error("Restarting client failed", error, "force"); - } - }); context.subscriptions.push( client.onDidChangeState((evt) => { @@ -199,29 +253,40 @@ const PLATFORMS: PlatformTriplets = { async function getServerPath( context: ExtensionContext, outputChannel: OutputChannel, -): Promise { +): Promise<{ bundled: boolean; command: string } | undefined> { // Only allow the bundled Biome binary in untrusted workspaces if (!workspace.isTrusted) { - return getBundledBinary(context, outputChannel); + return { + bundled: true, + command: await getBundledBinary(context, outputChannel), + }; } if (process.env.DEBUG_SERVER_PATH) { outputChannel.appendLine( `Biome DEBUG_SERVER_PATH detected: ${process.env.DEBUG_SERVER_PATH}`, ); - return process.env.DEBUG_SERVER_PATH; + return { + bundled: false, + command: process.env.DEBUG_SERVER_PATH, + }; } const config = workspace.getConfiguration(); const explicitPath = config.get("biome.lspBin"); if (typeof explicitPath === "string" && explicitPath !== "") { - return getWorkspaceRelativePath(explicitPath); + return { + bundled: false, + command: await getWorkspaceRelativePath(explicitPath), + }; } - return ( - (await getWorkspaceDependency(outputChannel)) ?? - (await getBundledBinary(context, outputChannel)) - ); + const workspaceDependency = await getWorkspaceDependency(outputChannel); + return { + bundled: workspaceDependency === undefined, + command: + workspaceDependency ?? (await getBundledBinary(context, outputChannel)), + }; } // Resolve `path` as relative to the workspace root @@ -286,18 +351,11 @@ async function getBundledBinary( context: ExtensionContext, outputChannel: OutputChannel, ) { - const triplet = PLATFORMS[process.platform]?.[process.arch]?.triplet; - if (!triplet) { - outputChannel.appendLine( - `Unsupported platform ${process.platform} ${process.arch}`, - ); - return undefined; - } - - const binaryExt = triplet.includes("windows") ? ".exe" : ""; - const binaryName = `biome${binaryExt}`; - - const bundlePath = Uri.joinPath(context.extensionUri, "server", binaryName); + const bundlePath = Uri.joinPath( + context.globalStorageUri, + "server", + `biome${process.platform === "win32" ? ".exe" : ""}`, + ); const bundleExists = await fileExists(bundlePath); if (!bundleExists) { outputChannel.appendLine( diff --git a/src/statusBar.ts b/src/statusBar.ts index ce0c560e..26c18f4c 100644 --- a/src/statusBar.ts +++ b/src/statusBar.ts @@ -1,7 +1,15 @@ -import { StatusBarAlignment, StatusBarItem, ThemeColor, window } from "vscode"; +import { gt } from "semver"; +import { + ExtensionContext, + StatusBarAlignment, + StatusBarItem, + ThemeColor, + window, +} from "vscode"; import { State } from "vscode-languageclient"; import { LanguageClient } from "vscode-languageclient/node"; import { Commands } from "./commands"; +import { getVersions } from "./downloader"; /** * Enumeration of all the status the extension can display @@ -21,13 +29,15 @@ enum Status { } export class StatusBar { + private usingBundledBiome = false; private statusBarItem: StatusBarItem; + private statusBarUpdateItem: StatusBarItem; private serverState: State = State.Starting; private isActive = false; private serverVersion = ""; - constructor() { + constructor(private readonly context: ExtensionContext) { this.statusBarItem = window.createStatusBarItem( "biome.status", StatusBarAlignment.Right, @@ -36,7 +46,15 @@ export class StatusBar { this.statusBarItem.name = "Biome"; this.statusBarItem.command = Commands.ServerStatus; + + this.statusBarUpdateItem = window.createStatusBarItem( + "biome.update", + StatusBarAlignment.Right, + -2, + ); + this.update(); + this.checkForUpdates(); } public setServerState(client: LanguageClient, state: State) { @@ -70,8 +88,9 @@ export class StatusBar { status = Status.Error; } - this.statusBarItem.text = - `$(${status}) Biome ${this.serverVersion}`.trimEnd(); + this.statusBarItem.text = `$(${status}) Biome ${this.serverVersion} ${ + this.usingBundledBiome ? "(bundled)" : "" + }`.trimEnd(); switch (status) { case Status.Pending: { @@ -125,10 +144,54 @@ export class StatusBar { } } + if (this.usingBundledBiome) { + this.statusBarItem.command = { + title: "Change bundled Biome version", + command: Commands.ChangeVersion, + }; + } else { + this.statusBarItem.command = Commands.ServerStatus; + } + this.statusBarItem.show(); } public hide() { this.statusBarItem.hide(); } + + public async setUsingBundledBiome(usingBundledBiome: boolean) { + this.usingBundledBiome = usingBundledBiome; + await this.checkForUpdates(); + } + + public async checkForUpdates() { + const latestVersion = (await getVersions(this.context))[0]; + const hasUpdates = gt( + latestVersion, + this.context.globalState.get("bundled_biome_version") ?? "0.0.0", + ); + + if (this.usingBundledBiome && hasUpdates) { + this.statusBarUpdateItem.name = "Biome update"; + this.statusBarUpdateItem.text = + "Biome update available $(cloud-download)"; + + this.statusBarUpdateItem.tooltip = "Click to update Biome"; + this.statusBarUpdateItem.backgroundColor = new ThemeColor( + "statusBarItem.warningBackground", + ); + this.statusBarUpdateItem.show(); + + this.statusBarUpdateItem.command = { + title: "Update Biome", + command: Commands.UpdateBiome, + arguments: [latestVersion], + }; + } else { + this.statusBarUpdateItem.hide(); + } + + this.update(); + } }