diff --git a/CHANGELOG.md b/CHANGELOG.md index 8725a127..7a99d9ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ and configFile are provided. - Add `coder.disableUpdateNotifications` setting to disable workspace template update notifications. +- Coder output panel enhancements: All log entries now include timestamps, and you + can filter messages by log level in the panel. ## [v1.9.2](https://github.com/coder/vscode-coder/releases/tag/v1.9.2) 2025-06-25 diff --git a/src/api.ts b/src/api.ts index 22de2618..96b49673 100644 --- a/src/api.ts +++ b/src/api.ts @@ -105,7 +105,7 @@ export function makeCoderSdk( restClient.getAxiosInstance().interceptors.response.use( (r) => r, async (err) => { - throw await CertificateError.maybeWrap(err, baseUrl, storage); + throw await CertificateError.maybeWrap(err, baseUrl, storage.output); }, ); diff --git a/src/commands.ts b/src/commands.ts index d6734376..4373228c 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -245,8 +245,9 @@ export class Commands { } catch (err) { const message = getErrorMessage(err, "no response from the server"); if (isAutologin) { - this.storage.writeToCoderOutputChannel( - `Failed to log in to Coder server: ${message}`, + this.storage.output.warn( + "Failed to log in to Coder server:", + message, ); } else { this.vscodeProposed.window.showErrorMessage( diff --git a/src/error.test.ts b/src/error.test.ts index 3c4a50c3..4bbb9395 100644 --- a/src/error.test.ts +++ b/src/error.test.ts @@ -4,6 +4,7 @@ import https from "https"; import * as path from "path"; import { afterAll, beforeAll, it, expect, vi } from "vitest"; import { CertificateError, X509_ERR, X509_ERR_CODE } from "./error"; +import { Logger } from "./logger"; // Before each test we make a request to sanity check that we really get the // error we are expecting, then we run it through CertificateError. @@ -23,10 +24,16 @@ beforeAll(() => { }); }); -const logger = { - writeToCoderOutputChannel(message: string) { - throw new Error(message); - }, +const throwingLog = (message: string) => { + throw new Error(message); +}; + +const logger: Logger = { + trace: throwingLog, + debug: throwingLog, + info: throwingLog, + warn: throwingLog, + error: throwingLog, }; const disposers: (() => void)[] = []; diff --git a/src/error.ts b/src/error.ts index 53cc3389..5fa07294 100644 --- a/src/error.ts +++ b/src/error.ts @@ -3,6 +3,7 @@ import { isApiError, isApiErrorResponse } from "coder/site/src/api/errors"; import * as forge from "node-forge"; import * as tls from "tls"; import * as vscode from "vscode"; +import { Logger } from "./logger"; // X509_ERR_CODE represents error codes as returned from BoringSSL/OpenSSL. export enum X509_ERR_CODE { @@ -21,10 +22,6 @@ export enum X509_ERR { UNTRUSTED_CHAIN = "Your Coder deployment's certificate chain does not appear to be trusted by this system. The root of the certificate chain must be added to this system's trust store. ", } -export interface Logger { - writeToCoderOutputChannel(message: string): void; -} - interface KeyUsage { keyCertSign: boolean; } @@ -59,9 +56,7 @@ export class CertificateError extends Error { await CertificateError.determineVerifyErrorCause(address); return new CertificateError(err.message, cause); } catch (error) { - logger.writeToCoderOutputChannel( - `Failed to parse certificate from ${address}: ${error}`, - ); + logger.warn(`Failed to parse certificate from ${address}`, error); break; } case X509_ERR_CODE.DEPTH_ZERO_SELF_SIGNED_CERT: diff --git a/src/extension.ts b/src/extension.ts index 05eb7319..96f110c5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -47,7 +47,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ); } - const output = vscode.window.createOutputChannel("Coder"); + const output = vscode.window.createOutputChannel("Coder", { log: true }); const storage = new Storage( output, ctx.globalState, @@ -317,7 +317,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } } catch (ex) { if (ex instanceof CertificateError) { - storage.writeToCoderOutputChannel(ex.x509Err || ex.message); + storage.output.warn(ex.x509Err || ex.message); await ex.showModal("Failed to open workspace"); } else if (isAxiosError(ex)) { const msg = getErrorMessage(ex, "None"); @@ -326,7 +326,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const method = ex.config?.method?.toUpperCase() || "request"; const status = ex.response?.status || "None"; const message = `API ${method} to '${urlString}' failed.\nStatus code: ${status}\nMessage: ${msg}\nDetail: ${detail}`; - storage.writeToCoderOutputChannel(message); + storage.output.warn(message); await vscodeProposed.window.showErrorMessage( "Failed to open workspace", { @@ -337,7 +337,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ); } else { const message = errToStr(ex, "No error message was provided"); - storage.writeToCoderOutputChannel(message); + storage.output.warn(message); await vscodeProposed.window.showErrorMessage( "Failed to open workspace", { @@ -356,14 +356,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // See if the plugin client is authenticated. const baseUrl = restClient.getAxiosInstance().defaults.baseURL; if (baseUrl) { - storage.writeToCoderOutputChannel( - `Logged in to ${baseUrl}; checking credentials`, - ); + storage.output.info(`Logged in to ${baseUrl}; checking credentials`); restClient .getAuthenticatedUser() .then(async (user) => { if (user && user.roles) { - storage.writeToCoderOutputChannel("Credentials are valid"); + storage.output.info("Credentials are valid"); vscode.commands.executeCommand( "setContext", "coder.authenticated", @@ -381,17 +379,13 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { myWorkspacesProvider.fetchAndRefresh(); allWorkspacesProvider.fetchAndRefresh(); } else { - storage.writeToCoderOutputChannel( - `No error, but got unexpected response: ${user}`, - ); + storage.output.warn("No error, but got unexpected response", user); } }) .catch((error) => { // This should be a failure to make the request, like the header command // errored. - storage.writeToCoderOutputChannel( - `Failed to check user authentication: ${error.message}`, - ); + storage.output.warn("Failed to check user authentication", error); vscode.window.showErrorMessage( `Failed to check user authentication: ${error.message}`, ); @@ -400,7 +394,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { vscode.commands.executeCommand("setContext", "coder.loaded", true); }); } else { - storage.writeToCoderOutputChannel("Not currently logged in"); + storage.output.info("Not currently logged in"); vscode.commands.executeCommand("setContext", "coder.loaded", true); // Handle autologin, if not already logged in. diff --git a/src/headers.test.ts b/src/headers.test.ts index 5cf333f5..669a8d74 100644 --- a/src/headers.test.ts +++ b/src/headers.test.ts @@ -2,11 +2,14 @@ import * as os from "os"; import { it, expect, describe, beforeEach, afterEach, vi } from "vitest"; import { WorkspaceConfiguration } from "vscode"; import { getHeaderCommand, getHeaders } from "./headers"; - -const logger = { - writeToCoderOutputChannel() { - // no-op - }, +import { Logger } from "./logger"; + +const logger: Logger = { + trace: () => {}, + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, }; it("should return no headers", async () => { diff --git a/src/headers.ts b/src/headers.ts index 4d4b5f44..e61bfa81 100644 --- a/src/headers.ts +++ b/src/headers.ts @@ -2,12 +2,9 @@ import * as cp from "child_process"; import * as os from "os"; import * as util from "util"; import type { WorkspaceConfiguration } from "vscode"; +import { Logger } from "./logger"; import { escapeCommandArg } from "./util"; -export interface Logger { - writeToCoderOutputChannel(message: string): void; -} - interface ExecException { code?: number; stderr?: string; @@ -78,11 +75,9 @@ export async function getHeaders( }); } catch (error) { if (isExecException(error)) { - logger.writeToCoderOutputChannel( - `Header command exited unexpectedly with code ${error.code}`, - ); - logger.writeToCoderOutputChannel(`stdout: ${error.stdout}`); - logger.writeToCoderOutputChannel(`stderr: ${error.stderr}`); + logger.warn("Header command exited unexpectedly with code", error.code); + logger.warn("stdout:", error.stdout); + logger.warn("stderr:", error.stderr); throw new Error( `Header command exited unexpectedly with code ${error.code}`, ); diff --git a/src/inbox.ts b/src/inbox.ts index 709dfbd8..0ec79720 100644 --- a/src/inbox.ts +++ b/src/inbox.ts @@ -63,7 +63,7 @@ export class Inbox implements vscode.Disposable { }); this.#socket.on("open", () => { - this.#storage.writeToCoderOutputChannel("Listening to Coder Inbox"); + this.#storage.output.info("Listening to Coder Inbox"); }); this.#socket.on("error", (error) => { @@ -86,9 +86,7 @@ export class Inbox implements vscode.Disposable { dispose() { if (!this.#disposed) { - this.#storage.writeToCoderOutputChannel( - "No longer listening to Coder Inbox", - ); + this.#storage.output.info("No longer listening to Coder Inbox"); this.#socket.close(); this.#disposed = true; } @@ -99,6 +97,6 @@ export class Inbox implements vscode.Disposable { error, "Got empty error while monitoring Coder Inbox", ); - this.#storage.writeToCoderOutputChannel(message); + this.#storage.output.error(message); } } diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 00000000..30bf0ec6 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,7 @@ +export interface Logger { + trace(message: string, ...args: unknown[]): void; + debug(message: string, ...args: unknown[]): void; + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; +} diff --git a/src/remote.ts b/src/remote.ts index 6397ba08..2d80a55b 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -117,9 +117,7 @@ export class Remote { case "starting": case "stopping": writeEmitter = initWriteEmitterAndTerminal(); - this.storage.writeToCoderOutputChannel( - `Waiting for ${workspaceName}...`, - ); + this.storage.output.info(`Waiting for ${workspaceName}...`); workspace = await waitForBuild( restClient, writeEmitter, @@ -131,9 +129,7 @@ export class Remote { return undefined; } writeEmitter = initWriteEmitterAndTerminal(); - this.storage.writeToCoderOutputChannel( - `Starting ${workspaceName}...`, - ); + this.storage.output.info(`Starting ${workspaceName}...`); workspace = await startWorkspaceIfStoppedOrFailed( restClient, globalConfigDir, @@ -150,9 +146,7 @@ export class Remote { return undefined; } writeEmitter = initWriteEmitterAndTerminal(); - this.storage.writeToCoderOutputChannel( - `Starting ${workspaceName}...`, - ); + this.storage.output.info(`Starting ${workspaceName}...`); workspace = await startWorkspaceIfStoppedOrFailed( restClient, globalConfigDir, @@ -175,8 +169,9 @@ export class Remote { ); } } - this.storage.writeToCoderOutputChannel( - `${workspaceName} status is now ${workspace.latest_build.status}`, + this.storage.output.info( + `${workspaceName} status is now`, + workspace.latest_build.status, ); } return workspace; @@ -243,12 +238,8 @@ export class Remote { return; } - this.storage.writeToCoderOutputChannel( - `Using deployment URL: ${baseUrlRaw}`, - ); - this.storage.writeToCoderOutputChannel( - `Using deployment label: ${parts.label || "n/a"}`, - ); + this.storage.output.info("Using deployment URL", baseUrlRaw); + this.storage.output.info("Using deployment label", parts.label || "n/a"); // We could use the plugin client, but it is possible for the user to log // out or log into a different deployment while still connected, which would @@ -314,15 +305,14 @@ export class Remote { // Next is to find the workspace from the URI scheme provided. let workspace: Workspace; try { - this.storage.writeToCoderOutputChannel( - `Looking for workspace ${workspaceName}...`, - ); + this.storage.output.info(`Looking for workspace ${workspaceName}...`); workspace = await workspaceRestClient.getWorkspaceByOwnerAndName( parts.username, parts.workspace, ); - this.storage.writeToCoderOutputChannel( - `Found workspace ${workspaceName} with status ${workspace.latest_build.status}`, + this.storage.output.info( + `Found workspace ${workspaceName} with status`, + workspace.latest_build.status, ); this.commands.workspace = workspace; } catch (error) { @@ -404,9 +394,7 @@ export class Remote { this.commands.workspace = workspace; // Pick an agent. - this.storage.writeToCoderOutputChannel( - `Finding agent for ${workspaceName}...`, - ); + this.storage.output.info(`Finding agent for ${workspaceName}...`); const gotAgent = await this.commands.maybeAskAgent(workspace, parts.agent); if (!gotAgent) { // User declined to pick an agent. @@ -414,12 +402,13 @@ export class Remote { return; } let agent = gotAgent; // Reassign so it cannot be undefined in callbacks. - this.storage.writeToCoderOutputChannel( - `Found agent ${agent.name} with status ${agent.status}`, + this.storage.output.info( + `Found agent ${agent.name} with status`, + agent.status, ); // Do some janky setting manipulation. - this.storage.writeToCoderOutputChannel("Modifying settings..."); + this.storage.output.info("Modifying settings..."); const remotePlatforms = this.vscodeProposed.workspace .getConfiguration() .get>("remote.SSH.remotePlatform", {}); @@ -491,9 +480,7 @@ export class Remote { // write here is not necessarily catastrophic since the user will be // asked for the platform and the default timeout might be sufficient. mungedPlatforms = mungedConnTimeout = false; - this.storage.writeToCoderOutputChannel( - `Failed to configure settings: ${ex}`, - ); + this.storage.output.warn("Failed to configure settings", ex); } } @@ -521,9 +508,7 @@ export class Remote { // Wait for the agent to connect. if (agent.status === "connecting") { - this.storage.writeToCoderOutputChannel( - `Waiting for ${workspaceName}/${agent.name}...`, - ); + this.storage.output.info(`Waiting for ${workspaceName}/${agent.name}...`); await vscode.window.withProgress( { title: "Waiting for the agent to connect...", @@ -552,8 +537,9 @@ export class Remote { }); }, ); - this.storage.writeToCoderOutputChannel( - `Agent ${agent.name} status is now ${agent.status}`, + this.storage.output.info( + `Agent ${agent.name} status is now`, + agent.status, ); } @@ -584,7 +570,7 @@ export class Remote { // If we didn't write to the SSH config file, connecting would fail with // "Host not found". try { - this.storage.writeToCoderOutputChannel("Updating SSH config..."); + this.storage.output.info("Updating SSH config..."); await this.updateSSHConfig( workspaceRestClient, parts.label, @@ -594,9 +580,7 @@ export class Remote { featureSet, ); } catch (error) { - this.storage.writeToCoderOutputChannel( - `Failed to configure SSH: ${error}`, - ); + this.storage.output.warn("Failed to configure SSH", error); throw error; } @@ -636,7 +620,7 @@ export class Remote { }), ); - this.storage.writeToCoderOutputChannel("Remote setup complete"); + this.storage.output.info("Remote setup complete"); // Returning the URL and token allows the plugin to authenticate its own // client, for example to display the list of workspaces belonging to this @@ -677,8 +661,9 @@ export class Remote { return ""; } await fs.mkdir(logDir, { recursive: true }); - this.storage.writeToCoderOutputChannel( - `SSH proxy diagnostics are being written to ${logDir}`, + this.storage.output.info( + "SSH proxy diagnostics are being written to", + logDir, ); return ` --log-dir ${escapeCommandArg(logDir)}`; } diff --git a/src/storage.ts b/src/storage.ts index 8453bc5d..206dbce3 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -14,7 +14,7 @@ const MAX_URLS = 10; export class Storage { constructor( - private readonly output: vscode.OutputChannel, + public readonly output: vscode.LogOutputChannel, private readonly memento: vscode.Memento, private readonly secrets: vscode.SecretStorage, private readonly globalStorageUri: vscode.Uri, @@ -129,57 +129,50 @@ export class Storage { const enableDownloads = vscode.workspace.getConfiguration().get("coder.enableDownloads") !== false; - this.output.appendLine( - `Downloads are ${enableDownloads ? "enabled" : "disabled"}`, - ); + this.output.info("Downloads are", enableDownloads ? "enabled" : "disabled"); // Get the build info to compare with the existing binary version, if any, // and to log for debugging. const buildInfo = await restClient.getBuildInfo(); - this.output.appendLine(`Got server version: ${buildInfo.version}`); + this.output.info("Got server version", buildInfo.version); // Check if there is an existing binary and whether it looks valid. If it // is valid and matches the server, or if it does not match the server but // downloads are disabled, we can return early. const binPath = path.join(this.getBinaryCachePath(label), cli.name()); - this.output.appendLine(`Using binary path: ${binPath}`); + this.output.info("Using binary path", binPath); const stat = await cli.stat(binPath); if (stat === undefined) { - this.output.appendLine("No existing binary found, starting download"); + this.output.info("No existing binary found, starting download"); } else { - this.output.appendLine( - `Existing binary size is ${prettyBytes(stat.size)}`, - ); + this.output.info("Existing binary size is", prettyBytes(stat.size)); try { const version = await cli.version(binPath); - this.output.appendLine(`Existing binary version is ${version}`); + this.output.info("Existing binary version is", version); // If we have the right version we can avoid the request entirely. if (version === buildInfo.version) { - this.output.appendLine( + this.output.info( "Using existing binary since it matches the server version", ); return binPath; } else if (!enableDownloads) { - this.output.appendLine( + this.output.info( "Using existing binary even though it does not match the server version because downloads are disabled", ); return binPath; } - this.output.appendLine( + this.output.info( "Downloading since existing binary does not match the server version", ); } catch (error) { - this.output.appendLine( - `Unable to get version of existing binary: ${error}`, + this.output.warn( + `Unable to get version of existing binary: ${error}. Downloading new binary instead`, ); - this.output.appendLine("Downloading new binary instead"); } } if (!enableDownloads) { - this.output.appendLine( - "Unable to download CLI because downloads are disabled", - ); + this.output.warn("Unable to download CLI because downloads are disabled"); throw new Error("Unable to download CLI because downloads are disabled"); } @@ -187,9 +180,9 @@ export class Storage { const removed = await cli.rmOld(binPath); removed.forEach(({ fileName, error }) => { if (error) { - this.output.appendLine(`Failed to remove ${fileName}: ${error}`); + this.output.warn("Failed to remove", fileName, error); } else { - this.output.appendLine(`Removed ${fileName}`); + this.output.info("Removed", fileName); } }); @@ -202,12 +195,12 @@ export class Storage { configSource && String(configSource).trim().length > 0 ? String(configSource) : "/bin/" + binName; - this.output.appendLine(`Downloading binary from: ${binSource}`); + this.output.info("Downloading binary from", binSource); // Ideally we already caught that this was the right version and returned // early, but just in case set the ETag. const etag = stat !== undefined ? await cli.eTag(binPath) : ""; - this.output.appendLine(`Using ETag: ${etag}`); + this.output.info("Using ETag", etag); // Make the download request. const controller = new AbortController(); @@ -223,20 +216,19 @@ export class Storage { // Ignore all errors so we can catch a 404! validateStatus: () => true, }); - this.output.appendLine(`Got status code ${resp.status}`); + this.output.info("Got status code", resp.status); switch (resp.status) { case 200: { const rawContentLength = resp.headers["content-length"]; const contentLength = Number.parseInt(rawContentLength); if (Number.isNaN(contentLength)) { - this.output.appendLine( - `Got invalid or missing content length: ${rawContentLength}`, + this.output.warn( + "Got invalid or missing content length", + rawContentLength, ); } else { - this.output.appendLine( - `Got content length: ${prettyBytes(contentLength)}`, - ); + this.output.info("Got content length", prettyBytes(contentLength)); } // Download to a temporary file. @@ -317,12 +309,13 @@ export class Storage { // False means the user canceled, although in practice it appears we // would not get this far because VS Code already throws on cancelation. if (!completed) { - this.output.appendLine("User aborted download"); + this.output.warn("User aborted download"); throw new Error("User aborted download"); } - this.output.appendLine( - `Downloaded ${prettyBytes(written)} to ${path.basename(tempFile)}`, + this.output.info( + `Downloaded ${prettyBytes(written)} to`, + path.basename(tempFile), ); // Move the old binary to a backup location first, just in case. And, @@ -331,35 +324,33 @@ export class Storage { if (stat !== undefined) { const oldBinPath = binPath + ".old-" + Math.random().toString(36).substring(8); - this.output.appendLine( - `Moving existing binary to ${path.basename(oldBinPath)}`, + this.output.info( + "Moving existing binary to", + path.basename(oldBinPath), ); await fs.rename(binPath, oldBinPath); } // Then move the temporary binary into the right place. - this.output.appendLine( - `Moving downloaded file to ${path.basename(binPath)}`, - ); + this.output.info("Moving downloaded file to", path.basename(binPath)); await fs.mkdir(path.dirname(binPath), { recursive: true }); await fs.rename(tempFile, binPath); // For debugging, to see if the binary only partially downloaded. const newStat = await cli.stat(binPath); - this.output.appendLine( - `Downloaded binary size is ${prettyBytes(newStat?.size || 0)}`, + this.output.info( + "Downloaded binary size is", + prettyBytes(newStat?.size || 0), ); // Make sure we can execute this new binary. const version = await cli.version(binPath); - this.output.appendLine(`Downloaded binary version is ${version}`); + this.output.info("Downloaded binary version is", version); return binPath; } case 304: { - this.output.appendLine( - "Using existing binary since server returned a 304", - ); + this.output.info("Using existing binary since server returned a 304"); return binPath; } case 404: { @@ -507,14 +498,6 @@ export class Storage { : path.join(this.globalStorageUri.fsPath, "url"); } - public writeToCoderOutputChannel(message: string) { - this.output.appendLine(`[${new Date().toISOString()}] ${message}`); - // We don't want to focus on the output here, because the - // Coder server is designed to restart gracefully for users - // because of P2P connections, and we don't want to draw - // attention to it. - } - /** * Configure the CLI for the deployment with the provided label. * @@ -614,7 +597,7 @@ export class Storage { return getHeaders( url, getHeaderCommand(vscode.workspace.getConfiguration()), - this, + this.output, ); } } diff --git a/src/workspaceMonitor.ts b/src/workspaceMonitor.ts index 189d444a..d1eaf704 100644 --- a/src/workspaceMonitor.ts +++ b/src/workspaceMonitor.ts @@ -42,7 +42,7 @@ export class WorkspaceMonitor implements vscode.Disposable { this.name = `${workspace.owner_name}/${workspace.name}`; const url = this.restClient.getAxiosInstance().defaults.baseURL; const watchUrl = new URL(`${url}/api/v2/workspaces/${workspace.id}/watch`); - this.storage.writeToCoderOutputChannel(`Monitoring ${this.name}...`); + this.storage.output.info(`Monitoring ${this.name}...`); const eventSource = new EventSource(watchUrl.toString(), { fetch: createStreamingFetchAdapter(this.restClient.getAxiosInstance()), @@ -85,7 +85,7 @@ export class WorkspaceMonitor implements vscode.Disposable { */ dispose() { if (!this.disposed) { - this.storage.writeToCoderOutputChannel(`Unmonitoring ${this.name}...`); + this.storage.output.info(`Unmonitoring ${this.name}...`); this.statusBarItem.dispose(); this.eventSource.close(); this.disposed = true; @@ -211,7 +211,7 @@ export class WorkspaceMonitor implements vscode.Disposable { error, "Got empty error while monitoring workspace", ); - this.storage.writeToCoderOutputChannel(message); + this.storage.output.error(message); } private updateContext(workspace: Workspace) { diff --git a/src/workspacesProvider.ts b/src/workspacesProvider.ts index a77b31ad..64b74e7d 100644 --- a/src/workspacesProvider.ts +++ b/src/workspacesProvider.ts @@ -96,7 +96,7 @@ export class WorkspaceProvider */ private async fetch(): Promise { if (vscode.env.logLevel <= vscode.LogLevel.Debug) { - this.storage.writeToCoderOutputChannel( + this.storage.output.info( `Fetching workspaces: ${this.getWorkspacesQuery || "no filter"}...`, ); }