From f3a23c6b3fbcae2342ce5f0a10a61c54f29d6902 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 10 Sep 2025 15:20:34 +0300 Subject: [PATCH 01/12] Move path resolution from storage.ts --- src/commands.ts | 7 ++- src/core/pathResolver.ts | 103 ++++++++++++++++++++++++++++++++++ src/extension.ts | 10 +++- src/pgp.ts | 8 +-- src/remote.ts | 25 +++++---- src/storage.ts | 116 ++++----------------------------------- 6 files changed, 144 insertions(+), 125 deletions(-) create mode 100644 src/core/pathResolver.ts diff --git a/src/commands.ts b/src/commands.ts index 9961c82b..df2679dd 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -5,11 +5,11 @@ import { Workspace, WorkspaceAgent, } from "coder/site/src/api/typesGenerated"; -import path from "node:path"; import * as vscode from "vscode"; import { createWorkspaceIdentifier, extractAgents } from "./api/api-helper"; import { CoderApi } from "./api/coderApi"; import { needToken } from "./api/utils"; +import { PathResolver } from "./core/pathResolver"; import { CertificateError } from "./error"; import { getGlobalFlags } from "./globalFlags"; import { Storage } from "./storage"; @@ -36,6 +36,7 @@ export class Commands { private readonly vscodeProposed: typeof vscode, private readonly restClient: Api, private readonly storage: Storage, + private readonly pathResolver: PathResolver, ) {} /** @@ -512,8 +513,8 @@ export class Commands { toSafeHost(url), ); - const configDir = path.dirname( - this.storage.getSessionTokenPath(toSafeHost(url)), + const configDir = this.pathResolver.getGlobalConfigDir( + toSafeHost(url), ); const globalFlags = getGlobalFlags( vscode.workspace.getConfiguration(), diff --git a/src/core/pathResolver.ts b/src/core/pathResolver.ts new file mode 100644 index 00000000..11ecd862 --- /dev/null +++ b/src/core/pathResolver.ts @@ -0,0 +1,103 @@ +import * as path from "path"; +import * as vscode from "vscode"; + +export class PathResolver { + constructor( + private readonly basePath: string, + private readonly configurations: vscode.WorkspaceConfiguration, + ) {} + + /** + * Return the directory for the deployment with the provided label to where + * the global Coder configs are stored. + * + * If the label is empty, read the old deployment-unaware config instead. + * + * The caller must ensure this directory exists before use. + */ + public getGlobalConfigDir(label: string): string { + return label ? path.join(this.basePath, label) : this.basePath; + } + + /** + * Return the directory for a deployment with the provided label to where its + * binary is cached. + * + * If the label is empty, read the old deployment-unaware config instead. + * + * The caller must ensure this directory exists before use. + */ + public getBinaryCachePath(label: string): string { + const configPath = this.configurations.get( + "coder.binaryDestination", + ); + return configPath && configPath.trim().length > 0 + ? path.normalize(configPath) + : path.join(this.getGlobalConfigDir(label), "bin"); + } + + /** + * Return the path where network information for SSH hosts are stored. + * + * The CLI will write files here named after the process PID. + */ + public getNetworkInfoPath(): string { + return path.join(this.basePath, "net"); + } + + /** + * Return the path where log data from the connection is stored. + * + * The CLI will write files here named after the process PID. + * + * Note: This directory is not currently used. + */ + public getLogPath(): string { + return path.join(this.basePath, "log"); + } + + /** + * Get the path to the user's settings.json file. + * + * Going through VSCode's API should be preferred when modifying settings. + */ + public getUserSettingsPath(): string { + return path.join(this.basePath, "..", "..", "..", "User", "settings.json"); + } + + /** + * Return the directory for the deployment with the provided label to where + * its session token is stored. + * + * If the label is empty, read the old deployment-unaware config instead. + * + * The caller must ensure this directory exists before use. + */ + public getSessionTokenPath(label: string): string { + return path.join(this.getGlobalConfigDir(label), "session"); + } + + /** + * Return the directory for the deployment with the provided label to where + * its session token was stored by older code. + * + * If the label is empty, read the old deployment-unaware config instead. + * + * The caller must ensure this directory exists before use. + */ + public getLegacySessionTokenPath(label: string): string { + return path.join(this.getGlobalConfigDir(label), "session_token"); + } + + /** + * Return the directory for the deployment with the provided label to where + * its url is stored. + * + * If the label is empty, read the old deployment-unaware config instead. + * + * The caller must ensure this directory exists before use. + */ + public getUrlPath(label: string): string { + return path.join(this.getGlobalConfigDir(label), "url"); + } +} diff --git a/src/extension.ts b/src/extension.ts index 9d1531db..320ad70d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,6 +7,7 @@ import { errToStr } from "./api/api-helper"; import { CoderApi } from "./api/coderApi"; import { needToken } from "./api/utils"; import { Commands } from "./commands"; +import { PathResolver } from "./core/pathResolver"; import { CertificateError, getErrorDetail } from "./error"; import { Remote } from "./remote"; import { Storage } from "./storage"; @@ -48,14 +49,18 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ); } + const pathResolver = new PathResolver( + ctx.globalStorageUri.fsPath, + vscode.workspace.getConfiguration(), + ); const output = vscode.window.createOutputChannel("Coder", { log: true }); const storage = new Storage( vscodeProposed, output, ctx.globalState, ctx.secrets, - ctx.globalStorageUri, ctx.logUri, + pathResolver, ); // Try to clear this flag ASAP then pass it around if needed @@ -253,7 +258,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Register globally available commands. Many of these have visibility // controlled by contexts, see `when` in the package.json. - const commands = new Commands(vscodeProposed, client, storage); + const commands = new Commands(vscodeProposed, client, storage, pathResolver); vscode.commands.registerCommand("coder.login", commands.login.bind(commands)); vscode.commands.registerCommand( "coder.logout", @@ -312,6 +317,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { storage, commands, ctx.extensionMode, + pathResolver, ); try { const details = await remote.setup( diff --git a/src/pgp.ts b/src/pgp.ts index c707c5b4..2e82fb79 100644 --- a/src/pgp.ts +++ b/src/pgp.ts @@ -2,8 +2,8 @@ import { createReadStream, promises as fs } from "fs"; import * as openpgp from "openpgp"; import * as path from "path"; import { Readable } from "stream"; -import * as vscode from "vscode"; import { errToStr } from "./api/api-helper"; +import { Logger } from "./logging/logger"; export type Key = openpgp.Key; @@ -35,9 +35,7 @@ export class VerificationError extends Error { /** * Return the public keys bundled with the plugin. */ -export async function readPublicKeys( - logger?: vscode.LogOutputChannel, -): Promise { +export async function readPublicKeys(logger?: Logger): Promise { const keyFile = path.join(__dirname, "../pgp-public.key"); logger?.info("Reading public key", keyFile); const armoredKeys = await fs.readFile(keyFile, "utf8"); @@ -53,7 +51,7 @@ export async function verifySignature( publicKeys: openpgp.Key[], cliPath: string, signaturePath: string, - logger?: vscode.LogOutputChannel, + logger?: Logger, ): Promise { try { logger?.info("Reading signature", signaturePath); diff --git a/src/remote.ts b/src/remote.ts index 172074ee..6d231e33 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -21,6 +21,7 @@ import { needToken } from "./api/utils"; import { startWorkspaceIfStoppedOrFailed, waitForBuild } from "./api/workspace"; import * as cli from "./cliManager"; import { Commands } from "./commands"; +import { PathResolver } from "./core/pathResolver"; import { featureSetForVersion, FeatureSet } from "./featureSet"; import { getGlobalFlags } from "./globalFlags"; import { Inbox } from "./inbox"; @@ -48,6 +49,7 @@ export class Remote { private readonly storage: Storage, private readonly commands: Commands, private readonly mode: vscode.ExtensionMode, + private readonly pathResolver: PathResolver, ) {} private async confirmStart(workspaceName: string): Promise { @@ -111,9 +113,7 @@ export class Remote { title: "Waiting for workspace build...", }, async () => { - const globalConfigDir = path.dirname( - this.storage.getSessionTokenPath(label), - ); + const globalConfigDir = this.pathResolver.getGlobalConfigDir(label); while (workspace.latest_build.status !== "running") { ++attempts; switch (workspace.latest_build.status) { @@ -437,7 +437,7 @@ export class Remote { let settingsContent = "{}"; try { settingsContent = await fs.readFile( - this.storage.getUserSettingsPath(), + this.pathResolver.getUserSettingsPath(), "utf8", ); } catch (ex) { @@ -486,7 +486,10 @@ export class Remote { if (mungedPlatforms || mungedConnTimeout) { try { - await fs.writeFile(this.storage.getUserSettingsPath(), settingsContent); + await fs.writeFile( + this.pathResolver.getUserSettingsPath(), + settingsContent, + ); } catch (ex) { // This could be because the user's settings.json is read-only. This is // the case when using home-manager on NixOS, for example. Failure to @@ -765,11 +768,11 @@ export class Remote { const globalConfigs = this.globalConfigs(label); const proxyCommand = featureSet.wildcardSSH - ? `${escapeCommandArg(binaryPath)}${globalConfigs} ssh --stdio --usage-app=vscode --disable-autostart --network-info-dir ${escapeCommandArg(this.storage.getNetworkInfoPath())}${await this.formatLogArg(logDir)} --ssh-host-prefix ${hostPrefix} %h` + ? `${escapeCommandArg(binaryPath)}${globalConfigs} ssh --stdio --usage-app=vscode --disable-autostart --network-info-dir ${escapeCommandArg(this.pathResolver.getNetworkInfoPath())}${await this.formatLogArg(logDir)} --ssh-host-prefix ${hostPrefix} %h` : `${escapeCommandArg(binaryPath)}${globalConfigs} vscodessh --network-info-dir ${escapeCommandArg( - this.storage.getNetworkInfoPath(), - )}${await this.formatLogArg(logDir)} --session-token-file ${escapeCommandArg(this.storage.getSessionTokenPath(label))} --url-file ${escapeCommandArg( - this.storage.getUrlPath(label), + this.pathResolver.getNetworkInfoPath(), + )}${await this.formatLogArg(logDir)} --session-token-file ${escapeCommandArg(this.pathResolver.getSessionTokenPath(label))} --url-file ${escapeCommandArg( + this.pathResolver.getUrlPath(label), )} %h`; const sshValues: SSHValues = { @@ -828,7 +831,7 @@ export class Remote { const vscodeConfig = vscode.workspace.getConfiguration(); const args = getGlobalFlags( vscodeConfig, - path.dirname(this.storage.getSessionTokenPath(label)), + this.pathResolver.getGlobalConfigDir(label), ); return ` ${args.join(" ")}`; } @@ -841,7 +844,7 @@ export class Remote { 1000, ); const networkInfoFile = path.join( - this.storage.getNetworkInfoPath(), + this.pathResolver.getNetworkInfoPath(), `${sshPid}.json`, ); diff --git a/src/storage.ts b/src/storage.ts index 97d62ff7..3ce7e37d 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -12,6 +12,7 @@ import * as semver from "semver"; import * as vscode from "vscode"; import { errToStr } from "./api/api-helper"; import * as cli from "./cliManager"; +import { PathResolver } from "./core/pathResolver"; import { getHeaderCommand, getHeaders } from "./headers"; import * as pgp from "./pgp"; @@ -24,8 +25,8 @@ export class Storage { public readonly output: vscode.LogOutputChannel, private readonly memento: vscode.Memento, private readonly secrets: vscode.SecretStorage, - private readonly globalStorageUri: vscode.Uri, private readonly logUri: vscode.Uri, + private readonly pathResolver: PathResolver, ) {} /** @@ -171,7 +172,10 @@ export class Storage { // 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()); + const binPath = path.join( + this.pathResolver.getBinaryCachePath(label), + cli.name(), + ); this.output.info("Using binary path", binPath); const stat = await cli.stat(binPath); if (stat === undefined) { @@ -585,102 +589,6 @@ export class Storage { return status; } - /** - * Return the directory for a deployment with the provided label to where its - * binary is cached. - * - * If the label is empty, read the old deployment-unaware config instead. - * - * The caller must ensure this directory exists before use. - */ - public getBinaryCachePath(label: string): string { - const configPath = vscode.workspace - .getConfiguration() - .get("coder.binaryDestination"); - return configPath && String(configPath).trim().length > 0 - ? path.resolve(String(configPath)) - : label - ? path.join(this.globalStorageUri.fsPath, label, "bin") - : path.join(this.globalStorageUri.fsPath, "bin"); - } - - /** - * Return the path where network information for SSH hosts are stored. - * - * The CLI will write files here named after the process PID. - */ - public getNetworkInfoPath(): string { - return path.join(this.globalStorageUri.fsPath, "net"); - } - - /** - * - * Return the path where log data from the connection is stored. - * - * The CLI will write files here named after the process PID. - */ - public getLogPath(): string { - return path.join(this.globalStorageUri.fsPath, "log"); - } - - /** - * Get the path to the user's settings.json file. - * - * Going through VSCode's API should be preferred when modifying settings. - */ - public getUserSettingsPath(): string { - return path.join( - this.globalStorageUri.fsPath, - "..", - "..", - "..", - "User", - "settings.json", - ); - } - - /** - * Return the directory for the deployment with the provided label to where - * its session token is stored. - * - * If the label is empty, read the old deployment-unaware config instead. - * - * The caller must ensure this directory exists before use. - */ - public getSessionTokenPath(label: string): string { - return label - ? path.join(this.globalStorageUri.fsPath, label, "session") - : path.join(this.globalStorageUri.fsPath, "session"); - } - - /** - * Return the directory for the deployment with the provided label to where - * its session token was stored by older code. - * - * If the label is empty, read the old deployment-unaware config instead. - * - * The caller must ensure this directory exists before use. - */ - public getLegacySessionTokenPath(label: string): string { - return label - ? path.join(this.globalStorageUri.fsPath, label, "session_token") - : path.join(this.globalStorageUri.fsPath, "session_token"); - } - - /** - * Return the directory for the deployment with the provided label to where - * its url is stored. - * - * If the label is empty, read the old deployment-unaware config instead. - * - * The caller must ensure this directory exists before use. - */ - public getUrlPath(label: string): string { - return label - ? path.join(this.globalStorageUri.fsPath, label, "url") - : path.join(this.globalStorageUri.fsPath, "url"); - } - /** * Configure the CLI for the deployment with the provided label. * @@ -709,7 +617,7 @@ export class Storage { url: string | undefined, ): Promise { if (url) { - const urlPath = this.getUrlPath(label); + const urlPath = this.pathResolver.getUrlPath(label); await fs.mkdir(path.dirname(urlPath), { recursive: true }); await fs.writeFile(urlPath, url); } @@ -727,7 +635,7 @@ export class Storage { token: string | undefined | null, ) { if (token !== null) { - const tokenPath = this.getSessionTokenPath(label); + const tokenPath = this.pathResolver.getSessionTokenPath(label); await fs.mkdir(path.dirname(tokenPath), { recursive: true }); await fs.writeFile(tokenPath, token ?? ""); } @@ -743,8 +651,8 @@ export class Storage { public async readCliConfig( label: string, ): Promise<{ url: string; token: string }> { - const urlPath = this.getUrlPath(label); - const tokenPath = this.getSessionTokenPath(label); + const urlPath = this.pathResolver.getUrlPath(label); + const tokenPath = this.pathResolver.getSessionTokenPath(label); const [url, token] = await Promise.allSettled([ fs.readFile(urlPath, "utf8"), fs.readFile(tokenPath, "utf8"), @@ -759,8 +667,8 @@ export class Storage { * Migrate the session token file from "session_token" to "session", if needed. */ public async migrateSessionToken(label: string) { - const oldTokenPath = this.getLegacySessionTokenPath(label); - const newTokenPath = this.getSessionTokenPath(label); + const oldTokenPath = this.pathResolver.getLegacySessionTokenPath(label); + const newTokenPath = this.pathResolver.getSessionTokenPath(label); try { await fs.rename(oldTokenPath, newTokenPath); } catch (error) { From 1a4183d776cddf322b80d28789416c640dedc875 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 10 Sep 2025 16:24:32 +0300 Subject: [PATCH 02/12] Move CLI configuration from storage.ts --- src/commands.ts | 8 ++- src/core/cliConfig.ts | 81 ++++++++++++++++++++++++++++++ src/core/pathResolver.ts | 4 +- src/extension.ts | 7 ++- src/remote.ts | 26 ++++++++-- src/storage.ts | 104 --------------------------------------- 6 files changed, 117 insertions(+), 113 deletions(-) create mode 100644 src/core/cliConfig.ts diff --git a/src/commands.ts b/src/commands.ts index df2679dd..eba64d5b 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -9,6 +9,7 @@ import * as vscode from "vscode"; import { createWorkspaceIdentifier, extractAgents } from "./api/api-helper"; import { CoderApi } from "./api/coderApi"; import { needToken } from "./api/utils"; +import { CliConfigManager } from "./core/cliConfig"; import { PathResolver } from "./core/pathResolver"; import { CertificateError } from "./error"; import { getGlobalFlags } from "./globalFlags"; @@ -21,6 +22,7 @@ import { } from "./workspacesProvider"; export class Commands { + private readonly cliConfigManager: CliConfigManager; // These will only be populated when actively connected to a workspace and are // used in commands. Because commands can be executed by the user, it is not // possible to pass in arguments, so we have to store the current workspace @@ -37,7 +39,9 @@ export class Commands { private readonly restClient: Api, private readonly storage: Storage, private readonly pathResolver: PathResolver, - ) {} + ) { + this.cliConfigManager = new CliConfigManager(pathResolver); + } /** * Find the requested agent if specified, otherwise return the agent if there @@ -199,7 +203,7 @@ export class Commands { await this.storage.setSessionToken(res.token); // Store on disk to be used by the cli. - await this.storage.configureCli(label, url, res.token); + await this.cliConfigManager.configure(label, url, res.token); // These contexts control various menu items and the sidebar. await vscode.commands.executeCommand( diff --git a/src/core/cliConfig.ts b/src/core/cliConfig.ts new file mode 100644 index 00000000..2dd8b694 --- /dev/null +++ b/src/core/cliConfig.ts @@ -0,0 +1,81 @@ +import fs from "fs/promises"; +import path from "path"; +import { PathResolver } from "./pathResolver"; + +export class CliConfigManager { + constructor(private readonly pathResolver: PathResolver) {} + + /** + * Configure the CLI for the deployment with the provided label. + * + * Falsey URLs and null tokens are a no-op; we avoid unconfiguring the CLI to + * avoid breaking existing connections. + */ + public async configure( + label: string, + url: string | undefined, + token: string | null, + ) { + await Promise.all([ + this.updateUrlForCli(label, url), + this.updateTokenForCli(label, token), + ]); + } + + /** + * Update the URL for the deployment with the provided label on disk which can + * be used by the CLI via --url-file. If the URL is falsey, do nothing. + * + * If the label is empty, read the old deployment-unaware config instead. + */ + private async updateUrlForCli( + label: string, + url: string | undefined, + ): Promise { + if (url) { + const urlPath = this.pathResolver.getUrlPath(label); + await fs.mkdir(path.dirname(urlPath), { recursive: true }); + await fs.writeFile(urlPath, url); + } + } + + /** + * Update the session token for a deployment with the provided label on disk + * which can be used by the CLI via --session-token-file. If the token is + * null, do nothing. + * + * If the label is empty, read the old deployment-unaware config instead. + */ + private async updateTokenForCli( + label: string, + token: string | undefined | null, + ) { + if (token !== null) { + const tokenPath = this.pathResolver.getSessionTokenPath(label); + await fs.mkdir(path.dirname(tokenPath), { recursive: true }); + await fs.writeFile(tokenPath, token ?? ""); + } + } + + /** + * Read the CLI config for a deployment with the provided label. + * + * IF a config file does not exist, return an empty string. + * + * If the label is empty, read the old deployment-unaware config. + */ + public async readConfig( + label: string, + ): Promise<{ url: string; token: string }> { + const urlPath = this.pathResolver.getUrlPath(label); + const tokenPath = this.pathResolver.getSessionTokenPath(label); + const [url, token] = await Promise.allSettled([ + fs.readFile(urlPath, "utf8"), + fs.readFile(tokenPath, "utf8"), + ]); + return { + url: url.status === "fulfilled" ? url.value.trim() : "", + token: token.status === "fulfilled" ? token.value.trim() : "", + }; + } +} diff --git a/src/core/pathResolver.ts b/src/core/pathResolver.ts index 11ecd862..6a7ed6fb 100644 --- a/src/core/pathResolver.ts +++ b/src/core/pathResolver.ts @@ -1,10 +1,10 @@ import * as path from "path"; -import * as vscode from "vscode"; +import type { WorkspaceConfiguration } from "vscode"; export class PathResolver { constructor( private readonly basePath: string, - private readonly configurations: vscode.WorkspaceConfiguration, + private readonly configurations: WorkspaceConfiguration, ) {} /** diff --git a/src/extension.ts b/src/extension.ts index 320ad70d..484fe167 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,6 +7,7 @@ import { errToStr } from "./api/api-helper"; import { CoderApi } from "./api/coderApi"; import { needToken } from "./api/utils"; import { Commands } from "./commands"; +import { CliConfigManager } from "./core/cliConfig"; import { PathResolver } from "./core/pathResolver"; import { CertificateError, getErrorDetail } from "./error"; import { Remote } from "./remote"; @@ -107,6 +108,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { allWorkspacesProvider.setVisibility(event.visible); }); + const cliConfigManager = new CliConfigManager(pathResolver); + // Handle vscode:// URIs. vscode.window.registerUriHandler({ handleUri: async (uri) => { @@ -160,7 +163,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } // Store on disk to be used by the cli. - await storage.configureCli(toSafeHost(url), url, token); + await cliConfigManager.configure(toSafeHost(url), url, token); vscode.commands.executeCommand( "coder.open", @@ -238,7 +241,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { : (params.get("token") ?? ""); // Store on disk to be used by the cli. - await storage.configureCli(toSafeHost(url), url, token); + await cliConfigManager.configure(toSafeHost(url), url, token); vscode.commands.executeCommand( "coder.openDevContainer", diff --git a/src/remote.ts b/src/remote.ts index 6d231e33..248efe5b 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -21,6 +21,7 @@ import { needToken } from "./api/utils"; import { startWorkspaceIfStoppedOrFailed, waitForBuild } from "./api/workspace"; import * as cli from "./cliManager"; import { Commands } from "./commands"; +import { CliConfigManager } from "./core/cliConfig"; import { PathResolver } from "./core/pathResolver"; import { featureSetForVersion, FeatureSet } from "./featureSet"; import { getGlobalFlags } from "./globalFlags"; @@ -43,6 +44,7 @@ export interface RemoteDetails extends vscode.Disposable { } export class Remote { + private readonly cliConfigManager: CliConfigManager; public constructor( // We use the proposed API to get access to useCustom in dialogs. private readonly vscodeProposed: typeof vscode, @@ -50,7 +52,9 @@ export class Remote { private readonly commands: Commands, private readonly mode: vscode.ExtensionMode, private readonly pathResolver: PathResolver, - ) {} + ) { + this.cliConfigManager = new CliConfigManager(pathResolver); + } private async confirmStart(workspaceName: string): Promise { const action = await this.vscodeProposed.window.showInformationMessage( @@ -213,10 +217,10 @@ export class Remote { const workspaceName = `${parts.username}/${parts.workspace}`; // Migrate "session_token" file to "session", if needed. - await this.storage.migrateSessionToken(parts.label); + await this.migrateSessionToken(parts.label); // Get the URL and token belonging to this host. - const { url: baseUrlRaw, token } = await this.storage.readCliConfig( + const { url: baseUrlRaw, token } = await this.cliConfigManager.readConfig( parts.label, ); @@ -649,6 +653,22 @@ export class Remote { }; } + /** + * Migrate the session token file from "session_token" to "session", if needed. + */ + private async migrateSessionToken(label: string) { + const oldTokenPath = this.pathResolver.getLegacySessionTokenPath(label); + const newTokenPath = this.pathResolver.getSessionTokenPath(label); + try { + await fs.rename(oldTokenPath, newTokenPath); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + return; + } + throw error; + } + } + /** * Return the --log-dir argument value for the ProxyCommand. It may be an * empty string if the setting is not set or the cli does not support it. diff --git a/src/storage.ts b/src/storage.ts index 3ce7e37d..9e0baece 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -13,7 +13,6 @@ import * as vscode from "vscode"; import { errToStr } from "./api/api-helper"; import * as cli from "./cliManager"; import { PathResolver } from "./core/pathResolver"; -import { getHeaderCommand, getHeaders } from "./headers"; import * as pgp from "./pgp"; // Maximium number of recent URLs to store. @@ -588,107 +587,4 @@ export class Storage { } return status; } - - /** - * Configure the CLI for the deployment with the provided label. - * - * Falsey URLs and null tokens are a no-op; we avoid unconfiguring the CLI to - * avoid breaking existing connections. - */ - public async configureCli( - label: string, - url: string | undefined, - token: string | null, - ) { - await Promise.all([ - this.updateUrlForCli(label, url), - this.updateTokenForCli(label, token), - ]); - } - - /** - * Update the URL for the deployment with the provided label on disk which can - * be used by the CLI via --url-file. If the URL is falsey, do nothing. - * - * If the label is empty, read the old deployment-unaware config instead. - */ - private async updateUrlForCli( - label: string, - url: string | undefined, - ): Promise { - if (url) { - const urlPath = this.pathResolver.getUrlPath(label); - await fs.mkdir(path.dirname(urlPath), { recursive: true }); - await fs.writeFile(urlPath, url); - } - } - - /** - * Update the session token for a deployment with the provided label on disk - * which can be used by the CLI via --session-token-file. If the token is - * null, do nothing. - * - * If the label is empty, read the old deployment-unaware config instead. - */ - private async updateTokenForCli( - label: string, - token: string | undefined | null, - ) { - if (token !== null) { - const tokenPath = this.pathResolver.getSessionTokenPath(label); - await fs.mkdir(path.dirname(tokenPath), { recursive: true }); - await fs.writeFile(tokenPath, token ?? ""); - } - } - - /** - * Read the CLI config for a deployment with the provided label. - * - * IF a config file does not exist, return an empty string. - * - * If the label is empty, read the old deployment-unaware config. - */ - public async readCliConfig( - label: string, - ): Promise<{ url: string; token: string }> { - const urlPath = this.pathResolver.getUrlPath(label); - const tokenPath = this.pathResolver.getSessionTokenPath(label); - const [url, token] = await Promise.allSettled([ - fs.readFile(urlPath, "utf8"), - fs.readFile(tokenPath, "utf8"), - ]); - return { - url: url.status === "fulfilled" ? url.value.trim() : "", - token: token.status === "fulfilled" ? token.value.trim() : "", - }; - } - - /** - * Migrate the session token file from "session_token" to "session", if needed. - */ - public async migrateSessionToken(label: string) { - const oldTokenPath = this.pathResolver.getLegacySessionTokenPath(label); - const newTokenPath = this.pathResolver.getSessionTokenPath(label); - try { - await fs.rename(oldTokenPath, newTokenPath); - } catch (error) { - if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { - return; - } - throw error; - } - } - - /** - * Run the header command and return the generated headers. - */ - public async getHeaders( - url: string | undefined, - ): Promise> { - return getHeaders( - url, - getHeaderCommand(vscode.workspace.getConfiguration()), - this.output, - ); - } } From ae30ae804444bc490c6b9a8c740649673cec9e7f Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 10 Sep 2025 17:58:21 +0300 Subject: [PATCH 03/12] Move memento and secrets managment from storage.ts --- src/commands.ts | 36 ++++++----- src/core/mementoManager.ts | 71 ++++++++++++++++++++++ src/core/pathResolver.ts | 13 ++++ src/core/secretsManager.ts | 29 +++++++++ src/extension.ts | 45 ++++++++------ src/remote.ts | 25 +++++++- src/storage.ts | 121 +------------------------------------ 7 files changed, 184 insertions(+), 156 deletions(-) create mode 100644 src/core/mementoManager.ts create mode 100644 src/core/secretsManager.ts diff --git a/src/commands.ts b/src/commands.ts index eba64d5b..41005869 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -10,7 +10,9 @@ import { createWorkspaceIdentifier, extractAgents } from "./api/api-helper"; import { CoderApi } from "./api/coderApi"; import { needToken } from "./api/utils"; import { CliConfigManager } from "./core/cliConfig"; +import { MementoManager } from "./core/mementoManager"; import { PathResolver } from "./core/pathResolver"; +import { SecretsManager } from "./core/secretsManager"; import { CertificateError } from "./error"; import { getGlobalFlags } from "./globalFlags"; import { Storage } from "./storage"; @@ -39,6 +41,8 @@ export class Commands { private readonly restClient: Api, private readonly storage: Storage, private readonly pathResolver: PathResolver, + private readonly mementoManager: MementoManager, + private readonly secretsManager: SecretsManager, ) { this.cliConfigManager = new CliConfigManager(pathResolver); } @@ -108,7 +112,7 @@ export class Commands { quickPick.title = "Enter the URL of your Coder deployment."; // Initial items. - quickPick.items = this.storage + quickPick.items = this.mementoManager .withUrlHistory(defaultURL, process.env.CODER_URL) .map((url) => ({ alwaysShow: true, @@ -119,7 +123,7 @@ export class Commands { // an option in case the user wants to connect to something that is not in // the list. quickPick.onDidChangeValue((value) => { - quickPick.items = this.storage + quickPick.items = this.mementoManager .withUrlHistory(defaultURL, process.env.CODER_URL, value) .map((url) => ({ alwaysShow: true, @@ -199,8 +203,8 @@ export class Commands { this.restClient.setSessionToken(res.token); // Store these to be used in later sessions. - await this.storage.setUrl(url); - await this.storage.setSessionToken(res.token); + await this.mementoManager.setUrl(url); + await this.secretsManager.setSessionToken(res.token); // Store on disk to be used by the cli. await this.cliConfigManager.configure(label, url, res.token); @@ -288,7 +292,7 @@ export class Commands { title: "Coder API Key", password: true, placeHolder: "Paste your API key.", - value: token || (await this.storage.getSessionToken()), + value: token || (await this.secretsManager.getSessionToken()), ignoreFocusOut: true, validateInput: async (value) => { client.setSessionToken(value); @@ -354,7 +358,7 @@ export class Commands { * Log out from the currently logged-in deployment. */ public async logout(): Promise { - const url = this.storage.getUrl(); + const url = this.mementoManager.getUrl(); if (!url) { // Sanity check; command should not be available if no url. throw new Error("You are not logged in"); @@ -366,8 +370,8 @@ export class Commands { this.restClient.setSessionToken(""); // Clear from memory. - await this.storage.setUrl(undefined); - await this.storage.setSessionToken(undefined); + await this.mementoManager.setUrl(undefined); + await this.secretsManager.setSessionToken(undefined); await vscode.commands.executeCommand( "setContext", @@ -392,7 +396,7 @@ export class Commands { * Must only be called if currently logged in. */ public async createWorkspace(): Promise { - const uri = this.storage.getUrl() + "/templates"; + const uri = this.mementoManager.getUrl() + "/templates"; await vscode.commands.executeCommand("vscode.open", uri); } @@ -407,7 +411,7 @@ export class Commands { public async navigateToWorkspace(item: OpenableTreeItem) { if (item) { const workspaceId = createWorkspaceIdentifier(item.workspace); - const uri = this.storage.getUrl() + `/@${workspaceId}`; + const uri = this.mementoManager.getUrl() + `/@${workspaceId}`; await vscode.commands.executeCommand("vscode.open", uri); } else if (this.workspace && this.workspaceRestClient) { const baseUrl = @@ -430,7 +434,7 @@ export class Commands { public async navigateToWorkspaceSettings(item: OpenableTreeItem) { if (item) { const workspaceId = createWorkspaceIdentifier(item.workspace); - const uri = this.storage.getUrl() + `/@${workspaceId}/settings`; + const uri = this.mementoManager.getUrl() + `/@${workspaceId}/settings`; await vscode.commands.executeCommand("vscode.open", uri); } else if (this.workspace && this.workspaceRestClient) { const baseUrl = @@ -508,7 +512,7 @@ export class Commands { // If workspace_name is provided, run coder ssh before the command - const url = this.storage.getUrl(); + const url = this.mementoManager.getUrl(); if (!url) { throw new Error("No coder url found for sidebar"); } @@ -650,8 +654,8 @@ export class Commands { newWindow = false; } - // Only set the memento if when opening a new folder - await this.storage.setFirstConnect(); + // Only set the memento when opening a new folder + await this.mementoManager.setFirstConnect(); await vscode.commands.executeCommand( "vscode.openFolder", vscode.Uri.from({ @@ -831,8 +835,8 @@ export class Commands { } } - // Only set the memento if when opening a new folder/window - await this.storage.setFirstConnect(); + // Only set the memento when opening a new folder/window + await this.mementoManager.setFirstConnect(); if (folderPath) { await vscode.commands.executeCommand( "vscode.openFolder", diff --git a/src/core/mementoManager.ts b/src/core/mementoManager.ts new file mode 100644 index 00000000..af99ee58 --- /dev/null +++ b/src/core/mementoManager.ts @@ -0,0 +1,71 @@ +import type { Memento } from "vscode"; + +// Maximum number of recent URLs to store. +const MAX_URLS = 10; + +export class MementoManager { + constructor(private readonly memento: Memento) {} + + /** + * Add the URL to the list of recently accessed URLs in global storage, then + * set it as the last used URL. + * + * If the URL is falsey, then remove it as the last used URL and do not touch + * the history. + */ + public async setUrl(url?: string): Promise { + await this.memento.update("url", url); + if (url) { + const history = this.withUrlHistory(url); + await this.memento.update("urlHistory", history); + } + } + + /** + * Get the last used URL. + */ + public getUrl(): string | undefined { + return this.memento.get("url"); + } + + /** + * Get the most recently accessed URLs (oldest to newest) with the provided + * values appended. Duplicates will be removed. + */ + public withUrlHistory(...append: (string | undefined)[]): string[] { + const val = this.memento.get("urlHistory"); + const urls = Array.isArray(val) ? new Set(val) : new Set(); + for (const url of append) { + if (url) { + // It might exist; delete first so it gets appended. + urls.delete(url); + urls.add(url); + } + } + // Slice off the head if the list is too large. + return urls.size > MAX_URLS + ? Array.from(urls).slice(urls.size - MAX_URLS, urls.size) + : Array.from(urls); + } + + /** + * Mark this as the first connection to a workspace, which influences whether + * the workspace startup confirmation is shown to the user. + */ + public async setFirstConnect(): Promise { + return this.memento.update("firstConnect", true); + } + + /** + * Check if this is the first connection to a workspace and clear the flag. + * Used to determine whether to automatically start workspaces without + * prompting the user for confirmation. + */ + public async getAndClearFirstConnect(): Promise { + const isFirst = this.memento.get("firstConnect"); + if (isFirst !== undefined) { + await this.memento.update("firstConnect", undefined); + } + return isFirst === true; + } +} diff --git a/src/core/pathResolver.ts b/src/core/pathResolver.ts index 6a7ed6fb..434080cb 100644 --- a/src/core/pathResolver.ts +++ b/src/core/pathResolver.ts @@ -4,6 +4,7 @@ import type { WorkspaceConfiguration } from "vscode"; export class PathResolver { constructor( private readonly basePath: string, + private readonly codeLogPath: string, private readonly configurations: WorkspaceConfiguration, ) {} @@ -100,4 +101,16 @@ export class PathResolver { public getUrlPath(label: string): string { return path.join(this.getGlobalConfigDir(label), "url"); } + + /** + * The uri of a directory in which the extension can create log files. + * + * The directory might not exist on disk and creation is up to the extension. + * However, the parent directory is guaranteed to be existent. + * + * This directory is provided by VS Code and may not be the same as the directory where the Coder CLI writes its log files. + */ + public getCodeLogDir(): string { + return this.codeLogPath; + } } diff --git a/src/core/secretsManager.ts b/src/core/secretsManager.ts new file mode 100644 index 00000000..7fd98f8f --- /dev/null +++ b/src/core/secretsManager.ts @@ -0,0 +1,29 @@ +import type { SecretStorage } from "vscode"; + +export class SecretsManager { + constructor(private readonly secrets: SecretStorage) {} + + /** + * Set or unset the last used token. + */ + public async setSessionToken(sessionToken?: string): Promise { + if (!sessionToken) { + await this.secrets.delete("sessionToken"); + } else { + await this.secrets.store("sessionToken", sessionToken); + } + } + + /** + * Get the last used token. + */ + public async getSessionToken(): Promise { + try { + return await this.secrets.get("sessionToken"); + } catch (ex) { + // The VS Code session store has become corrupt before, and + // will fail to get the session token... + return undefined; + } + } +} diff --git a/src/extension.ts b/src/extension.ts index 484fe167..6a3a3669 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,7 +8,9 @@ import { CoderApi } from "./api/coderApi"; import { needToken } from "./api/utils"; import { Commands } from "./commands"; import { CliConfigManager } from "./core/cliConfig"; +import { MementoManager } from "./core/mementoManager"; import { PathResolver } from "./core/pathResolver"; +import { SecretsManager } from "./core/secretsManager"; import { CertificateError, getErrorDetail } from "./error"; import { Remote } from "./remote"; import { Storage } from "./storage"; @@ -52,28 +54,26 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const pathResolver = new PathResolver( ctx.globalStorageUri.fsPath, + ctx.logUri.fsPath, vscode.workspace.getConfiguration(), ); + const cliConfigManager = new CliConfigManager(pathResolver); + const mementoManager = new MementoManager(ctx.globalState); + const secretsManager = new SecretsManager(ctx.secrets); + const output = vscode.window.createOutputChannel("Coder", { log: true }); - const storage = new Storage( - vscodeProposed, - output, - ctx.globalState, - ctx.secrets, - ctx.logUri, - pathResolver, - ); + const storage = new Storage(output, pathResolver); - // Try to clear this flag ASAP then pass it around if needed - const isFirstConnect = await storage.getAndClearFirstConnect(); + // Try to clear this flag ASAP + const isFirstConnect = await mementoManager.getAndClearFirstConnect(); // This client tracks the current login and will be used through the life of // the plugin to poll workspaces for the current login, as well as being used // in commands that operate on the current login. - const url = storage.getUrl(); + const url = mementoManager.getUrl(); const client = CoderApi.create( url || "", - await storage.getSessionToken(), + await secretsManager.getSessionToken(), storage.output, () => vscode.workspace.getConfiguration(), ); @@ -108,8 +108,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { allWorkspacesProvider.setVisibility(event.visible); }); - const cliConfigManager = new CliConfigManager(pathResolver); - // Handle vscode:// URIs. vscode.window.registerUriHandler({ handleUri: async (uri) => { @@ -137,11 +135,11 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // hit enter and move on. const url = await commands.maybeAskUrl( params.get("url"), - storage.getUrl(), + mementoManager.getUrl(), ); if (url) { client.setHost(url); - await storage.setUrl(url); + await mementoManager.setUrl(url); } else { throw new Error( "url must be provided or specified as a query parameter", @@ -159,7 +157,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { : (params.get("token") ?? ""); if (token) { client.setSessionToken(token); - await storage.setSessionToken(token); + await secretsManager.setSessionToken(token); } // Store on disk to be used by the cli. @@ -219,11 +217,11 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // hit enter and move on. const url = await commands.maybeAskUrl( params.get("url"), - storage.getUrl(), + mementoManager.getUrl(), ); if (url) { client.setHost(url); - await storage.setUrl(url); + await mementoManager.setUrl(url); } else { throw new Error( "url must be provided or specified as a query parameter", @@ -261,7 +259,14 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Register globally available commands. Many of these have visibility // controlled by contexts, see `when` in the package.json. - const commands = new Commands(vscodeProposed, client, storage, pathResolver); + const commands = new Commands( + vscodeProposed, + client, + storage, + pathResolver, + mementoManager, + secretsManager, + ); vscode.commands.registerCommand("coder.login", commands.login.bind(commands)); vscode.commands.registerCommand( "coder.logout", diff --git a/src/remote.ts b/src/remote.ts index 248efe5b..ff0653fe 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -987,7 +987,7 @@ export class Remote { return undefined; } // Loop until we find the remote SSH log for this window. - const filePath = await this.storage.getRemoteSSHLogPath(); + const filePath = await this.getRemoteSSHLogPath(); if (!filePath) { return new Promise((resolve) => setTimeout(() => resolve(loop()), 500)); } @@ -1001,6 +1001,29 @@ export class Remote { return loop(); } + /** + * Returns the log path for the "Remote - SSH" output panel. There is no VS + * Code API to get the contents of an output panel. We use this to get the + * active port so we can display network information. + */ + private async getRemoteSSHLogPath(): Promise { + const upperDir = path.dirname(this.pathResolver.getCodeLogDir()); + // Node returns these directories sorted already! + const dirs = await fs.readdir(upperDir); + const latestOutput = dirs + .reverse() + .filter((dir) => dir.startsWith("output_logging_")); + if (latestOutput.length === 0) { + return undefined; + } + const dir = await fs.readdir(path.join(upperDir, latestOutput[0])); + const remoteSSH = dir.filter((file) => file.indexOf("Remote - SSH") !== -1); + if (remoteSSH.length === 0) { + return undefined; + } + return path.join(upperDir, latestOutput[0], remoteSSH[0]); + } + /** * Creates and manages a status bar item that displays metadata information for a given workspace agent. * The status bar item updates dynamically based on changes to the agent's metadata, diff --git a/src/storage.ts b/src/storage.ts index 9e0baece..c2aaa29c 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -15,129 +15,12 @@ import * as cli from "./cliManager"; import { PathResolver } from "./core/pathResolver"; import * as pgp from "./pgp"; -// Maximium number of recent URLs to store. -const MAX_URLS = 10; - export class Storage { constructor( - private readonly vscodeProposed: typeof vscode, public readonly output: vscode.LogOutputChannel, - private readonly memento: vscode.Memento, - private readonly secrets: vscode.SecretStorage, - private readonly logUri: vscode.Uri, private readonly pathResolver: PathResolver, ) {} - /** - * Add the URL to the list of recently accessed URLs in global storage, then - * set it as the last used URL. - * - * If the URL is falsey, then remove it as the last used URL and do not touch - * the history. - */ - public async setUrl(url?: string): Promise { - await this.memento.update("url", url); - if (url) { - const history = this.withUrlHistory(url); - await this.memento.update("urlHistory", history); - } - } - - /** - * Get the last used URL. - */ - public getUrl(): string | undefined { - return this.memento.get("url"); - } - - /** - * Get the most recently accessed URLs (oldest to newest) with the provided - * values appended. Duplicates will be removed. - */ - public withUrlHistory(...append: (string | undefined)[]): string[] { - const val = this.memento.get("urlHistory"); - const urls = Array.isArray(val) ? new Set(val) : new Set(); - for (const url of append) { - if (url) { - // It might exist; delete first so it gets appended. - urls.delete(url); - urls.add(url); - } - } - // Slice off the head if the list is too large. - return urls.size > MAX_URLS - ? Array.from(urls).slice(urls.size - MAX_URLS, urls.size) - : Array.from(urls); - } - - /** - * Mark this as the first connection to a workspace, which influences whether - * the workspace startup confirmation is shown to the user. - */ - public async setFirstConnect(): Promise { - return this.memento.update("firstConnect", true); - } - - /** - * Check if this is the first connection to a workspace and clear the flag. - * Used to determine whether to automatically start workspaces without - * prompting the user for confirmation. - */ - public async getAndClearFirstConnect(): Promise { - const isFirst = this.memento.get("firstConnect"); - if (isFirst !== undefined) { - await this.memento.update("firstConnect", undefined); - } - return isFirst === true; - } - - /** - * Set or unset the last used token. - */ - public async setSessionToken(sessionToken?: string): Promise { - if (!sessionToken) { - await this.secrets.delete("sessionToken"); - } else { - await this.secrets.store("sessionToken", sessionToken); - } - } - - /** - * Get the last used token. - */ - public async getSessionToken(): Promise { - try { - return await this.secrets.get("sessionToken"); - } catch (ex) { - // The VS Code session store has become corrupt before, and - // will fail to get the session token... - return undefined; - } - } - - /** - * Returns the log path for the "Remote - SSH" output panel. There is no VS - * Code API to get the contents of an output panel. We use this to get the - * active port so we can display network information. - */ - public async getRemoteSSHLogPath(): Promise { - const upperDir = path.dirname(this.logUri.fsPath); - // Node returns these directories sorted already! - const dirs = await fs.readdir(upperDir); - const latestOutput = dirs - .reverse() - .filter((dir) => dir.startsWith("output_logging_")); - if (latestOutput.length === 0) { - return undefined; - } - const dir = await fs.readdir(path.join(upperDir, latestOutput[0])); - const remoteSSH = dir.filter((file) => file.indexOf("Remote - SSH") !== -1); - if (remoteSSH.length === 0) { - return undefined; - } - return path.join(upperDir, latestOutput[0], remoteSSH[0]); - } - /** * Download and return the path to a working binary for the deployment with * the provided label using the provided client. If the label is empty, use @@ -511,7 +394,7 @@ export class Storage { options.push("Download signature"); } options.push("Run without verification"); - const action = await this.vscodeProposed.window.showWarningMessage( + const action = await vscode.window.showWarningMessage( status === 404 ? "Signature not found" : "Failed to download signature", { useCustom: true, @@ -565,7 +448,7 @@ export class Storage { this.output, ); } catch (error) { - const action = await this.vscodeProposed.window.showWarningMessage( + const action = await vscode.window.showWarningMessage( // VerificationError should be the only thing that throws, but // unfortunately caught errors are always type unknown. error instanceof pgp.VerificationError From a2792c49188b4365363b2c7b192834a77ffd124e Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 10 Sep 2025 18:31:28 +0300 Subject: [PATCH 04/12] Split binary management logic from storage.ts --- src/core/binaryManager.ts | 475 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 475 insertions(+) create mode 100644 src/core/binaryManager.ts diff --git a/src/core/binaryManager.ts b/src/core/binaryManager.ts new file mode 100644 index 00000000..b73835c7 --- /dev/null +++ b/src/core/binaryManager.ts @@ -0,0 +1,475 @@ +import globalAxios, { + type AxiosInstance, + type AxiosRequestConfig, +} from "axios"; +import { Api } from "coder/site/src/api/api"; +import { createWriteStream, WriteStream } from "fs"; +import fs from "fs/promises"; +import { IncomingMessage } from "http"; +import path from "path"; +import prettyBytes from "pretty-bytes"; +import * as semver from "semver"; +import * as vscode from "vscode"; + +import { errToStr } from "../api-helper"; +import * as cli from "../cliManager"; +import { Logger } from "../logging/logger"; +import * as pgp from "../pgp"; +import { PathResolver } from "./pathResolver"; + +export class BinaryManager { + constructor( + private readonly output: Logger, + private readonly pathResolver: PathResolver, + ) {} + + /** + * Download and return the path to a working binary for the deployment with + * the provided label using the provided client. If the label is empty, use + * the old deployment-unaware path instead. + * + * If there is already a working binary and it matches the server version, + * return that, skipping the download. If it does not match but downloads are + * disabled, return whatever we have and log a warning. Otherwise throw if + * unable to download a working binary, whether because of network issues or + * downloads being disabled. + */ + public async fetchBinary(restClient: Api, label: string): Promise { + const cfg = vscode.workspace.getConfiguration("coder"); + + // Settings can be undefined when set to their defaults (true in this case), + // so explicitly check against false. + const enableDownloads = cfg.get("enableDownloads") !== false; + 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.info("Got server version", buildInfo.version); + const parsedVersion = semver.parse(buildInfo.version); + if (!parsedVersion) { + throw new Error( + `Got invalid version from deployment: ${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.pathResolver.getBinaryCachePath(label), + cli.name(), + ); + this.output.info("Using binary path", binPath); + const stat = await cli.stat(binPath); + if (stat === undefined) { + this.output.info("No existing binary found, starting download"); + } else { + this.output.info("Existing binary size is", prettyBytes(stat.size)); + try { + const version = await cli.version(binPath); + 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.info( + "Using existing binary since it matches the server version", + ); + return binPath; + } else if (!enableDownloads) { + this.output.info( + "Using existing binary even though it does not match the server version because downloads are disabled", + ); + return binPath; + } + this.output.info( + "Downloading since existing binary does not match the server version", + ); + } catch (error) { + this.output.warn( + `Unable to get version of existing binary: ${error}. Downloading new binary instead`, + ); + } + } + + if (!enableDownloads) { + this.output.warn("Unable to download CLI because downloads are disabled"); + throw new Error("Unable to download CLI because downloads are disabled"); + } + + // Remove any left-over old or temporary binaries and signatures. + const removed = await cli.rmOld(binPath); + removed.forEach(({ fileName, error }) => { + if (error) { + this.output.warn("Failed to remove", fileName, error); + } else { + this.output.info("Removed", fileName); + } + }); + + // Figure out where to get the binary. + const binName = cli.name(); + const configSource = cfg.get("binarySource"); + const binSource = + configSource && String(configSource).trim().length > 0 + ? String(configSource) + : "/bin/" + binName; + 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.info("Using ETag", etag); + + // Download the binary to a temporary file. + await fs.mkdir(path.dirname(binPath), { recursive: true }); + const tempFile = + binPath + ".temp-" + Math.random().toString(36).substring(8); + const writeStream = createWriteStream(tempFile, { + autoClose: true, + mode: 0o755, + }); + const client = restClient.getAxiosInstance(); + const status = await this.download(client, binSource, writeStream, { + "Accept-Encoding": "gzip", + "If-None-Match": `"${etag}"`, + }); + + switch (status) { + case 200: { + if (cfg.get("disableSignatureVerification")) { + this.output.info( + "Skipping binary signature verification due to settings", + ); + } else { + await this.verifyBinarySignatures(client, tempFile, [ + // A signature placed at the same level as the binary. It must be + // named exactly the same with an appended `.asc` (such as + // coder-windows-amd64.exe.asc or coder-linux-amd64.asc). + binSource + ".asc", + // The releases.coder.com bucket does not include the leading "v", + // and unlike what we get from buildinfo it uses a truncated version + // with only major.minor.patch. The signature name follows the same + // rule as above. + `https://releases.coder.com/coder-cli/${parsedVersion.major}.${parsedVersion.minor}.${parsedVersion.patch}/${binName}.asc`, + ]); + } + + // Move the old binary to a backup location first, just in case. And, + // on Linux at least, you cannot write onto a binary that is in use so + // moving first works around that (delete would also work). + if (stat !== undefined) { + const oldBinPath = + binPath + ".old-" + Math.random().toString(36).substring(8); + 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.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.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.info("Downloaded binary version is", version); + + return binPath; + } + case 304: { + this.output.info("Using existing binary since server returned a 304"); + return binPath; + } + case 404: { + vscode.window + .showErrorMessage( + "Coder isn't supported for your platform. Please open an issue, we'd love to support it!", + "Open an Issue", + ) + .then((value) => { + if (!value) { + return; + } + const os = cli.goos(); + const arch = cli.goarch(); + const params = new URLSearchParams({ + title: `Support the \`${os}-${arch}\` platform`, + body: `I'd like to use the \`${os}-${arch}\` architecture with the VS Code extension.`, + }); + const uri = vscode.Uri.parse( + `https://github.com/coder/vscode-coder/issues/new?` + + params.toString(), + ); + vscode.env.openExternal(uri); + }); + throw new Error("Platform not supported"); + } + default: { + vscode.window + .showErrorMessage( + "Failed to download binary. Please open an issue.", + "Open an Issue", + ) + .then((value) => { + if (!value) { + return; + } + const params = new URLSearchParams({ + title: `Failed to download binary on \`${cli.goos()}-${cli.goarch()}\``, + body: `Received status code \`${status}\` when downloading the binary.`, + }); + const uri = vscode.Uri.parse( + `https://github.com/coder/vscode-coder/issues/new?` + + params.toString(), + ); + vscode.env.openExternal(uri); + }); + throw new Error("Failed to download binary"); + } + } + } + + /** + * Download the source to the provided stream with a progress dialog. Return + * the status code or throw if the user aborts or there is an error. + */ + private async download( + client: AxiosInstance, + source: string, + writeStream: WriteStream, + headers?: AxiosRequestConfig["headers"], + ): Promise { + const baseUrl = client.defaults.baseURL; + + const controller = new AbortController(); + const resp = await client.get(source, { + signal: controller.signal, + baseURL: baseUrl, + responseType: "stream", + headers, + decompress: true, + // Ignore all errors so we can catch a 404! + validateStatus: () => true, + }); + this.output.info("Got status code", resp.status); + + if (resp.status === 200) { + const rawContentLength = resp.headers["content-length"]; + const contentLength = Number.parseInt(rawContentLength); + if (Number.isNaN(contentLength)) { + this.output.warn( + "Got invalid or missing content length", + rawContentLength, + ); + } else { + this.output.info("Got content length", prettyBytes(contentLength)); + } + + // Track how many bytes were written. + let written = 0; + + const completed = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Downloading ${baseUrl}`, + cancellable: true, + }, + async (progress, token) => { + const readStream = resp.data as IncomingMessage; + let cancelled = false; + token.onCancellationRequested(() => { + controller.abort(); + readStream.destroy(); + cancelled = true; + }); + + // Reverse proxies might not always send a content length. + const contentLengthPretty = Number.isNaN(contentLength) + ? "unknown" + : prettyBytes(contentLength); + + // Pipe data received from the request to the stream. + readStream.on("data", (buffer: Buffer) => { + writeStream.write(buffer, () => { + written += buffer.byteLength; + progress.report({ + message: `${prettyBytes(written)} / ${contentLengthPretty}`, + increment: Number.isNaN(contentLength) + ? undefined + : (buffer.byteLength / contentLength) * 100, + }); + }); + }); + + // Wait for the stream to end or error. + return new Promise((resolve, reject) => { + writeStream.on("error", (error) => { + readStream.destroy(); + reject( + new Error( + `Unable to download binary: ${errToStr(error, "no reason given")}`, + ), + ); + }); + readStream.on("error", (error) => { + writeStream.close(); + reject( + new Error( + `Unable to download binary: ${errToStr(error, "no reason given")}`, + ), + ); + }); + readStream.on("close", () => { + writeStream.close(); + if (cancelled) { + resolve(false); + } else { + resolve(true); + } + }); + }); + }, + ); + + // 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.warn("User aborted download"); + throw new Error("Download aborted"); + } + + this.output.info(`Downloaded ${prettyBytes(written)}`); + } + + return resp.status; + } + + /** + * Download detached signatures one at a time and use them to verify the + * binary. The first signature is always downloaded, but the next signatures + * are only tried if the previous ones did not exist and the user indicates + * they want to try the next source. + * + * If the first successfully downloaded signature is valid or it is invalid + * and the user indicates to use the binary anyway, return, otherwise throw. + * + * If no signatures could be downloaded, return if the user indicates to use + * the binary anyway, otherwise throw. + */ + private async verifyBinarySignatures( + client: AxiosInstance, + cliPath: string, + sources: string[], + ): Promise { + const publicKeys = await pgp.readPublicKeys(this.output); + for (let i = 0; i < sources.length; ++i) { + const source = sources[i]; + // For the primary source we use the common client, but for the rest we do + // not to avoid sending user-provided headers to external URLs. + if (i === 1) { + client = globalAxios.create(); + } + const status = await this.verifyBinarySignature( + client, + cliPath, + publicKeys, + source, + ); + if (status === 200) { + return; + } + // If we failed to download, try the next source. + let nextPrompt = ""; + const options: string[] = []; + const nextSource = sources[i + 1]; + if (nextSource) { + nextPrompt = ` Would you like to download the signature from ${nextSource}?`; + options.push("Download signature"); + } + options.push("Run without verification"); + const action = await vscode.window.showWarningMessage( + status === 404 ? "Signature not found" : "Failed to download signature", + { + useCustom: true, + modal: true, + detail: + status === 404 + ? `No binary signature was found at ${source}.${nextPrompt}` + : `Received ${status} trying to download binary signature from ${source}.${nextPrompt}`, + }, + ...options, + ); + switch (action) { + case "Download signature": { + continue; + } + case "Run without verification": + this.output.info(`Signature download from ${nextSource} declined`); + this.output.info("Binary will be ran anyway at user request"); + return; + default: + this.output.info(`Signature download from ${nextSource} declined`); + this.output.info("Binary was rejected at user request"); + throw new Error("Signature download aborted"); + } + } + // Reaching here would be a developer error. + throw new Error("Unable to download any signatures"); + } + + /** + * Download a detached signature and if successful (200 status code) use it to + * verify the binary. Throw if the binary signature is invalid and the user + * declined to run the binary, otherwise return the status code. + */ + private async verifyBinarySignature( + client: AxiosInstance, + cliPath: string, + publicKeys: pgp.Key[], + source: string, + ): Promise { + this.output.info("Downloading signature from", source); + const signaturePath = path.join(cliPath + ".asc"); + const writeStream = createWriteStream(signaturePath); + const status = await this.download(client, source, writeStream); + if (status === 200) { + try { + await pgp.verifySignature( + publicKeys, + cliPath, + signaturePath, + this.output, + ); + } catch (error) { + const action = await vscode.window.showWarningMessage( + // VerificationError should be the only thing that throws, but + // unfortunately caught errors are always type unknown. + error instanceof pgp.VerificationError + ? error.summary() + : "Failed to verify signature", + { + useCustom: true, + modal: true, + detail: `${errToStr(error)} Would you like to accept this risk and run the binary anyway?`, + }, + "Run anyway", + ); + if (!action) { + this.output.info("Binary was rejected at user request"); + throw new Error("Signature verification aborted"); + } + this.output.info("Binary will be ran anyway at user request"); + } + } + return status; + } +} From 2fe85c82b6e658f511d8747fd7c95acc48249bbb Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 15 Sep 2025 12:58:55 +0300 Subject: [PATCH 05/12] Remove `storage.ts` file entirely --- src/commands.ts | 17 +- src/core/pathResolver.ts | 2 +- src/extension.ts | 33 +-- src/inbox.ts | 17 +- src/remote.ts | 66 +++--- src/storage.ts | 473 -------------------------------------- src/workspaceMonitor.ts | 10 +- src/workspacesProvider.ts | 8 +- 8 files changed, 74 insertions(+), 552 deletions(-) delete mode 100644 src/storage.ts diff --git a/src/commands.ts b/src/commands.ts index 41005869..a0538845 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -9,13 +9,14 @@ import * as vscode from "vscode"; import { createWorkspaceIdentifier, extractAgents } from "./api/api-helper"; import { CoderApi } from "./api/coderApi"; import { needToken } from "./api/utils"; +import { BinaryManager } from "./core/binaryManager"; import { CliConfigManager } from "./core/cliConfig"; import { MementoManager } from "./core/mementoManager"; import { PathResolver } from "./core/pathResolver"; import { SecretsManager } from "./core/secretsManager"; import { CertificateError } from "./error"; import { getGlobalFlags } from "./globalFlags"; -import { Storage } from "./storage"; +import { Logger } from "./logging/logger"; import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util"; import { AgentTreeItem, @@ -39,10 +40,11 @@ export class Commands { public constructor( private readonly vscodeProposed: typeof vscode, private readonly restClient: Api, - private readonly storage: Storage, + private readonly logger: Logger, private readonly pathResolver: PathResolver, private readonly mementoManager: MementoManager, private readonly secretsManager: SecretsManager, + private readonly binaryManager: BinaryManager, ) { this.cliConfigManager = new CliConfigManager(pathResolver); } @@ -249,7 +251,7 @@ export class Commands { token: string, isAutologin: boolean, ): Promise<{ user: User; token: string } | null> { - const client = CoderApi.create(url, token, this.storage.output, () => + const client = CoderApi.create(url, token, this.logger, () => vscode.workspace.getConfiguration(), ); if (!needToken(vscode.workspace.getConfiguration())) { @@ -261,10 +263,7 @@ export class Commands { } catch (err) { const message = getErrorMessage(err, "no response from the server"); if (isAutologin) { - this.storage.output.warn( - "Failed to log in to Coder server:", - message, - ); + this.logger.warn("Failed to log in to Coder server:", message); } else { this.vscodeProposed.window.showErrorMessage( "Failed to log in to Coder server", @@ -516,7 +515,7 @@ export class Commands { if (!url) { throw new Error("No coder url found for sidebar"); } - const binary = await this.storage.fetchBinary( + const binary = await this.binaryManager.fetchBinary( this.restClient, toSafeHost(url), ); @@ -764,7 +763,7 @@ export class Commands { // If we have no agents, the workspace may not be running, in which case // we need to fetch the agents through the resources API, as the // workspaces query does not include agents when off. - this.storage.output.info("Fetching agents from template version"); + this.logger.info("Fetching agents from template version"); const resources = await this.restClient.getTemplateVersionResources( workspace.latest_build.template_version_id, ); diff --git a/src/core/pathResolver.ts b/src/core/pathResolver.ts index 434080cb..2f9f08bc 100644 --- a/src/core/pathResolver.ts +++ b/src/core/pathResolver.ts @@ -103,7 +103,7 @@ export class PathResolver { } /** - * The uri of a directory in which the extension can create log files. + * The URI of a directory in which the extension can create log files. * * The directory might not exist on disk and creation is up to the extension. * However, the parent directory is guaranteed to be existent. diff --git a/src/extension.ts b/src/extension.ts index 6a3a3669..ba2f2310 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,13 +7,13 @@ import { errToStr } from "./api/api-helper"; import { CoderApi } from "./api/coderApi"; import { needToken } from "./api/utils"; import { Commands } from "./commands"; +import { BinaryManager } from "./core/binaryManager"; import { CliConfigManager } from "./core/cliConfig"; import { MementoManager } from "./core/mementoManager"; import { PathResolver } from "./core/pathResolver"; import { SecretsManager } from "./core/secretsManager"; import { CertificateError, getErrorDetail } from "./error"; import { Remote } from "./remote"; -import { Storage } from "./storage"; import { toSafeHost } from "./util"; import { WorkspaceQuery, WorkspaceProvider } from "./workspacesProvider"; @@ -62,7 +62,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const secretsManager = new SecretsManager(ctx.secrets); const output = vscode.window.createOutputChannel("Coder", { log: true }); - const storage = new Storage(output, pathResolver); // Try to clear this flag ASAP const isFirstConnect = await mementoManager.getAndClearFirstConnect(); @@ -74,20 +73,20 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const client = CoderApi.create( url || "", await secretsManager.getSessionToken(), - storage.output, + output, () => vscode.workspace.getConfiguration(), ); const myWorkspacesProvider = new WorkspaceProvider( WorkspaceQuery.Mine, client, - storage, + output, 5, ); const allWorkspacesProvider = new WorkspaceProvider( WorkspaceQuery.All, client, - storage, + output, ); // createTreeView, unlike registerTreeDataProvider, gives us the tree view API @@ -257,15 +256,18 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }, }); + const binaryManager = new BinaryManager(output, pathResolver); + // Register globally available commands. Many of these have visibility // controlled by contexts, see `when` in the package.json. const commands = new Commands( vscodeProposed, client, - storage, + output, pathResolver, mementoManager, secretsManager, + binaryManager, ); vscode.commands.registerCommand("coder.login", commands.login.bind(commands)); vscode.commands.registerCommand( @@ -322,10 +324,11 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { if (remoteSSHExtension && vscodeProposed.env.remoteAuthority) { const remote = new Remote( vscodeProposed, - storage, + output, commands, ctx.extensionMode, pathResolver, + binaryManager, ); try { const details = await remote.setup( @@ -340,7 +343,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } } catch (ex) { if (ex instanceof CertificateError) { - storage.output.warn(ex.x509Err || ex.message); + output.warn(ex.x509Err || ex.message); await ex.showModal("Failed to open workspace"); } else if (isAxiosError(ex)) { const msg = getErrorMessage(ex, "None"); @@ -349,7 +352,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.output.warn(message); + output.warn(message); await vscodeProposed.window.showErrorMessage( "Failed to open workspace", { @@ -360,7 +363,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ); } else { const message = errToStr(ex, "No error message was provided"); - storage.output.warn(message); + output.warn(message); await vscodeProposed.window.showErrorMessage( "Failed to open workspace", { @@ -379,12 +382,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // See if the plugin client is authenticated. const baseUrl = client.getAxiosInstance().defaults.baseURL; if (baseUrl) { - storage.output.info(`Logged in to ${baseUrl}; checking credentials`); + output.info(`Logged in to ${baseUrl}; checking credentials`); client .getAuthenticatedUser() .then(async (user) => { if (user && user.roles) { - storage.output.info("Credentials are valid"); + output.info("Credentials are valid"); vscode.commands.executeCommand( "setContext", "coder.authenticated", @@ -402,13 +405,13 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { myWorkspacesProvider.fetchAndRefresh(); allWorkspacesProvider.fetchAndRefresh(); } else { - storage.output.warn("No error, but got unexpected response", user); + 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.output.warn("Failed to check user authentication", error); + output.warn("Failed to check user authentication", error); vscode.window.showErrorMessage( `Failed to check user authentication: ${error.message}`, ); @@ -417,7 +420,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { vscode.commands.executeCommand("setContext", "coder.loaded", true); }); } else { - storage.output.info("Not currently logged in"); + output.info("Not currently logged in"); vscode.commands.executeCommand("setContext", "coder.loaded", true); // Handle autologin, if not already logged in. diff --git a/src/inbox.ts b/src/inbox.ts index 3141b661..e12263bf 100644 --- a/src/inbox.ts +++ b/src/inbox.ts @@ -4,7 +4,7 @@ import { } from "coder/site/src/api/typesGenerated"; import * as vscode from "vscode"; import { CoderApi } from "./api/coderApi"; -import { type Storage } from "./storage"; +import { Logger } from "./logging/logger"; import { OneWayWebSocket } from "./websocket/oneWayWebSocket"; // These are the template IDs of our notifications. @@ -14,12 +14,12 @@ const TEMPLATE_WORKSPACE_OUT_OF_MEMORY = "a9d027b4-ac49-4fb1-9f6d-45af15f64e7a"; const TEMPLATE_WORKSPACE_OUT_OF_DISK = "f047f6a3-5713-40f7-85aa-0394cce9fa3a"; export class Inbox implements vscode.Disposable { - readonly #storage: Storage; + readonly #logger: Logger; #disposed = false; #socket: OneWayWebSocket; - constructor(workspace: Workspace, client: CoderApi, storage: Storage) { - this.#storage = storage; + constructor(workspace: Workspace, client: CoderApi, logger: Logger) { + this.#logger = logger; const watchTemplates = [ TEMPLATE_WORKSPACE_OUT_OF_DISK, @@ -31,7 +31,7 @@ export class Inbox implements vscode.Disposable { this.#socket = client.watchInboxNotifications(watchTemplates, watchTargets); this.#socket.addEventListener("open", () => { - this.#storage.output.info("Listening to Coder Inbox"); + this.#logger.info("Listening to Coder Inbox"); }); this.#socket.addEventListener("error", () => { @@ -41,10 +41,7 @@ export class Inbox implements vscode.Disposable { this.#socket.addEventListener("message", (data) => { if (data.parseError) { - this.#storage.output.error( - "Failed to parse inbox message", - data.parseError, - ); + this.#logger.error("Failed to parse inbox message", data.parseError); } else { vscode.window.showInformationMessage( data.parsedMessage.notification.title, @@ -55,7 +52,7 @@ export class Inbox implements vscode.Disposable { dispose() { if (!this.#disposed) { - this.#storage.output.info("No longer listening to Coder Inbox"); + this.#logger.info("No longer listening to Coder Inbox"); this.#socket.close(); this.#disposed = true; } diff --git a/src/remote.ts b/src/remote.ts index ff0653fe..0c7541fc 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -21,14 +21,15 @@ import { needToken } from "./api/utils"; import { startWorkspaceIfStoppedOrFailed, waitForBuild } from "./api/workspace"; import * as cli from "./cliManager"; import { Commands } from "./commands"; +import { BinaryManager } from "./core/binaryManager"; import { CliConfigManager } from "./core/cliConfig"; import { PathResolver } from "./core/pathResolver"; import { featureSetForVersion, FeatureSet } from "./featureSet"; import { getGlobalFlags } from "./globalFlags"; import { Inbox } from "./inbox"; +import { Logger } from "./logging/logger"; import { SSHConfig, SSHValues, mergeSSHConfigValues } from "./sshConfig"; import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport"; -import { Storage } from "./storage"; import { AuthorityPrefix, escapeCommandArg, @@ -48,10 +49,11 @@ export class Remote { public constructor( // We use the proposed API to get access to useCustom in dialogs. private readonly vscodeProposed: typeof vscode, - private readonly storage: Storage, + private readonly logger: Logger, private readonly commands: Commands, private readonly mode: vscode.ExtensionMode, private readonly pathResolver: PathResolver, + private readonly binaryManager: BinaryManager, ) { this.cliConfigManager = new CliConfigManager(pathResolver); } @@ -125,7 +127,7 @@ export class Remote { case "starting": case "stopping": writeEmitter = initWriteEmitterAndTerminal(); - this.storage.output.info(`Waiting for ${workspaceName}...`); + this.logger.info(`Waiting for ${workspaceName}...`); workspace = await waitForBuild(client, writeEmitter, workspace); break; case "stopped": @@ -136,7 +138,7 @@ export class Remote { return undefined; } writeEmitter = initWriteEmitterAndTerminal(); - this.storage.output.info(`Starting ${workspaceName}...`); + this.logger.info(`Starting ${workspaceName}...`); workspace = await startWorkspaceIfStoppedOrFailed( client, globalConfigDir, @@ -157,7 +159,7 @@ export class Remote { return undefined; } writeEmitter = initWriteEmitterAndTerminal(); - this.storage.output.info(`Starting ${workspaceName}...`); + this.logger.info(`Starting ${workspaceName}...`); workspace = await startWorkspaceIfStoppedOrFailed( client, globalConfigDir, @@ -181,7 +183,7 @@ export class Remote { ); } } - this.storage.output.info( + this.logger.info( `${workspaceName} status is now`, workspace.latest_build.status, ); @@ -254,8 +256,8 @@ export class Remote { return; } - this.storage.output.info("Using deployment URL", baseUrlRaw); - this.storage.output.info("Using deployment label", parts.label || "n/a"); + this.logger.info("Using deployment URL", baseUrlRaw); + this.logger.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 @@ -265,7 +267,7 @@ export class Remote { const workspaceClient = CoderApi.create( baseUrlRaw, token, - this.storage.output, + this.logger, () => vscode.workspace.getConfiguration(), ); // Store for use in commands. @@ -273,7 +275,10 @@ export class Remote { let binaryPath: string | undefined; if (this.mode === vscode.ExtensionMode.Production) { - binaryPath = await this.storage.fetchBinary(workspaceClient, parts.label); + binaryPath = await this.binaryManager.fetchBinary( + workspaceClient, + parts.label, + ); } else { try { // In development, try to use `/tmp/coder` as the binary path. @@ -281,7 +286,7 @@ export class Remote { binaryPath = path.join(os.tmpdir(), "coder"); await fs.stat(binaryPath); } catch (ex) { - binaryPath = await this.storage.fetchBinary( + binaryPath = await this.binaryManager.fetchBinary( workspaceClient, parts.label, ); @@ -319,12 +324,12 @@ export class Remote { // Next is to find the workspace from the URI scheme provided. let workspace: Workspace; try { - this.storage.output.info(`Looking for workspace ${workspaceName}...`); + this.logger.info(`Looking for workspace ${workspaceName}...`); workspace = await workspaceClient.getWorkspaceByOwnerAndName( parts.username, parts.workspace, ); - this.storage.output.info( + this.logger.info( `Found workspace ${workspaceName} with status`, workspace.latest_build.status, ); @@ -410,7 +415,7 @@ export class Remote { this.commands.workspace = workspace; // Pick an agent. - this.storage.output.info(`Finding agent for ${workspaceName}...`); + this.logger.info(`Finding agent for ${workspaceName}...`); const agents = extractAgents(workspace.latest_build.resources); const gotAgent = await this.commands.maybeAskAgent(agents, parts.agent); if (!gotAgent) { @@ -419,13 +424,10 @@ export class Remote { return; } let agent = gotAgent; // Reassign so it cannot be undefined in callbacks. - this.storage.output.info( - `Found agent ${agent.name} with status`, - agent.status, - ); + this.logger.info(`Found agent ${agent.name} with status`, agent.status); // Do some janky setting manipulation. - this.storage.output.info("Modifying settings..."); + this.logger.info("Modifying settings..."); const remotePlatforms = this.vscodeProposed.workspace .getConfiguration() .get>("remote.SSH.remotePlatform", {}); @@ -500,7 +502,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.output.warn("Failed to configure settings", ex); + this.logger.warn("Failed to configure settings", ex); } } @@ -508,7 +510,7 @@ export class Remote { const monitor = new WorkspaceMonitor( workspace, workspaceClient, - this.storage, + this.logger, this.vscodeProposed, ); disposables.push(monitor); @@ -517,12 +519,12 @@ export class Remote { ); // Watch coder inbox for messages - const inbox = new Inbox(workspace, workspaceClient, this.storage); + const inbox = new Inbox(workspace, workspaceClient, this.logger); disposables.push(inbox); // Wait for the agent to connect. if (agent.status === "connecting") { - this.storage.output.info(`Waiting for ${workspaceName}/${agent.name}...`); + this.logger.info(`Waiting for ${workspaceName}/${agent.name}...`); await vscode.window.withProgress( { title: "Waiting for the agent to connect...", @@ -551,10 +553,7 @@ export class Remote { }); }, ); - this.storage.output.info( - `Agent ${agent.name} status is now`, - agent.status, - ); + this.logger.info(`Agent ${agent.name} status is now`, agent.status); } // Make sure the agent is connected. @@ -584,7 +583,7 @@ export class Remote { // If we didn't write to the SSH config file, connecting would fail with // "Host not found". try { - this.storage.output.info("Updating SSH config..."); + this.logger.info("Updating SSH config..."); await this.updateSSHConfig( workspaceClient, parts.label, @@ -594,7 +593,7 @@ export class Remote { featureSet, ); } catch (error) { - this.storage.output.warn("Failed to configure SSH", error); + this.logger.warn("Failed to configure SSH", error); throw error; } @@ -638,7 +637,7 @@ export class Remote { ...this.createAgentMetadataStatusBar(agent, workspaceClient), ); - this.storage.output.info("Remote setup complete"); + this.logger.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 @@ -695,10 +694,7 @@ export class Remote { return ""; } await fs.mkdir(logDir, { recursive: true }); - this.storage.output.info( - "SSH proxy diagnostics are being written to", - logDir, - ); + this.logger.info("SSH proxy diagnostics are being written to", logDir); return ` --log-dir ${escapeCommandArg(logDir)} -v`; } @@ -1043,7 +1039,7 @@ export class Remote { const onChangeDisposable = agentWatcher.onChange(() => { if (agentWatcher.error) { const errMessage = formatMetadataError(agentWatcher.error); - this.storage.output.warn(errMessage); + this.logger.warn(errMessage); statusBarItem.text = "$(warning) Agent Status Unavailable"; statusBarItem.tooltip = errMessage; diff --git a/src/storage.ts b/src/storage.ts deleted file mode 100644 index c2aaa29c..00000000 --- a/src/storage.ts +++ /dev/null @@ -1,473 +0,0 @@ -import globalAxios, { - type AxiosInstance, - type AxiosRequestConfig, -} from "axios"; -import { Api } from "coder/site/src/api/api"; -import { createWriteStream, type WriteStream } from "fs"; -import fs from "fs/promises"; -import { IncomingMessage } from "http"; -import path from "path"; -import prettyBytes from "pretty-bytes"; -import * as semver from "semver"; -import * as vscode from "vscode"; -import { errToStr } from "./api/api-helper"; -import * as cli from "./cliManager"; -import { PathResolver } from "./core/pathResolver"; -import * as pgp from "./pgp"; - -export class Storage { - constructor( - public readonly output: vscode.LogOutputChannel, - private readonly pathResolver: PathResolver, - ) {} - - /** - * Download and return the path to a working binary for the deployment with - * the provided label using the provided client. If the label is empty, use - * the old deployment-unaware path instead. - * - * If there is already a working binary and it matches the server version, - * return that, skipping the download. If it does not match but downloads are - * disabled, return whatever we have and log a warning. Otherwise throw if - * unable to download a working binary, whether because of network issues or - * downloads being disabled. - */ - public async fetchBinary(restClient: Api, label: string): Promise { - const cfg = vscode.workspace.getConfiguration("coder"); - - // Settings can be undefined when set to their defaults (true in this case), - // so explicitly check against false. - const enableDownloads = cfg.get("enableDownloads") !== false; - 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.info("Got server version", buildInfo.version); - const parsedVersion = semver.parse(buildInfo.version); - if (!parsedVersion) { - throw new Error( - `Got invalid version from deployment: ${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.pathResolver.getBinaryCachePath(label), - cli.name(), - ); - this.output.info("Using binary path", binPath); - const stat = await cli.stat(binPath); - if (stat === undefined) { - this.output.info("No existing binary found, starting download"); - } else { - this.output.info("Existing binary size is", prettyBytes(stat.size)); - try { - const version = await cli.version(binPath); - 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.info( - "Using existing binary since it matches the server version", - ); - return binPath; - } else if (!enableDownloads) { - this.output.info( - "Using existing binary even though it does not match the server version because downloads are disabled", - ); - return binPath; - } - this.output.info( - "Downloading since existing binary does not match the server version", - ); - } catch (error) { - this.output.warn( - `Unable to get version of existing binary: ${error}. Downloading new binary instead`, - ); - } - } - - if (!enableDownloads) { - this.output.warn("Unable to download CLI because downloads are disabled"); - throw new Error("Unable to download CLI because downloads are disabled"); - } - - // Remove any left-over old or temporary binaries and signatures. - const removed = await cli.rmOld(binPath); - removed.forEach(({ fileName, error }) => { - if (error) { - this.output.warn("Failed to remove", fileName, error); - } else { - this.output.info("Removed", fileName); - } - }); - - // Figure out where to get the binary. - const binName = cli.name(); - const configSource = cfg.get("binarySource"); - const binSource = - configSource && String(configSource).trim().length > 0 - ? String(configSource) - : "/bin/" + binName; - 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.info("Using ETag", etag); - - // Download the binary to a temporary file. - await fs.mkdir(path.dirname(binPath), { recursive: true }); - const tempFile = - binPath + ".temp-" + Math.random().toString(36).substring(8); - const writeStream = createWriteStream(tempFile, { - autoClose: true, - mode: 0o755, - }); - const client = restClient.getAxiosInstance(); - const status = await this.download(client, binSource, writeStream, { - "Accept-Encoding": "gzip", - "If-None-Match": `"${etag}"`, - }); - - switch (status) { - case 200: { - if (cfg.get("disableSignatureVerification")) { - this.output.info( - "Skipping binary signature verification due to settings", - ); - } else { - await this.verifyBinarySignatures(client, tempFile, [ - // A signature placed at the same level as the binary. It must be - // named exactly the same with an appended `.asc` (such as - // coder-windows-amd64.exe.asc or coder-linux-amd64.asc). - binSource + ".asc", - // The releases.coder.com bucket does not include the leading "v", - // and unlike what we get from buildinfo it uses a truncated version - // with only major.minor.patch. The signature name follows the same - // rule as above. - `https://releases.coder.com/coder-cli/${parsedVersion.major}.${parsedVersion.minor}.${parsedVersion.patch}/${binName}.asc`, - ]); - } - - // Move the old binary to a backup location first, just in case. And, - // on Linux at least, you cannot write onto a binary that is in use so - // moving first works around that (delete would also work). - if (stat !== undefined) { - const oldBinPath = - binPath + ".old-" + Math.random().toString(36).substring(8); - 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.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.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.info("Downloaded binary version is", version); - - return binPath; - } - case 304: { - this.output.info("Using existing binary since server returned a 304"); - return binPath; - } - case 404: { - vscode.window - .showErrorMessage( - "Coder isn't supported for your platform. Please open an issue, we'd love to support it!", - "Open an Issue", - ) - .then((value) => { - if (!value) { - return; - } - const os = cli.goos(); - const arch = cli.goarch(); - const params = new URLSearchParams({ - title: `Support the \`${os}-${arch}\` platform`, - body: `I'd like to use the \`${os}-${arch}\` architecture with the VS Code extension.`, - }); - const uri = vscode.Uri.parse( - `https://github.com/coder/vscode-coder/issues/new?` + - params.toString(), - ); - vscode.env.openExternal(uri); - }); - throw new Error("Platform not supported"); - } - default: { - vscode.window - .showErrorMessage( - "Failed to download binary. Please open an issue.", - "Open an Issue", - ) - .then((value) => { - if (!value) { - return; - } - const params = new URLSearchParams({ - title: `Failed to download binary on \`${cli.goos()}-${cli.goarch()}\``, - body: `Received status code \`${status}\` when downloading the binary.`, - }); - const uri = vscode.Uri.parse( - `https://github.com/coder/vscode-coder/issues/new?` + - params.toString(), - ); - vscode.env.openExternal(uri); - }); - throw new Error("Failed to download binary"); - } - } - } - - /** - * Download the source to the provided stream with a progress dialog. Return - * the status code or throw if the user aborts or there is an error. - */ - private async download( - client: AxiosInstance, - source: string, - writeStream: WriteStream, - headers?: AxiosRequestConfig["headers"], - ): Promise { - const baseUrl = client.defaults.baseURL; - - const controller = new AbortController(); - const resp = await client.get(source, { - signal: controller.signal, - baseURL: baseUrl, - responseType: "stream", - headers, - decompress: true, - // Ignore all errors so we can catch a 404! - validateStatus: () => true, - }); - this.output.info("Got status code", resp.status); - - if (resp.status === 200) { - const rawContentLength = resp.headers["content-length"]; - const contentLength = Number.parseInt(rawContentLength); - if (Number.isNaN(contentLength)) { - this.output.warn( - "Got invalid or missing content length", - rawContentLength, - ); - } else { - this.output.info("Got content length", prettyBytes(contentLength)); - } - - // Track how many bytes were written. - let written = 0; - - const completed = await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: `Downloading ${baseUrl}`, - cancellable: true, - }, - async (progress, token) => { - const readStream = resp.data as IncomingMessage; - let cancelled = false; - token.onCancellationRequested(() => { - controller.abort(); - readStream.destroy(); - cancelled = true; - }); - - // Reverse proxies might not always send a content length. - const contentLengthPretty = Number.isNaN(contentLength) - ? "unknown" - : prettyBytes(contentLength); - - // Pipe data received from the request to the stream. - readStream.on("data", (buffer: Buffer) => { - writeStream.write(buffer, () => { - written += buffer.byteLength; - progress.report({ - message: `${prettyBytes(written)} / ${contentLengthPretty}`, - increment: Number.isNaN(contentLength) - ? undefined - : (buffer.byteLength / contentLength) * 100, - }); - }); - }); - - // Wait for the stream to end or error. - return new Promise((resolve, reject) => { - writeStream.on("error", (error) => { - readStream.destroy(); - reject( - new Error( - `Unable to download binary: ${errToStr(error, "no reason given")}`, - ), - ); - }); - readStream.on("error", (error) => { - writeStream.close(); - reject( - new Error( - `Unable to download binary: ${errToStr(error, "no reason given")}`, - ), - ); - }); - readStream.on("close", () => { - writeStream.close(); - if (cancelled) { - resolve(false); - } else { - resolve(true); - } - }); - }); - }, - ); - - // 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.warn("User aborted download"); - throw new Error("Download aborted"); - } - - this.output.info(`Downloaded ${prettyBytes(written)}`); - } - - return resp.status; - } - - /** - * Download detached signatures one at a time and use them to verify the - * binary. The first signature is always downloaded, but the next signatures - * are only tried if the previous ones did not exist and the user indicates - * they want to try the next source. - * - * If the first successfully downloaded signature is valid or it is invalid - * and the user indicates to use the binary anyway, return, otherwise throw. - * - * If no signatures could be downloaded, return if the user indicates to use - * the binary anyway, otherwise throw. - */ - private async verifyBinarySignatures( - client: AxiosInstance, - cliPath: string, - sources: string[], - ): Promise { - const publicKeys = await pgp.readPublicKeys(this.output); - for (let i = 0; i < sources.length; ++i) { - const source = sources[i]; - // For the primary source we use the common client, but for the rest we do - // not to avoid sending user-provided headers to external URLs. - if (i === 1) { - client = globalAxios.create(); - } - const status = await this.verifyBinarySignature( - client, - cliPath, - publicKeys, - source, - ); - if (status === 200) { - return; - } - // If we failed to download, try the next source. - let nextPrompt = ""; - const options: string[] = []; - const nextSource = sources[i + 1]; - if (nextSource) { - nextPrompt = ` Would you like to download the signature from ${nextSource}?`; - options.push("Download signature"); - } - options.push("Run without verification"); - const action = await vscode.window.showWarningMessage( - status === 404 ? "Signature not found" : "Failed to download signature", - { - useCustom: true, - modal: true, - detail: - status === 404 - ? `No binary signature was found at ${source}.${nextPrompt}` - : `Received ${status} trying to download binary signature from ${source}.${nextPrompt}`, - }, - ...options, - ); - switch (action) { - case "Download signature": { - continue; - } - case "Run without verification": - this.output.info(`Signature download from ${nextSource} declined`); - this.output.info("Binary will be ran anyway at user request"); - return; - default: - this.output.info(`Signature download from ${nextSource} declined`); - this.output.info("Binary was rejected at user request"); - throw new Error("Signature download aborted"); - } - } - // Reaching here would be a developer error. - throw new Error("Unable to download any signatures"); - } - - /** - * Download a detached signature and if successful (200 status code) use it to - * verify the binary. Throw if the binary signature is invalid and the user - * declined to run the binary, otherwise return the status code. - */ - private async verifyBinarySignature( - client: AxiosInstance, - cliPath: string, - publicKeys: pgp.Key[], - source: string, - ): Promise { - this.output.info("Downloading signature from", source); - const signaturePath = path.join(cliPath + ".asc"); - const writeStream = createWriteStream(signaturePath); - const status = await this.download(client, source, writeStream); - if (status === 200) { - try { - await pgp.verifySignature( - publicKeys, - cliPath, - signaturePath, - this.output, - ); - } catch (error) { - const action = await vscode.window.showWarningMessage( - // VerificationError should be the only thing that throws, but - // unfortunately caught errors are always type unknown. - error instanceof pgp.VerificationError - ? error.summary() - : "Failed to verify signature", - { - useCustom: true, - modal: true, - detail: `${errToStr(error)} Would you like to accept this risk and run the binary anyway?`, - }, - "Run anyway", - ); - if (!action) { - this.output.info("Binary was rejected at user request"); - throw new Error("Signature verification aborted"); - } - this.output.info("Binary will be ran anyway at user request"); - } - } - return status; - } -} diff --git a/src/workspaceMonitor.ts b/src/workspaceMonitor.ts index 16c1ecde..ece765a6 100644 --- a/src/workspaceMonitor.ts +++ b/src/workspaceMonitor.ts @@ -3,7 +3,7 @@ import { formatDistanceToNowStrict } from "date-fns"; import * as vscode from "vscode"; import { createWorkspaceIdentifier, errToStr } from "./api/api-helper"; import { CoderApi } from "./api/coderApi"; -import { Storage } from "./storage"; +import { Logger } from "./logging/logger"; import { OneWayWebSocket } from "./websocket/oneWayWebSocket"; /** @@ -34,7 +34,7 @@ export class WorkspaceMonitor implements vscode.Disposable { constructor( workspace: Workspace, private readonly client: CoderApi, - private readonly storage: Storage, + private readonly logger: Logger, // We use the proposed API to get access to useCustom in dialogs. private readonly vscodeProposed: typeof vscode, ) { @@ -42,7 +42,7 @@ export class WorkspaceMonitor implements vscode.Disposable { const socket = this.client.watchWorkspace(workspace); socket.addEventListener("open", () => { - this.storage.output.info(`Monitoring ${this.name}...`); + this.logger.info(`Monitoring ${this.name}...`); }); socket.addEventListener("message", (event) => { @@ -83,7 +83,7 @@ export class WorkspaceMonitor implements vscode.Disposable { */ dispose() { if (!this.disposed) { - this.storage.output.info(`Unmonitoring ${this.name}...`); + this.logger.info(`Unmonitoring ${this.name}...`); this.statusBarItem.dispose(); this.socket.close(); this.disposed = true; @@ -209,7 +209,7 @@ export class WorkspaceMonitor implements vscode.Disposable { error, "Got empty error while monitoring workspace", ); - this.storage.output.error(message); + this.logger.error(message); } private updateContext(workspace: Workspace) { diff --git a/src/workspacesProvider.ts b/src/workspacesProvider.ts index f344eb0f..23f5705a 100644 --- a/src/workspacesProvider.ts +++ b/src/workspacesProvider.ts @@ -13,11 +13,11 @@ import { } from "./agentMetadataHelper"; import { AgentMetadataEvent, - extractAllAgents, extractAgents, + extractAllAgents, } from "./api/api-helper"; import { CoderApi } from "./api/coderApi"; -import { Storage } from "./storage"; +import { Logger } from "./logging/logger"; export enum WorkspaceQuery { Mine = "owner:me", @@ -46,7 +46,7 @@ export class WorkspaceProvider constructor( private readonly getWorkspacesQuery: WorkspaceQuery, private readonly client: CoderApi, - private readonly storage: Storage, + private readonly logger: Logger, private readonly timerSeconds?: number, ) { // No initialization. @@ -92,7 +92,7 @@ export class WorkspaceProvider */ private async fetch(): Promise { if (vscode.env.logLevel <= vscode.LogLevel.Debug) { - this.storage.output.info( + this.logger.info( `Fetching workspaces: ${this.getWorkspacesQuery || "no filter"}...`, ); } From 9117ed8ddebd46240ab44a4549500ff8b461f98e Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 15 Sep 2025 22:31:10 +0300 Subject: [PATCH 06/12] Add binaryManager test + update vitest --- package.json | 3 +- src/core/binaryManager.adapters.ts | 71 ++ src/core/binaryManager.interfaces.ts | 68 ++ src/core/binaryManager.test.ts | 573 +++++++++++++ src/core/binaryManager.ts | 49 +- src/error.test.ts | 447 +++++----- src/extension.ts | 13 +- src/headers.test.ts | 16 +- vitest.config.ts | 3 + yarn.lock | 1188 ++++++++++++++------------ 10 files changed, 1632 insertions(+), 799 deletions(-) create mode 100644 src/core/binaryManager.adapters.ts create mode 100644 src/core/binaryManager.interfaces.ts create mode 100644 src/core/binaryManager.test.ts diff --git a/package.json b/package.json index b07e754f..c6335411 100644 --- a/package.json +++ b/package.json @@ -336,6 +336,7 @@ "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^6.21.0", + "@vitest/coverage-v8": "^3.2.4", "@vscode/test-cli": "^0.0.11", "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "^3.6.0", @@ -355,7 +356,7 @@ "ts-loader": "^9.5.1", "typescript": "^5.8.3", "utf-8-validate": "^6.0.5", - "vitest": "^0.34.6", + "vitest": "^3.2.4", "vscode-test": "^1.5.0", "webpack": "^5.99.6", "webpack-cli": "^5.1.4" diff --git a/src/core/binaryManager.adapters.ts b/src/core/binaryManager.adapters.ts new file mode 100644 index 00000000..7a8f8ec1 --- /dev/null +++ b/src/core/binaryManager.adapters.ts @@ -0,0 +1,71 @@ +import * as vscode from "vscode"; +import { + ConfigurationProvider, + ProgressReporter, + UserInteraction, +} from "./binaryManager.interfaces"; + +/** + * VS Code implementation of ConfigurationProvider + */ +export class VSCodeConfigurationProvider implements ConfigurationProvider { + get(key: string): T | undefined; + get(key: string, defaultValue: T): T; + get(key: string, defaultValue?: T): T | undefined { + const config = vscode.workspace.getConfiguration(); + return defaultValue !== undefined + ? config.get(key, defaultValue) + : config.get(key); + } +} + +/** + * VS Code implementation of ProgressReporter + */ +export class VSCodeProgressReporter implements ProgressReporter { + async withProgress( + title: string, + operation: ( + progress: { + report: (value: { message?: string; increment?: number }) => void; + }, + cancellationToken: { + onCancellationRequested: (listener: () => void) => void; + }, + ) => Promise, + ): Promise { + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title, + cancellable: true, + }, + operation, + ); + } +} + +/** + * VS Code implementation of UserInteraction + */ +export class VSCodeUserInteraction implements UserInteraction { + async showErrorMessage( + message: string, + options?: { detail?: string; modal?: boolean; useCustom?: boolean }, + ...items: string[] + ): Promise { + return vscode.window.showErrorMessage(message, options || {}, ...items); + } + + async showWarningMessage( + message: string, + options?: { detail?: string; modal?: boolean; useCustom?: boolean }, + ...items: string[] + ): Promise { + return vscode.window.showWarningMessage(message, options || {}, ...items); + } + + async openExternal(url: string): Promise { + await vscode.env.openExternal(vscode.Uri.parse(url)); + } +} diff --git a/src/core/binaryManager.interfaces.ts b/src/core/binaryManager.interfaces.ts new file mode 100644 index 00000000..8d5a2be1 --- /dev/null +++ b/src/core/binaryManager.interfaces.ts @@ -0,0 +1,68 @@ +/** + * Provides access to configuration settings + */ +export interface ConfigurationProvider { + get(key: string): T | undefined; + get(key: string, defaultValue: T): T; +} + +/** + * Provides progress reporting capabilities for long-running operations + */ +export interface ProgressReporter { + /** + * Reports progress for a download operation with cancellation support + * @param title The title to display for the progress + * @param operation The operation to run with progress reporting + * @returns Promise that resolves to true if completed, false if cancelled + */ + withProgress( + title: string, + operation: ( + progress: { + report: (value: { message?: string; increment?: number }) => void; + }, + cancellationToken: { + onCancellationRequested: (listener: () => void) => void; + }, + ) => Promise, + ): Promise; +} + +/** + * User interaction capabilities for showing dialogs and opening external URLs + */ +export interface UserInteraction { + /** + * Shows an error message with optional action buttons + * @param message The message to display + * @param options Additional options for the dialog + * @param items Action button labels + * @returns Promise that resolves to the selected action or undefined + */ + showErrorMessage( + message: string, + options?: { detail?: string; modal?: boolean; useCustom?: boolean }, + ...items: string[] + ): Promise; + + /** + * Shows a warning message with optional action buttons + * @param message The message to display + * @param options Additional options for the dialog + * @param items Action button labels + * @returns Promise that resolves to the selected action or undefined + */ + showWarningMessage( + message: string, + options?: { detail?: string; modal?: boolean; useCustom?: boolean }, + ...items: string[] + ): Promise; + + /** + * Opens an external URL + * @param url The URL to open + * @returns Promise that resolves when the URL is opened + */ + openExternal(url: string): Promise; +} diff --git a/src/core/binaryManager.test.ts b/src/core/binaryManager.test.ts new file mode 100644 index 00000000..8764ea0f --- /dev/null +++ b/src/core/binaryManager.test.ts @@ -0,0 +1,573 @@ +import globalAxios, { AxiosInstance } from "axios"; +import { Api } from "coder/site/src/api/api"; +import { BuildInfoResponse } from "coder/site/src/api/typesGenerated"; +import type { Stats, WriteStream } from "fs"; +import * as fs from "fs"; +import * as fse from "fs/promises"; +import { IncomingMessage } from "http"; +import * as path from "path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { WorkspaceConfiguration } from "vscode"; +import * as cli from "../cliManager"; +import { Logger } from "../logging/logger"; +import * as pgp from "../pgp"; +import { BinaryManager } from "./binaryManager"; +import { + ConfigurationProvider, + ProgressReporter, + UserInteraction, +} from "./binaryManager.interfaces"; +import { PathResolver } from "./pathResolver"; + +// Mock all external modules +vi.mock("fs/promises"); +vi.mock("fs", () => ({ + createWriteStream: vi.fn(), +})); +vi.mock("../cliManager"); +vi.mock("../pgp"); +vi.mock("axios"); + +describe("Binary Manager", () => { + let manager: BinaryManager; + let mockLogger: Logger; + let mockConfig: MockConfigurationProvider; + let mockProgress: MockProgressReporter; + let mockUI: MockUserInteraction; + let mockApi: Api; + let mockAxios: AxiosInstance; + + // Test constants + const TEST_VERSION = "1.2.3"; + const TEST_URL = "https://test.coder.com"; + const BINARY_PATH = "/path/binary/coder"; + + beforeEach(() => { + vi.clearAllMocks(); + + // Initialize all mocks + mockLogger = createMockLogger(); + mockConfig = new MockConfigurationProvider(); + mockProgress = new MockProgressReporter(); + mockUI = new MockUserInteraction(); + mockApi = createMockApi(TEST_VERSION, TEST_URL); + mockAxios = mockApi.getAxiosInstance(); + + vi.mocked(globalAxios.create).mockReturnValue(mockAxios); + + const config = { + get: (key: string) => + key === "coder.binaryDestination" + ? path.dirname(BINARY_PATH) + : undefined, + } as unknown as WorkspaceConfiguration; + const pathResolver = new PathResolver("/path/base", "/code/log", config); + + manager = new BinaryManager( + mockLogger, + pathResolver, + mockConfig, + mockProgress, + mockUI, + ); + + // Setup default CLI mocks + setupDefaultCliMocks(); + }); + + describe("Configuration", () => { + it("respects disabled downloads setting", async () => { + mockConfig.set("coder.enableDownloads", false); + + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Unable to download CLI because downloads are disabled", + ); + }); + + it("validates server version", async () => { + mockApi.getBuildInfo = vi.fn().mockResolvedValue({ + version: "invalid-version", + }); + + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Got invalid version from deployment", + ); + }); + + it("uses existing binary when versions match", async () => { + setupExistingBinary(TEST_VERSION); + + const result = await manager.fetchBinary(mockApi, "test"); + + expect(result).toBe(BINARY_PATH); + expect(mockLogger.info).toHaveBeenCalledWith( + "Using existing binary since it matches the server version", + ); + }); + + it("handles corrupted existing binary gracefully", async () => { + vi.mocked(cli.stat).mockResolvedValue({ size: 1024 } as Stats); + vi.mocked(cli.version).mockRejectedValueOnce(new Error("corrupted")); + + setupSuccessfulDownload(mockApi); + + const result = await manager.fetchBinary(mockApi, "test"); + + expect(result).toBe(BINARY_PATH); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("Unable to get version of existing binary"), + ); + }); + }); + + describe("Download Flow", () => { + it("downloads binary successfully", async () => { + setupSuccessfulDownload(mockApi); + + const result = await manager.fetchBinary(mockApi, "test"); + + expect(result).toBe(BINARY_PATH); + expect(mockAxios.get).toHaveBeenCalledWith( + "/bin/coder", + expect.objectContaining({ + responseType: "stream", + headers: expect.objectContaining({ + "Accept-Encoding": "gzip", + }), + }), + ); + }); + + it("handles 304 Not Modified response", async () => { + setupExistingBinary("1.2.5"); // Different version + mockAxios.get = vi.fn().mockResolvedValue({ + status: 304, + headers: {}, + data: undefined, + }); + + const result = await manager.fetchBinary(mockApi, "test"); + + expect(result).toBe(BINARY_PATH); + expect(mockLogger.info).toHaveBeenCalledWith( + "Using existing binary since server returned a 304", + ); + }); + + it("handles 404 platform not supported", async () => { + mockAxios.get = vi.fn().mockResolvedValue({ + status: 404, + headers: {}, + data: undefined, + }); + mockUI.setResponse("Open an Issue"); + + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Platform not supported", + ); + + expect(mockUI.openExternal).toHaveBeenCalledWith( + expect.stringContaining("github.com/coder/vscode-coder/issues/new"), + ); + }); + + it("handles download failure", async () => { + mockAxios.get = vi.fn().mockResolvedValue({ + status: 500, + headers: {}, + data: undefined, + }); + + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Failed to download binary", + ); + }); + }); + + describe("Stream Error Handling", () => { + it("handles write stream errors", async () => { + const writeError = new Error("disk full"); + const { mockWriteStream, mockReadStream } = setupStreamMocks(); + + // Trigger write error after setup + mockWriteStream.on = vi.fn((event, callback) => { + if (event === "error") { + setTimeout(() => callback(writeError), 5); + } + return mockWriteStream; + }); + + mockAxios.get = vi.fn().mockResolvedValue({ + status: 200, + headers: { "content-length": "1024" }, + data: mockReadStream, + }); + + vi.mocked(fs.createWriteStream).mockReturnValue(mockWriteStream); + + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Unable to download binary: disk full", + ); + + expect(mockReadStream.destroy).toHaveBeenCalled(); + }); + + it("handles read stream errors", async () => { + const { mockWriteStream } = setupStreamMocks(); + const mockReadStream = createMockReadStream((event, callback) => { + if (event === "error") { + setTimeout(() => callback(new Error("network timeout")), 5); + } + }); + + mockAxios.get = vi.fn().mockResolvedValue({ + status: 200, + headers: { "content-length": "1024" }, + data: mockReadStream, + }); + + vi.mocked(fs.createWriteStream).mockReturnValue(mockWriteStream); + + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Unable to download binary: network timeout", + ); + + expect(mockWriteStream.close).toHaveBeenCalled(); + }); + }); + + describe("Progress Monitor", () => { + it("rejects with 'Download aborted' when cancelled", async () => { + const { mockWriteStream, mockReadStream } = setupStreamMocks(); + + // Enable cancellation for this test + mockProgress.setCancellation(true); + + mockAxios.get = vi.fn().mockResolvedValue({ + status: 200, + headers: { "content-length": "1024" }, + data: mockReadStream, + }); + + vi.mocked(fs.createWriteStream).mockReturnValue(mockWriteStream); + + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Download aborted", + ); + + expect(mockReadStream.destroy).toHaveBeenCalled(); + + // Reset cancellation state + mockProgress.setCancellation(false); + }); + }); + + describe("Signature Verification", () => { + beforeEach(() => { + vi.mocked(pgp.readPublicKeys).mockResolvedValue([]); + }); + + it("verifies signature successfully", async () => { + vi.mocked(pgp.verifySignature).mockResolvedValue(); + setupSuccessfulDownloadWithSignature(mockApi); + + const result = await manager.fetchBinary(mockApi, "test"); + + expect(result).toBe(BINARY_PATH); + expect(pgp.verifySignature).toHaveBeenCalled(); + }); + + it("tries alternative signature source on 404", async () => { + vi.mocked(pgp.verifySignature).mockResolvedValue(); + + const { mockWriteStream, mockReadStream } = setupStreamMocks(); + mockAxios.get = vi + .fn() + .mockResolvedValueOnce(createStreamResponse(200, mockReadStream)) // Binary + .mockResolvedValueOnce({ status: 404, headers: {}, data: undefined }) // First sig + .mockResolvedValueOnce(createStreamResponse(200, mockReadStream)); // Alt sig + + vi.mocked(fs.createWriteStream).mockReturnValue(mockWriteStream); + vi.mocked(cli.stat) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ size: 1024 } as Stats); + + mockUI.setResponse("Download signature"); + + const result = await manager.fetchBinary(mockApi, "test"); + + expect(result).toBe(BINARY_PATH); + expect(mockUI.showWarningMessage).toHaveBeenCalledWith( + "Signature not found", + expect.any(Object), + expect.any(String), + expect.any(String), + ); + }); + + it("allows running without verification on user request", async () => { + setupSuccessfulDownload(mockApi); + mockAxios.get = vi + .fn() + .mockResolvedValueOnce( + createStreamResponse(200, createMockReadStream()), + ) + .mockResolvedValueOnce({ status: 404, headers: {}, data: undefined }); + + mockUI.setResponse("Run without verification"); + + const result = await manager.fetchBinary(mockApi, "test"); + + expect(result).toBe(BINARY_PATH); + expect(pgp.verifySignature).not.toHaveBeenCalled(); + }); + + it("handles invalid signature with user override", async () => { + const verificationError = new pgp.VerificationError( + pgp.VerificationErrorCode.Invalid, + "Invalid signature", + ); + verificationError.summary = () => "Signature does not match"; + vi.mocked(pgp.verifySignature).mockRejectedValue(verificationError); + + setupSuccessfulDownloadWithSignature(mockApi); + mockUI.setResponse("Run anyway"); + + const result = await manager.fetchBinary(mockApi, "test"); + + expect(result).toBe(BINARY_PATH); + expect(mockLogger.info).toHaveBeenCalledWith( + "Binary will be ran anyway at user request", + ); + }); + + it("aborts on signature verification rejection", async () => { + const verificationError = new pgp.VerificationError( + pgp.VerificationErrorCode.Invalid, + "Invalid signature", + ); + verificationError.summary = () => "Signature does not match"; + vi.mocked(pgp.verifySignature).mockRejectedValue(verificationError); + + setupSuccessfulDownloadWithSignature(mockApi); + mockUI.setResponse(undefined); // User rejects + + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Signature verification aborted", + ); + }); + + it("skips verification when disabled in config", async () => { + mockConfig.set("coder.disableSignatureVerification", true); + vi.mocked(cli.version).mockResolvedValueOnce("1.5.9"); // No existing binary + setupSuccessfulDownload(mockApi); + + const result = await manager.fetchBinary(mockApi, "test"); + + expect(result).toBe(BINARY_PATH); + expect(pgp.verifySignature).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + "Skipping binary signature verification due to settings", + ); + }); + }); +}); + +// Helper Classes +class MockConfigurationProvider implements ConfigurationProvider { + private config = new Map(); + + set(key: string, value: unknown): void { + this.config.set(key, value); + } + + get(key: string): T | undefined; + get(key: string, defaultValue: T): T; + get(key: string, defaultValue?: T): T | undefined { + const value = this.config.get(key); + return value !== undefined ? (value as T) : defaultValue; + } +} + +class MockProgressReporter implements ProgressReporter { + private shouldCancel = false; + + setCancellation(cancel: boolean): void { + this.shouldCancel = cancel; + } + + async withProgress( + _title: string, + operation: ( + progress: { + report: (value: { message?: string; increment?: number }) => void; + }, + cancellationToken: { + onCancellationRequested: (listener: () => void) => void; + }, + ) => Promise, + ): Promise { + const mockToken = { + onCancellationRequested: vi.fn((callback: () => void) => { + if (this.shouldCancel) { + setTimeout(callback, 0); + } + }), + }; + return operation({ report: vi.fn() }, mockToken); + } +} + +class MockUserInteraction implements UserInteraction { + private responses = new Map(); + + setResponse(response: string | undefined): void; + setResponse(message: string, response: string | undefined): void; + setResponse( + messageOrResponse: string | undefined, + response?: string | undefined, + ): void { + if (response === undefined && messageOrResponse !== undefined) { + // Single argument - set default response + this.responses.set("default", messageOrResponse); + } else if (messageOrResponse !== undefined) { + // Two arguments - set specific response + this.responses.set(messageOrResponse, response); + } + } + + showErrorMessage = vi.fn( + async (message: string): Promise => { + return ( + (await this.responses.get(message)) ?? this.responses.get("default") + ); + }, + ); + + showWarningMessage = vi.fn( + async (message: string): Promise => { + return ( + (await this.responses.get(message)) ?? this.responses.get("default") + ); + }, + ); + + openExternal = vi.fn(); +} + +// Helper Functions +function createMockLogger(): Logger { + return { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +function createMockApi(version: string, url: string): Api { + const mockAxios = { + defaults: { baseURL: url }, + get: vi.fn(), + } as unknown as AxiosInstance; + + return { + getBuildInfo: vi.fn().mockResolvedValue({ + version, + external_url: url, + dashboard_url: url, + telemetry: false, + workspace_proxy: false, + upgrade_message: "", + deployment_id: "test", + agent_api_version: "1.0", + provisioner_api_version: "1.0", + } as BuildInfoResponse), + getAxiosInstance: vi.fn().mockReturnValue(mockAxios), + } as unknown as Api; +} + +function setupDefaultCliMocks(): void { + vi.mocked(cli.name).mockReturnValue("coder"); + vi.mocked(cli.stat).mockResolvedValue(undefined); + vi.mocked(cli.rmOld).mockResolvedValue([]); + vi.mocked(cli.eTag).mockResolvedValue(""); + vi.mocked(cli.version).mockResolvedValue("1.2.3"); + vi.mocked(cli.goos).mockReturnValue("linux"); + vi.mocked(cli.goarch).mockReturnValue("amd64"); + vi.mocked(fse.mkdir).mockResolvedValue(undefined); + vi.mocked(fse.rename).mockResolvedValue(undefined); +} + +function setupExistingBinary(version: string): void { + vi.mocked(cli.stat).mockResolvedValue({ size: 1024 } as Stats); + vi.mocked(cli.version).mockResolvedValue(version); +} + +function setupStreamMocks() { + const mockWriteStream = { + on: vi.fn().mockReturnThis(), + write: vi.fn((_buffer: Buffer, callback?: () => void) => { + callback?.(); + }), + close: vi.fn(), + } as unknown as WriteStream; + + const mockReadStream = createMockReadStream(); + + return { mockWriteStream, mockReadStream }; +} + +function createMockReadStream( + customHandler?: (event: string, callback: (data?: unknown) => void) => void, +): IncomingMessage { + return { + on: vi.fn((event: string, callback: (data?: unknown) => void) => { + if (customHandler) { + customHandler(event, callback); + } else { + if (event === "data") { + setTimeout(() => callback(Buffer.from("mock-data")), 0); + } else if (event === "close") { + setTimeout(callback, 10); + } + } + }), + destroy: vi.fn(), + } as unknown as IncomingMessage; +} + +function createStreamResponse(status: number, stream: IncomingMessage) { + return { + status, + headers: { "content-length": "1024" }, + data: stream, + }; +} + +function setupSuccessfulDownload(mockApi: Api): void { + const { mockWriteStream, mockReadStream } = setupStreamMocks(); + + vi.mocked(fs.createWriteStream).mockReturnValue(mockWriteStream); + const axios = mockApi.getAxiosInstance(); + axios.get = vi + .fn() + .mockResolvedValue(createStreamResponse(200, mockReadStream)); +} + +function setupSuccessfulDownloadWithSignature(mockApi: Api): void { + const { mockWriteStream, mockReadStream } = setupStreamMocks(); + const signatureStream = createMockReadStream(); + + vi.mocked(fs.createWriteStream).mockReturnValue(mockWriteStream); + vi.mocked(cli.stat) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ size: 1024 } as Stats); + + const axios = mockApi.getAxiosInstance() as AxiosInstance; + axios.get = vi + .fn() + .mockResolvedValueOnce(createStreamResponse(200, mockReadStream)) // Binary + .mockResolvedValueOnce(createStreamResponse(200, signatureStream)); // Signature +} diff --git a/src/core/binaryManager.ts b/src/core/binaryManager.ts index b73835c7..e851d8a7 100644 --- a/src/core/binaryManager.ts +++ b/src/core/binaryManager.ts @@ -9,18 +9,25 @@ import { IncomingMessage } from "http"; import path from "path"; import prettyBytes from "pretty-bytes"; import * as semver from "semver"; -import * as vscode from "vscode"; import { errToStr } from "../api-helper"; import * as cli from "../cliManager"; import { Logger } from "../logging/logger"; import * as pgp from "../pgp"; +import { + ConfigurationProvider, + ProgressReporter, + UserInteraction, +} from "./binaryManager.interfaces"; import { PathResolver } from "./pathResolver"; export class BinaryManager { constructor( private readonly output: Logger, private readonly pathResolver: PathResolver, + private readonly config: ConfigurationProvider, + private readonly progressReporter: ProgressReporter, + private readonly userInteraction: UserInteraction, ) {} /** @@ -35,11 +42,9 @@ export class BinaryManager { * downloads being disabled. */ public async fetchBinary(restClient: Api, label: string): Promise { - const cfg = vscode.workspace.getConfiguration("coder"); - // Settings can be undefined when set to their defaults (true in this case), // so explicitly check against false. - const enableDownloads = cfg.get("enableDownloads") !== false; + const enableDownloads = this.config.get("coder.enableDownloads") !== false; this.output.info("Downloads are", enableDownloads ? "enabled" : "disabled"); // Get the build info to compare with the existing binary version, if any, @@ -108,7 +113,7 @@ export class BinaryManager { // Figure out where to get the binary. const binName = cli.name(); - const configSource = cfg.get("binarySource"); + const configSource = this.config.get("coder.binarySource"); const binSource = configSource && String(configSource).trim().length > 0 ? String(configSource) @@ -136,7 +141,7 @@ export class BinaryManager { switch (status) { case 200: { - if (cfg.get("disableSignatureVerification")) { + if (this.config.get("coder.disableSignatureVerification")) { this.output.info( "Skipping binary signature verification due to settings", ); @@ -190,9 +195,10 @@ export class BinaryManager { return binPath; } case 404: { - vscode.window + this.userInteraction .showErrorMessage( "Coder isn't supported for your platform. Please open an issue, we'd love to support it!", + {}, "Open an Issue", ) .then((value) => { @@ -205,18 +211,16 @@ export class BinaryManager { title: `Support the \`${os}-${arch}\` platform`, body: `I'd like to use the \`${os}-${arch}\` architecture with the VS Code extension.`, }); - const uri = vscode.Uri.parse( - `https://github.com/coder/vscode-coder/issues/new?` + - params.toString(), - ); - vscode.env.openExternal(uri); + const url = `https://github.com/coder/vscode-coder/issues/new?${params.toString()}`; + this.userInteraction.openExternal(url); }); throw new Error("Platform not supported"); } default: { - vscode.window + this.userInteraction .showErrorMessage( "Failed to download binary. Please open an issue.", + {}, "Open an Issue", ) .then((value) => { @@ -227,11 +231,8 @@ export class BinaryManager { title: `Failed to download binary on \`${cli.goos()}-${cli.goarch()}\``, body: `Received status code \`${status}\` when downloading the binary.`, }); - const uri = vscode.Uri.parse( - `https://github.com/coder/vscode-coder/issues/new?` + - params.toString(), - ); - vscode.env.openExternal(uri); + const url = `https://github.com/coder/vscode-coder/issues/new?${params.toString()}`; + this.userInteraction.openExternal(url); }); throw new Error("Failed to download binary"); } @@ -277,12 +278,8 @@ export class BinaryManager { // Track how many bytes were written. let written = 0; - const completed = await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: `Downloading ${baseUrl}`, - cancellable: true, - }, + const completed = await this.progressReporter.withProgress( + `Downloading ${baseUrl}`, async (progress, token) => { const readStream = resp.data as IncomingMessage; let cancelled = false; @@ -396,7 +393,7 @@ export class BinaryManager { options.push("Download signature"); } options.push("Run without verification"); - const action = await vscode.window.showWarningMessage( + const action = await this.userInteraction.showWarningMessage( status === 404 ? "Signature not found" : "Failed to download signature", { useCustom: true, @@ -450,7 +447,7 @@ export class BinaryManager { this.output, ); } catch (error) { - const action = await vscode.window.showWarningMessage( + const action = await this.userInteraction.showWarningMessage( // VerificationError should be the only thing that throws, but // unfortunately caught errors are always type unknown. error instanceof pgp.VerificationError diff --git a/src/error.test.ts b/src/error.test.ts index 2d591d89..d91d08b6 100644 --- a/src/error.test.ts +++ b/src/error.test.ts @@ -2,260 +2,275 @@ import axios from "axios"; import * as fs from "fs/promises"; import https from "https"; import * as path from "path"; -import { afterAll, beforeAll, it, expect, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { CertificateError, X509_ERR, X509_ERR_CODE } from "./error"; import { Logger } from "./logging/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. +describe("Certificate errors", () => { + // 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. -// TODO: These sanity checks need to be ran in an Electron environment to -// reflect real usage in VS Code. We should either revert back to the standard -// extension testing framework which I believe runs in a headless VS Code -// instead of using vitest or at least run the tests through Electron running as -// Node (for now I do this manually by shimming Node). -const isElectron = - process.versions.electron || process.env.ELECTRON_RUN_AS_NODE; + // TODO: These sanity checks need to be ran in an Electron environment to + // reflect real usage in VS Code. We should either revert back to the standard + // extension testing framework which I believe runs in a headless VS Code + // instead of using vitest or at least run the tests through Electron running as + // Node (for now I do this manually by shimming Node). + const isElectron = + (process.versions.electron || process.env.ELECTRON_RUN_AS_NODE) && + !process.env.VSCODE_PID; // Running from the test explorer in VS Code -// TODO: Remove the vscode mock once we revert the testing framework. -beforeAll(() => { - vi.mock("vscode", () => { - return {}; + // TODO: Remove the vscode mock once we revert the testing framework. + beforeAll(() => { + vi.mock("vscode", () => { + return {}; + }); }); -}); -const throwingLog = (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 logger: Logger = { + trace: throwingLog, + debug: throwingLog, + info: throwingLog, + warn: throwingLog, + error: throwingLog, + }; -const disposers: (() => void)[] = []; -afterAll(() => { - disposers.forEach((d) => d()); -}); + const disposers: (() => void)[] = []; + afterAll(() => { + disposers.forEach((d) => d()); + }); -async function startServer(certName: string): Promise { - const server = https.createServer( - { - key: await fs.readFile( - path.join(__dirname, `../fixtures/tls/${certName}.key`), - ), - cert: await fs.readFile( - path.join(__dirname, `../fixtures/tls/${certName}.crt`), - ), - }, - (req, res) => { - if (req.url?.endsWith("/error")) { - res.writeHead(500); - res.end("error"); - return; - } - res.writeHead(200); - res.end("foobar"); - }, - ); - disposers.push(() => server.close()); - return new Promise((resolve, reject) => { - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address) { - throw new Error("Server has no address"); - } - if (typeof address !== "string") { - const host = - address.family === "IPv6" ? `[${address.address}]` : address.address; - return resolve(`https://${host}:${address.port}`); - } - resolve(address); + async function startServer(certName: string): Promise { + const server = https.createServer( + { + key: await fs.readFile( + path.join(__dirname, `../fixtures/tls/${certName}.key`), + ), + cert: await fs.readFile( + path.join(__dirname, `../fixtures/tls/${certName}.crt`), + ), + }, + (req, res) => { + if (req.url?.endsWith("/error")) { + res.writeHead(500); + res.end("error"); + return; + } + res.writeHead(200); + res.end("foobar"); + }, + ); + disposers.push(() => server.close()); + return new Promise((resolve, reject) => { + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address) { + throw new Error("Server has no address"); + } + if (typeof address !== "string") { + const host = + address.family === "IPv6" + ? `[${address.address}]` + : address.address; + return resolve(`https://${host}:${address.port}`); + } + resolve(address); + }); + }); + } + + // Both environments give the "unable to verify" error with partial chains. + it("detects partial chains", async () => { + const address = await startServer("chain-leaf"); + const request = axios.get(address, { + httpsAgent: new https.Agent({ + ca: await fs.readFile( + path.join(__dirname, "../fixtures/tls/chain-leaf.crt"), + ), + }), }); + await expect(request).rejects.toHaveProperty( + "code", + X509_ERR_CODE.UNABLE_TO_VERIFY_LEAF_SIGNATURE, + ); + try { + await request; + } catch (error) { + const wrapped = await CertificateError.maybeWrap(error, address, logger); + expect(wrapped instanceof CertificateError).toBeTruthy(); + expect((wrapped as CertificateError).x509Err).toBe( + X509_ERR.PARTIAL_CHAIN, + ); + } }); -} -// Both environments give the "unable to verify" error with partial chains. -it("detects partial chains", async () => { - const address = await startServer("chain-leaf"); - const request = axios.get(address, { - httpsAgent: new https.Agent({ - ca: await fs.readFile( - path.join(__dirname, "../fixtures/tls/chain-leaf.crt"), - ), - }), + it("can bypass partial chain", async () => { + const address = await startServer("chain-leaf"); + const request = axios.get(address, { + httpsAgent: new https.Agent({ + rejectUnauthorized: false, + }), + }); + await expect(request).resolves.toHaveProperty("data", "foobar"); }); - await expect(request).rejects.toHaveProperty( - "code", - X509_ERR_CODE.UNABLE_TO_VERIFY_LEAF_SIGNATURE, - ); - try { - await request; - } catch (error) { - const wrapped = await CertificateError.maybeWrap(error, address, logger); - expect(wrapped instanceof CertificateError).toBeTruthy(); - expect((wrapped as CertificateError).x509Err).toBe(X509_ERR.PARTIAL_CHAIN); - } -}); -it("can bypass partial chain", async () => { - const address = await startServer("chain-leaf"); - const request = axios.get(address, { - httpsAgent: new https.Agent({ - rejectUnauthorized: false, - }), + // In Electron a self-issued certificate without the signing capability fails + // (again with the same "unable to verify" error) but in Node self-issued + // certificates are not required to have the signing capability. + it("detects self-signed certificates without signing capability", async () => { + const address = await startServer("no-signing"); + const request = axios.get(address, { + httpsAgent: new https.Agent({ + ca: await fs.readFile( + path.join(__dirname, "../fixtures/tls/no-signing.crt"), + ), + servername: "localhost", + }), + }); + if (isElectron) { + await expect(request).rejects.toHaveProperty( + "code", + X509_ERR_CODE.UNABLE_TO_VERIFY_LEAF_SIGNATURE, + ); + try { + await request; + } catch (error) { + const wrapped = await CertificateError.maybeWrap( + error, + address, + logger, + ); + expect(wrapped instanceof CertificateError).toBeTruthy(); + expect((wrapped as CertificateError).x509Err).toBe( + X509_ERR.NON_SIGNING, + ); + } + } else { + await expect(request).resolves.toHaveProperty("data", "foobar"); + } }); - await expect(request).resolves.toHaveProperty("data", "foobar"); -}); -// In Electron a self-issued certificate without the signing capability fails -// (again with the same "unable to verify" error) but in Node self-issued -// certificates are not required to have the signing capability. -it("detects self-signed certificates without signing capability", async () => { - const address = await startServer("no-signing"); - const request = axios.get(address, { - httpsAgent: new https.Agent({ - ca: await fs.readFile( - path.join(__dirname, "../fixtures/tls/no-signing.crt"), - ), - servername: "localhost", - }), + it("can bypass self-signed certificates without signing capability", async () => { + const address = await startServer("no-signing"); + const request = axios.get(address, { + httpsAgent: new https.Agent({ + rejectUnauthorized: false, + }), + }); + await expect(request).resolves.toHaveProperty("data", "foobar"); }); - if (isElectron) { + + // Both environments give the same error code when a self-issued certificate is + // untrusted. + it("detects self-signed certificates", async () => { + const address = await startServer("self-signed"); + const request = axios.get(address); await expect(request).rejects.toHaveProperty( "code", - X509_ERR_CODE.UNABLE_TO_VERIFY_LEAF_SIGNATURE, + X509_ERR_CODE.DEPTH_ZERO_SELF_SIGNED_CERT, ); try { await request; } catch (error) { const wrapped = await CertificateError.maybeWrap(error, address, logger); expect(wrapped instanceof CertificateError).toBeTruthy(); - expect((wrapped as CertificateError).x509Err).toBe(X509_ERR.NON_SIGNING); + expect((wrapped as CertificateError).x509Err).toBe( + X509_ERR.UNTRUSTED_LEAF, + ); } - } else { - await expect(request).resolves.toHaveProperty("data", "foobar"); - } -}); - -it("can bypass self-signed certificates without signing capability", async () => { - const address = await startServer("no-signing"); - const request = axios.get(address, { - httpsAgent: new https.Agent({ - rejectUnauthorized: false, - }), }); - await expect(request).resolves.toHaveProperty("data", "foobar"); -}); -// Both environments give the same error code when a self-issued certificate is -// untrusted. -it("detects self-signed certificates", async () => { - const address = await startServer("self-signed"); - const request = axios.get(address); - await expect(request).rejects.toHaveProperty( - "code", - X509_ERR_CODE.DEPTH_ZERO_SELF_SIGNED_CERT, - ); - try { - await request; - } catch (error) { - const wrapped = await CertificateError.maybeWrap(error, address, logger); - expect(wrapped instanceof CertificateError).toBeTruthy(); - expect((wrapped as CertificateError).x509Err).toBe(X509_ERR.UNTRUSTED_LEAF); - } -}); - -// Both environments have no problem if the self-issued certificate is trusted -// and has the signing capability. -it("is ok with trusted self-signed certificates", async () => { - const address = await startServer("self-signed"); - const request = axios.get(address, { - httpsAgent: new https.Agent({ - ca: await fs.readFile( - path.join(__dirname, "../fixtures/tls/self-signed.crt"), - ), - servername: "localhost", - }), + // Both environments have no problem if the self-issued certificate is trusted + // and has the signing capability. + it("is ok with trusted self-signed certificates", async () => { + const address = await startServer("self-signed"); + const request = axios.get(address, { + httpsAgent: new https.Agent({ + ca: await fs.readFile( + path.join(__dirname, "../fixtures/tls/self-signed.crt"), + ), + servername: "localhost", + }), + }); + await expect(request).resolves.toHaveProperty("data", "foobar"); }); - await expect(request).resolves.toHaveProperty("data", "foobar"); -}); -it("can bypass self-signed certificates", async () => { - const address = await startServer("self-signed"); - const request = axios.get(address, { - httpsAgent: new https.Agent({ - rejectUnauthorized: false, - }), + it("can bypass self-signed certificates", async () => { + const address = await startServer("self-signed"); + const request = axios.get(address, { + httpsAgent: new https.Agent({ + rejectUnauthorized: false, + }), + }); + await expect(request).resolves.toHaveProperty("data", "foobar"); }); - await expect(request).resolves.toHaveProperty("data", "foobar"); -}); -// Both environments give the same error code when the chain is complete but the -// root is not trusted. -it("detects an untrusted chain", async () => { - const address = await startServer("chain"); - const request = axios.get(address); - await expect(request).rejects.toHaveProperty( - "code", - X509_ERR_CODE.SELF_SIGNED_CERT_IN_CHAIN, - ); - try { - await request; - } catch (error) { - const wrapped = await CertificateError.maybeWrap(error, address, logger); - expect(wrapped instanceof CertificateError).toBeTruthy(); - expect((wrapped as CertificateError).x509Err).toBe( - X509_ERR.UNTRUSTED_CHAIN, + // Both environments give the same error code when the chain is complete but the + // root is not trusted. + it("detects an untrusted chain", async () => { + const address = await startServer("chain"); + const request = axios.get(address); + await expect(request).rejects.toHaveProperty( + "code", + X509_ERR_CODE.SELF_SIGNED_CERT_IN_CHAIN, ); - } -}); + try { + await request; + } catch (error) { + const wrapped = await CertificateError.maybeWrap(error, address, logger); + expect(wrapped instanceof CertificateError).toBeTruthy(); + expect((wrapped as CertificateError).x509Err).toBe( + X509_ERR.UNTRUSTED_CHAIN, + ); + } + }); -// Both environments have no problem if the chain is complete and the root is -// trusted. -it("is ok with chains with a trusted root", async () => { - const address = await startServer("chain"); - const request = axios.get(address, { - httpsAgent: new https.Agent({ - ca: await fs.readFile( - path.join(__dirname, "../fixtures/tls/chain-root.crt"), - ), - servername: "localhost", - }), + // Both environments have no problem if the chain is complete and the root is + // trusted. + it("is ok with chains with a trusted root", async () => { + const address = await startServer("chain"); + const request = axios.get(address, { + httpsAgent: new https.Agent({ + ca: await fs.readFile( + path.join(__dirname, "../fixtures/tls/chain-root.crt"), + ), + servername: "localhost", + }), + }); + await expect(request).resolves.toHaveProperty("data", "foobar"); }); - await expect(request).resolves.toHaveProperty("data", "foobar"); -}); -it("can bypass chain", async () => { - const address = await startServer("chain"); - const request = axios.get(address, { - httpsAgent: new https.Agent({ - rejectUnauthorized: false, - }), + it("can bypass chain", async () => { + const address = await startServer("chain"); + const request = axios.get(address, { + httpsAgent: new https.Agent({ + rejectUnauthorized: false, + }), + }); + await expect(request).resolves.toHaveProperty("data", "foobar"); }); - await expect(request).resolves.toHaveProperty("data", "foobar"); -}); -it("falls back with different error", async () => { - const address = await startServer("chain"); - const request = axios.get(address + "/error", { - httpsAgent: new https.Agent({ - ca: await fs.readFile( - path.join(__dirname, "../fixtures/tls/chain-root.crt"), - ), - servername: "localhost", - }), + it("falls back with different error", async () => { + const address = await startServer("chain"); + const request = axios.get(address + "/error", { + httpsAgent: new https.Agent({ + ca: await fs.readFile( + path.join(__dirname, "../fixtures/tls/chain-root.crt"), + ), + servername: "localhost", + }), + }); + await expect(request).rejects.toThrow(/failed with status code 500/); + try { + await request; + } catch (error) { + const wrapped = await CertificateError.maybeWrap(error, "1", logger); + expect(wrapped instanceof CertificateError).toBeFalsy(); + expect((wrapped as Error).message).toMatch(/failed with status code 500/); + } }); - await expect(request).rejects.toMatch(/failed with status code 500/); - try { - await request; - } catch (error) { - const wrapped = await CertificateError.maybeWrap(error, "1", logger); - expect(wrapped instanceof CertificateError).toBeFalsy(); - expect((wrapped as Error).message).toMatch(/failed with status code 500/); - } }); diff --git a/src/extension.ts b/src/extension.ts index ba2f2310..144a53d1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,6 +8,11 @@ import { CoderApi } from "./api/coderApi"; import { needToken } from "./api/utils"; import { Commands } from "./commands"; import { BinaryManager } from "./core/binaryManager"; +import { + VSCodeConfigurationProvider, + VSCodeProgressReporter, + VSCodeUserInteraction, +} from "./core/binaryManager.adapters"; import { CliConfigManager } from "./core/cliConfig"; import { MementoManager } from "./core/mementoManager"; import { PathResolver } from "./core/pathResolver"; @@ -256,7 +261,13 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }, }); - const binaryManager = new BinaryManager(output, pathResolver); + const binaryManager = new BinaryManager( + output, + pathResolver, + new VSCodeConfigurationProvider(), + new VSCodeProgressReporter(), + new VSCodeUserInteraction(), + ); // Register globally available commands. Many of these have visibility // controlled by contexts, see `when` in the package.json. diff --git a/src/headers.test.ts b/src/headers.test.ts index 10e77f8d..84c39d36 100644 --- a/src/headers.test.ts +++ b/src/headers.test.ts @@ -66,25 +66,25 @@ it("should return headers", async () => { it("should error on malformed or empty lines", async () => { await expect( getHeaders("localhost", "printf 'foo=bar\\r\\n\\r\\n'", logger), - ).rejects.toMatch(/Malformed/); + ).rejects.toThrow(/Malformed/); await expect( getHeaders("localhost", "printf '\\r\\nfoo=bar'", logger), - ).rejects.toMatch(/Malformed/); + ).rejects.toThrow(/Malformed/); await expect( getHeaders("localhost", "printf '=foo'", logger), - ).rejects.toMatch(/Malformed/); - await expect(getHeaders("localhost", "printf 'foo'", logger)).rejects.toMatch( + ).rejects.toThrow(/Malformed/); + await expect(getHeaders("localhost", "printf 'foo'", logger)).rejects.toThrow( /Malformed/, ); await expect( getHeaders("localhost", "printf ' =foo'", logger), - ).rejects.toMatch(/Malformed/); + ).rejects.toThrow(/Malformed/); await expect( getHeaders("localhost", "printf 'foo =bar'", logger), - ).rejects.toMatch(/Malformed/); + ).rejects.toThrow(/Malformed/); await expect( getHeaders("localhost", "printf 'foo foo=bar'", logger), - ).rejects.toMatch(/Malformed/); + ).rejects.toThrow(/Malformed/); }); it("should have access to environment variables", async () => { @@ -101,7 +101,7 @@ it("should have access to environment variables", async () => { }); it("should error on non-zero exit", async () => { - await expect(getHeaders("localhost", "exit 10", logger)).rejects.toMatch( + await expect(getHeaders("localhost", "exit 10", logger)).rejects.toThrow( /exited unexpectedly with code 10/, ); }); diff --git a/vitest.config.ts b/vitest.config.ts index 2007fb45..6cf29157 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,5 +13,8 @@ export default defineConfig({ "./src/test/**", ], environment: "node", + coverage: { + provider: "v8", + }, }, }); diff --git a/yarn.lock b/yarn.lock index f30780a2..45d34629 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,7 +12,7 @@ resolved "https://registry.yarnpkg.com/@altano/repository-tools/-/repository-tools-1.0.1.tgz#969bb94cc80f8b4d62c7d6956466edc3f3c3817a" integrity sha512-/FFHQOMp5TZWplkDWbbLIjmANDr9H/FtqUm+hfJMK76OBut0Ht0cNfd0ZXd/6LXf4pWUTzvpgVjcin7EEHSznA== -"@ampproject/remapping@^2.2.0": +"@ampproject/remapping@^2.2.0", "@ampproject/remapping@^2.3.0": version "2.3.0" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4" integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== @@ -232,6 +232,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz#1aabb72ee72ed35789b4bbcad3ca2862ce614e8c" integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + "@babel/helper-validator-identifier@^7.22.20": version "7.22.20" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0" @@ -276,6 +281,13 @@ dependencies: "@babel/types" "^7.26.0" +"@babel/parser@^7.25.4": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.4.tgz#da25d4643532890932cc03f7705fe19637e03fa8" + integrity sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg== + dependencies: + "@babel/types" "^7.28.4" + "@babel/template@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.25.9.tgz#ecb62d81a8a6f5dc5fe8abfc3901fc52ddf15016" @@ -298,6 +310,14 @@ debug "^4.3.1" globals "^11.1.0" +"@babel/types@^7.25.4", "@babel/types@^7.28.4": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.4.tgz#0a4e618f4c60a7cd6c11cb2d48060e4dbe38ac3a" + integrity sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + "@babel/types@^7.25.9", "@babel/types@^7.26.0": version "7.26.0" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.0.tgz#deabd08d6b753bc8e0f198f8709fb575e31774ff" @@ -311,125 +331,145 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== +"@bcoe/v8-coverage@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz#bbe12dca5b4ef983a0d0af4b07b9bc90ea0ababa" + integrity sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA== + "@discoveryjs/json-ext@^0.5.0": version "0.5.7" resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== -"@esbuild/aix-ppc64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" - integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ== - -"@esbuild/android-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" - integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A== - -"@esbuild/android-arm@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" - integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg== - -"@esbuild/android-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" - integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA== - -"@esbuild/darwin-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" - integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ== - -"@esbuild/darwin-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" - integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw== - -"@esbuild/freebsd-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" - integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g== - -"@esbuild/freebsd-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" - integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ== - -"@esbuild/linux-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" - integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q== - -"@esbuild/linux-arm@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" - integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA== - -"@esbuild/linux-ia32@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" - integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg== - -"@esbuild/linux-loong64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" - integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg== - -"@esbuild/linux-mips64el@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" - integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg== - -"@esbuild/linux-ppc64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" - integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w== - -"@esbuild/linux-riscv64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" - integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA== - -"@esbuild/linux-s390x@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" - integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A== - -"@esbuild/linux-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" - integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ== - -"@esbuild/netbsd-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" - integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg== - -"@esbuild/openbsd-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" - integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow== - -"@esbuild/sunos-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" - integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg== - -"@esbuild/win32-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" - integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A== - -"@esbuild/win32-ia32@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" - integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA== - -"@esbuild/win32-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" - integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== +"@esbuild/aix-ppc64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz#bef96351f16520055c947aba28802eede3c9e9a9" + integrity sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA== + +"@esbuild/android-arm64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz#d2e70be7d51a529425422091e0dcb90374c1546c" + integrity sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg== + +"@esbuild/android-arm@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.9.tgz#d2a753fe2a4c73b79437d0ba1480e2d760097419" + integrity sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ== + +"@esbuild/android-x64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.9.tgz#5278836e3c7ae75761626962f902a0d55352e683" + integrity sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw== + +"@esbuild/darwin-arm64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz#f1513eaf9ec8fa15dcaf4c341b0f005d3e8b47ae" + integrity sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg== + +"@esbuild/darwin-x64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz#e27dbc3b507b3a1cea3b9280a04b8b6b725f82be" + integrity sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ== + +"@esbuild/freebsd-arm64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz#364e3e5b7a1fd45d92be08c6cc5d890ca75908ca" + integrity sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q== + +"@esbuild/freebsd-x64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz#7c869b45faeb3df668e19ace07335a0711ec56ab" + integrity sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg== + +"@esbuild/linux-arm64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz#48d42861758c940b61abea43ba9a29b186d6cb8b" + integrity sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw== + +"@esbuild/linux-arm@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz#6ce4b9cabf148274101701d112b89dc67cc52f37" + integrity sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw== + +"@esbuild/linux-ia32@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz#207e54899b79cac9c26c323fc1caa32e3143f1c4" + integrity sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A== + +"@esbuild/linux-loong64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz#0ba48a127159a8f6abb5827f21198b999ffd1fc0" + integrity sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ== + +"@esbuild/linux-mips64el@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz#a4d4cc693d185f66a6afde94f772b38ce5d64eb5" + integrity sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA== + +"@esbuild/linux-ppc64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz#0f5805c1c6d6435a1dafdc043cb07a19050357db" + integrity sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w== + +"@esbuild/linux-riscv64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz#6776edece0f8fca79f3386398b5183ff2a827547" + integrity sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg== + +"@esbuild/linux-s390x@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz#3f6f29ef036938447c2218d309dc875225861830" + integrity sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA== + +"@esbuild/linux-x64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz#831fe0b0e1a80a8b8391224ea2377d5520e1527f" + integrity sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg== + +"@esbuild/netbsd-arm64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz#06f99d7eebe035fbbe43de01c9d7e98d2a0aa548" + integrity sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q== + +"@esbuild/netbsd-x64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz#db99858e6bed6e73911f92a88e4edd3a8c429a52" + integrity sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g== + +"@esbuild/openbsd-arm64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz#afb886c867e36f9d86bb21e878e1185f5d5a0935" + integrity sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ== + +"@esbuild/openbsd-x64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz#30855c9f8381fac6a0ef5b5f31ac6e7108a66ecf" + integrity sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA== + +"@esbuild/openharmony-arm64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz#2f2144af31e67adc2a8e3705c20c2bd97bd88314" + integrity sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg== + +"@esbuild/sunos-x64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz#69b99a9b5bd226c9eb9c6a73f990fddd497d732e" + integrity sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw== + +"@esbuild/win32-arm64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz#d789330a712af916c88325f4ffe465f885719c6b" + integrity sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ== + +"@esbuild/win32-ia32@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz#52fc735406bd49688253e74e4e837ac2ba0789e3" + integrity sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww== + +"@esbuild/win32-x64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz#585624dc829cfb6e7c0aa6c3ca7d7e6daa87e34f" + integrity sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ== "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.4.0" @@ -522,13 +562,6 @@ resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== -"@jest/schemas@^29.6.3": - version "29.6.3" - resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03" - integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== - dependencies: - "@sinclair/typebox" "^0.27.8" - "@jridgewell/gen-mapping@^0.3.0": version "0.3.2" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz#c1aedc61e853f2bb9f5dfe6d4442d3b565b253b9" @@ -580,11 +613,16 @@ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== -"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.4.15": +"@jridgewell/sourcemap-codec@^1.4.14": version "1.4.15" resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== +"@jridgewell/sourcemap-codec@^1.5.5": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + "@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": version "0.3.25" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" @@ -593,6 +631,14 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@jridgewell/trace-mapping@^0.3.23", "@jridgewell/trace-mapping@^0.3.30": + version "0.3.31" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + "@jridgewell/trace-mapping@^0.3.9": version "0.3.17" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz#793041277af9073b0951a7fe0f0d8c4c98c36985" @@ -652,105 +698,110 @@ resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.7.tgz#eb5014dfd0b03e7f3ba2eeeff506eed89b028058" integrity sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg== -"@rollup/rollup-android-arm-eabi@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.39.0.tgz#1d8cc5dd3d8ffe569d8f7f67a45c7909828a0f66" - integrity sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA== - -"@rollup/rollup-android-arm64@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.39.0.tgz#9c136034d3d9ed29d0b138c74dd63c5744507fca" - integrity sha512-It9+M1zE31KWfqh/0cJLrrsCPiF72PoJjIChLX+rEcujVRCb4NLQ5QzFkzIZW8Kn8FTbvGQBY5TkKBau3S8cCQ== - -"@rollup/rollup-darwin-arm64@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.39.0.tgz#830d07794d6a407c12b484b8cf71affd4d3800a6" - integrity sha512-lXQnhpFDOKDXiGxsU9/l8UEGGM65comrQuZ+lDcGUx+9YQ9dKpF3rSEGepyeR5AHZ0b5RgiligsBhWZfSSQh8Q== - -"@rollup/rollup-darwin-x64@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.39.0.tgz#b26f0f47005c1fa5419a880f323ed509dc8d885c" - integrity sha512-mKXpNZLvtEbgu6WCkNij7CGycdw9cJi2k9v0noMb++Vab12GZjFgUXD69ilAbBh034Zwn95c2PNSz9xM7KYEAQ== - -"@rollup/rollup-freebsd-arm64@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.39.0.tgz#2b60c81ac01ff7d1bc8df66aee7808b6690c6d19" - integrity sha512-jivRRlh2Lod/KvDZx2zUR+I4iBfHcu2V/BA2vasUtdtTN2Uk3jfcZczLa81ESHZHPHy4ih3T/W5rPFZ/hX7RtQ== - -"@rollup/rollup-freebsd-x64@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.39.0.tgz#4826af30f4d933d82221289068846c9629cc628c" - integrity sha512-8RXIWvYIRK9nO+bhVz8DwLBepcptw633gv/QT4015CpJ0Ht8punmoHU/DuEd3iw9Hr8UwUV+t+VNNuZIWYeY7Q== - -"@rollup/rollup-linux-arm-gnueabihf@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.39.0.tgz#a1f4f963d5dcc9e5575c7acf9911824806436bf7" - integrity sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g== - -"@rollup/rollup-linux-arm-musleabihf@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.39.0.tgz#e924b0a8b7c400089146f6278446e6b398b75a06" - integrity sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw== - -"@rollup/rollup-linux-arm64-gnu@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.39.0.tgz#cb43303274ec9a716f4440b01ab4e20c23aebe20" - integrity sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ== - -"@rollup/rollup-linux-arm64-musl@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.39.0.tgz#531c92533ce3d167f2111bfcd2aa1a2041266987" - integrity sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA== - -"@rollup/rollup-linux-loongarch64-gnu@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.39.0.tgz#53403889755d0c37c92650aad016d5b06c1b061a" - integrity sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw== - -"@rollup/rollup-linux-powerpc64le-gnu@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.39.0.tgz#f669f162e29094c819c509e99dbeced58fc708f9" - integrity sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ== - -"@rollup/rollup-linux-riscv64-gnu@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.39.0.tgz#4bab37353b11bcda5a74ca11b99dea929657fd5f" - integrity sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ== - -"@rollup/rollup-linux-riscv64-musl@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.39.0.tgz#4d66be1ce3cfd40a7910eb34dddc7cbd4c2dd2a5" - integrity sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA== - -"@rollup/rollup-linux-s390x-gnu@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.39.0.tgz#7181c329395ed53340a0c59678ad304a99627f6d" - integrity sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA== - -"@rollup/rollup-linux-x64-gnu@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.39.0.tgz#00825b3458094d5c27cb4ed66e88bfe9f1e65f90" - integrity sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA== - -"@rollup/rollup-linux-x64-musl@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.39.0.tgz#81caac2a31b8754186f3acc142953a178fcd6fba" - integrity sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg== - -"@rollup/rollup-win32-arm64-msvc@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.39.0.tgz#3a3f421f5ce9bd99ed20ce1660cce7cee3e9f199" - integrity sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ== - -"@rollup/rollup-win32-ia32-msvc@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.39.0.tgz#a44972d5cdd484dfd9cf3705a884bf0c2b7785a7" - integrity sha512-6w9uMuza+LbLCVoNKL5FSLE7yvYkq9laSd09bwS0tMjkwXrmib/4KmoJcrKhLWHvw19mwU+33ndC69T7weNNjQ== - -"@rollup/rollup-win32-x64-msvc@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.39.0.tgz#bfe0214e163f70c4fec1c8f7bb8ce266f4c05b7e" - integrity sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug== +"@rollup/rollup-android-arm-eabi@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.2.tgz#52d66eba5198155f265f54aed94d2489c49269f6" + integrity sha512-uLN8NAiFVIRKX9ZQha8wy6UUs06UNSZ32xj6giK/rmMXAgKahwExvK6SsmgU5/brh4w/nSgj8e0k3c1HBQpa0A== + +"@rollup/rollup-android-arm64@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.2.tgz#137e8153fc9ce6757531ce300b8d2262299f758e" + integrity sha512-oEouqQk2/zxxj22PNcGSskya+3kV0ZKH+nQxuCCOGJ4oTXBdNTbv+f/E3c74cNLeMO1S5wVWacSws10TTSB77g== + +"@rollup/rollup-darwin-arm64@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.2.tgz#d4afd904386d37192cf5ef7345fdb0dd1bac0bc3" + integrity sha512-OZuTVTpj3CDSIxmPgGH8en/XtirV5nfljHZ3wrNwvgkT5DQLhIKAeuFSiwtbMto6oVexV0k1F1zqURPKf5rI1Q== + +"@rollup/rollup-darwin-x64@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.2.tgz#6dbe83431fc7cbc09a2b6ed2b9fb7a62dd66ebc2" + integrity sha512-Wa/Wn8RFkIkr1vy1k1PB//VYhLnlnn5eaJkfTQKivirOvzu5uVd2It01ukeQstMursuz7S1bU+8WW+1UPXpa8A== + +"@rollup/rollup-freebsd-arm64@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.2.tgz#d35afb9f66154b557b3387d12450920f8a954b96" + integrity sha512-QkzxvH3kYN9J1w7D1A+yIMdI1pPekD+pWx7G5rXgnIlQ1TVYVC6hLl7SOV9pi5q9uIDF9AuIGkuzcbF7+fAhow== + +"@rollup/rollup-freebsd-x64@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.2.tgz#849303ecdc171a420317ad9166a70af308348f34" + integrity sha512-dkYXB0c2XAS3a3jmyDkX4Jk0m7gWLFzq1C3qUnJJ38AyxIF5G/dyS4N9B30nvFseCfgtCEdbYFhk0ChoCGxPog== + +"@rollup/rollup-linux-arm-gnueabihf@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.2.tgz#ab36199ca613376232794b2f3ba10e2b547a447c" + integrity sha512-9VlPY/BN3AgbukfVHAB8zNFWB/lKEuvzRo1NKev0Po8sYFKx0i+AQlCYftgEjcL43F2h9Ui1ZSdVBc4En/sP2w== + +"@rollup/rollup-linux-arm-musleabihf@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.2.tgz#f3704bc2eaecd176f558dc47af64197fcac36e8a" + integrity sha512-+GdKWOvsifaYNlIVf07QYan1J5F141+vGm5/Y8b9uCZnG/nxoGqgCmR24mv0koIWWuqvFYnbURRqw1lv7IBINw== + +"@rollup/rollup-linux-arm64-gnu@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.2.tgz#dda0b06fd1daedd00b34395a2fb4aaaa2ed6c32b" + integrity sha512-df0Eou14ojtUdLQdPFnymEQteENwSJAdLf5KCDrmZNsy1c3YaCNaJvYsEUHnrg+/DLBH612/R0xd3dD03uz2dg== + +"@rollup/rollup-linux-arm64-musl@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.2.tgz#a018de66209051dad0c58e689e080326c3dd15b0" + integrity sha512-iPeouV0UIDtz8j1YFR4OJ/zf7evjauqv7jQ/EFs0ClIyL+by++hiaDAfFipjOgyz6y6xbDvJuiU4HwpVMpRFDQ== + +"@rollup/rollup-linux-loong64-gnu@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.50.2.tgz#6e514f09988615e0c98fa5a34a88a30fec64d969" + integrity sha512-OL6KaNvBopLlj5fTa5D5bau4W82f+1TyTZRr2BdnfsrnQnmdxh4okMxR2DcDkJuh4KeoQZVuvHvzuD/lyLn2Kw== + +"@rollup/rollup-linux-ppc64-gnu@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.2.tgz#9b2efebc7b4a1951e684a895fdee0fef26319e0d" + integrity sha512-I21VJl1w6z/K5OTRl6aS9DDsqezEZ/yKpbqlvfHbW0CEF5IL8ATBMuUx6/mp683rKTK8thjs/0BaNrZLXetLag== + +"@rollup/rollup-linux-riscv64-gnu@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.2.tgz#a7104270e93d75789d1ba857b2c68ddf61f24f68" + integrity sha512-Hq6aQJT/qFFHrYMjS20nV+9SKrXL2lvFBENZoKfoTH2kKDOJqff5OSJr4x72ZaG/uUn+XmBnGhfr4lwMRrmqCQ== + +"@rollup/rollup-linux-riscv64-musl@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.2.tgz#42d153f734a7b9fcacd764cc9bee6c207dca4db6" + integrity sha512-82rBSEXRv5qtKyr0xZ/YMF531oj2AIpLZkeNYxmKNN6I2sVE9PGegN99tYDLK2fYHJITL1P2Lgb4ZXnv0PjQvw== + +"@rollup/rollup-linux-s390x-gnu@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.2.tgz#826ad73099f6fd57c083dc5329151b25404bc67d" + integrity sha512-4Q3S3Hy7pC6uaRo9gtXUTJ+EKo9AKs3BXKc2jYypEcMQ49gDPFU2P1ariX9SEtBzE5egIX6fSUmbmGazwBVF9w== + +"@rollup/rollup-linux-x64-gnu@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.2.tgz#b9ec17bf0ca3f737d0895fca2115756674342142" + integrity sha512-9Jie/At6qk70dNIcopcL4p+1UirusEtznpNtcq/u/C5cC4HBX7qSGsYIcG6bdxj15EYWhHiu02YvmdPzylIZlA== + +"@rollup/rollup-linux-x64-musl@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.2.tgz#29fe0adb45a1d99042f373685efbac9cdd5354d9" + integrity sha512-HPNJwxPL3EmhzeAnsWQCM3DcoqOz3/IC6de9rWfGR8ZCuEHETi9km66bH/wG3YH0V3nyzyFEGUZeL5PKyy4xvw== + +"@rollup/rollup-openharmony-arm64@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.2.tgz#29648f11e202736b74413f823b71e339e3068d60" + integrity sha512-nMKvq6FRHSzYfKLHZ+cChowlEkR2lj/V0jYj9JnGUVPL2/mIeFGmVM2mLaFeNa5Jev7W7TovXqXIG2d39y1KYA== + +"@rollup/rollup-win32-arm64-msvc@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.2.tgz#91e7edec80542fd81ab1c2581a91403ac63458ae" + integrity sha512-eFUvvnTYEKeTyHEijQKz81bLrUQOXKZqECeiWH6tb8eXXbZk+CXSG2aFrig2BQ/pjiVRj36zysjgILkqarS2YA== + +"@rollup/rollup-win32-ia32-msvc@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.2.tgz#9b7cd9779f1147a3e8d3ddad432ae64dd222c4e9" + integrity sha512-cBaWmXqyfRhH8zmUxK3d3sAhEWLrtMjWBRwdMMHJIXSjvjLKvv49adxiEz+FJ8AP90apSDDBx2Tyd/WylV6ikA== + +"@rollup/rollup-win32-x64-msvc@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.2.tgz#40ecd1357526fe328c7af704a283ee8533ca7ad6" + integrity sha512-APwKy6YUhvZaEoHyM+9xqmTpviEI+9eL7LoCH+aLcvWYHJ663qG5zx7WzWZY+a9qkg5JtzcMyJ9z0WtQBMDmgA== "@rtsao/scc@^1.1.0": version "1.1.0" @@ -859,11 +910,6 @@ resolved "https://registry.yarnpkg.com/@secretlint/types/-/types-10.2.1.tgz#018f252a3754a9ff2371b3e132226d281be8515b" integrity sha512-F5k1qpoMoUe7rrZossOBgJ3jWKv/FGDBZIwepqnefgPmNienBdInxhtZeXiGwjcxXHVhsdgp6I5Fi/M8PMgwcw== -"@sinclair/typebox@^0.27.8": - version "0.27.8" - resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" - integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== - "@sindresorhus/merge-streams@^2.1.0": version "2.3.0" resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz#719df7fb41766bc143369eaa0dd56d8dc87c9958" @@ -921,22 +967,17 @@ resolved "https://registry.yarnpkg.com/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz#db4ecfd499a9765ab24002c3b696d02e6d32a12c" integrity sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA== -"@types/chai-subset@^1.3.3": - version "1.3.3" - resolved "https://registry.yarnpkg.com/@types/chai-subset/-/chai-subset-1.3.3.tgz#97893814e92abd2c534de422cb377e0e0bdaac94" - integrity sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw== +"@types/chai@^5.2.2": + version "5.2.2" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.2.tgz#6f14cea18180ffc4416bc0fd12be05fdd73bdd6b" + integrity sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg== dependencies: - "@types/chai" "*" - -"@types/chai@*": - version "4.3.4" - resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.4.tgz#e913e8175db8307d78b4e8fa690408ba6b65dee4" - integrity sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw== + "@types/deep-eql" "*" -"@types/chai@^4.3.5": - version "4.3.6" - resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.6.tgz#7b489e8baf393d5dd1266fb203ddd4ea941259e6" - integrity sha512-VOVRLM1mBxIRxydiViqPcKn6MIxZytrbMpd6RJLIWKxUNr3zux8no0Oc7kJx0WAPIitgZ0gkrDS+btlqQpubpw== +"@types/deep-eql@*": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" + integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== "@types/eslint-scope@^3.7.7": version "3.7.7" @@ -954,11 +995,16 @@ "@types/estree" "*" "@types/json-schema" "*" -"@types/estree@*", "@types/estree@1.0.7", "@types/estree@^1.0.6": +"@types/estree@*", "@types/estree@^1.0.6": version "1.0.7" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8" integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ== +"@types/estree@1.0.8", "@types/estree@^1.0.0": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + "@types/eventsource@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/eventsource/-/eventsource-3.0.0.tgz#6b1b50c677032fd3be0b5c322e8ae819b3df62eb" @@ -1190,48 +1236,85 @@ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== -"@vitest/expect@0.34.6": - version "0.34.6" - resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-0.34.6.tgz#608a7b7a9aa3de0919db99b4cc087340a03ea77e" - integrity sha512-QUzKpUQRc1qC7qdGo7rMK3AkETI7w18gTCUrsNnyjjJKYiuUB9+TQK3QnR1unhCnWRC0AbKv2omLGQDF/mIjOw== - dependencies: - "@vitest/spy" "0.34.6" - "@vitest/utils" "0.34.6" - chai "^4.3.10" - -"@vitest/runner@0.34.6": - version "0.34.6" - resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-0.34.6.tgz#6f43ca241fc96b2edf230db58bcde5b974b8dcaf" - integrity sha512-1CUQgtJSLF47NnhN+F9X2ycxUP0kLHQ/JWvNHbeBfwW8CzEGgeskzNnHDyv1ieKTltuR6sdIHV+nmR6kPxQqzQ== - dependencies: - "@vitest/utils" "0.34.6" - p-limit "^4.0.0" - pathe "^1.1.1" - -"@vitest/snapshot@0.34.6": - version "0.34.6" - resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-0.34.6.tgz#b4528cf683b60a3e8071cacbcb97d18b9d5e1d8b" - integrity sha512-B3OZqYn6k4VaN011D+ve+AA4whM4QkcwcrwaKwAbyyvS/NB1hCWjFIBQxAQQSQir9/RtyAAGuq+4RJmbn2dH4w== +"@vitest/coverage-v8@^3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz#a2d8d040288c1956a1c7d0a0e2cdcfc7a3319f13" + integrity sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ== dependencies: - magic-string "^0.30.1" - pathe "^1.1.1" - pretty-format "^29.5.0" - -"@vitest/spy@0.34.6": - version "0.34.6" - resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-0.34.6.tgz#b5e8642a84aad12896c915bce9b3cc8cdaf821df" - integrity sha512-xaCvneSaeBw/cz8ySmF7ZwGvL0lBjfvqc1LpQ/vcdHEvpLn3Ff1vAvjw+CoGn0802l++5L/pxb7whwcWAw+DUQ== - dependencies: - tinyspy "^2.1.1" - -"@vitest/utils@0.34.6": - version "0.34.6" - resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-0.34.6.tgz#38a0a7eedddb8e7291af09a2409cb8a189516968" - integrity sha512-IG5aDD8S6zlvloDsnzHw0Ut5xczlF+kv2BOTo+iXfPr54Yhi5qbVOgGB1hZaVq4iJ4C/MZ2J0y15IlsV/ZcI0A== - dependencies: - diff-sequences "^29.4.3" - loupe "^2.3.6" - pretty-format "^29.5.0" + "@ampproject/remapping" "^2.3.0" + "@bcoe/v8-coverage" "^1.0.2" + ast-v8-to-istanbul "^0.3.3" + debug "^4.4.1" + istanbul-lib-coverage "^3.2.2" + istanbul-lib-report "^3.0.1" + istanbul-lib-source-maps "^5.0.6" + istanbul-reports "^3.1.7" + magic-string "^0.30.17" + magicast "^0.3.5" + std-env "^3.9.0" + test-exclude "^7.0.1" + tinyrainbow "^2.0.0" + +"@vitest/expect@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-3.2.4.tgz#8362124cd811a5ee11c5768207b9df53d34f2433" + integrity sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/spy" "3.2.4" + "@vitest/utils" "3.2.4" + chai "^5.2.0" + tinyrainbow "^2.0.0" + +"@vitest/mocker@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-3.2.4.tgz#4471c4efbd62db0d4fa203e65cc6b058a85cabd3" + integrity sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ== + dependencies: + "@vitest/spy" "3.2.4" + estree-walker "^3.0.3" + magic-string "^0.30.17" + +"@vitest/pretty-format@3.2.4", "@vitest/pretty-format@^3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-3.2.4.tgz#3c102f79e82b204a26c7a5921bf47d534919d3b4" + integrity sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA== + dependencies: + tinyrainbow "^2.0.0" + +"@vitest/runner@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-3.2.4.tgz#5ce0274f24a971f6500f6fc166d53d8382430766" + integrity sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ== + dependencies: + "@vitest/utils" "3.2.4" + pathe "^2.0.3" + strip-literal "^3.0.0" + +"@vitest/snapshot@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-3.2.4.tgz#40a8bc0346ac0aee923c0eefc2dc005d90bc987c" + integrity sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ== + dependencies: + "@vitest/pretty-format" "3.2.4" + magic-string "^0.30.17" + pathe "^2.0.3" + +"@vitest/spy@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-3.2.4.tgz#cc18f26f40f3f028da6620046881f4e4518c2599" + integrity sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw== + dependencies: + tinyspy "^4.0.3" + +"@vitest/utils@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-3.2.4.tgz#c0813bc42d99527fb8c5b138c7a88516bca46fea" + integrity sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA== + dependencies: + "@vitest/pretty-format" "3.2.4" + loupe "^3.1.4" + tinyrainbow "^2.0.0" "@vscode/test-cli@^0.0.11": version "0.0.11" @@ -1507,17 +1590,12 @@ acorn-jsx@^5.2.0, acorn-jsx@^5.3.2: resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn-walk@^8.2.0: - version "8.2.0" - resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" - integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== - acorn@^7.1.1: version "7.4.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -acorn@^8.10.0, acorn@^8.14.0, acorn@^8.8.2, acorn@^8.9.0: +acorn@^8.14.0, acorn@^8.8.2, acorn@^8.9.0: version "8.14.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.1.tgz#721d5dc10f7d5b5609a891773d47731796935dfb" integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== @@ -1631,11 +1709,6 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" -ansi-styles@^5.0.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" - integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== - ansi-styles@^6.1.0, ansi-styles@^6.2.1: version "6.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" @@ -1765,10 +1838,10 @@ arraybuffer.prototype.slice@^1.0.3: is-array-buffer "^3.0.4" is-shared-array-buffer "^1.0.2" -assertion-error@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" - integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== ast-types@^0.13.4: version "0.13.4" @@ -1777,6 +1850,15 @@ ast-types@^0.13.4: dependencies: tslib "^2.0.1" +ast-v8-to-istanbul@^0.3.3: + version "0.3.5" + resolved "https://registry.yarnpkg.com/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.5.tgz#9fba217c272dd8c2615603da5de3e1a460b4b9af" + integrity sha512-9SdXjNheSiE8bALAQCQQuT6fgQaoxJh7IRYrRGZ8/9nv8WhJeC1aXAwN8TbaOssGOukUvyvnkgD9+Yuykvl1aA== + dependencies: + "@jridgewell/trace-mapping" "^0.3.30" + estree-walker "^3.0.3" + js-tokens "^9.0.1" + astral-regex@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" @@ -2072,18 +2154,16 @@ ccount@^1.0.0: resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.1.0.tgz#246687debb6014735131be8abab2d93898f8d043" integrity sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg== -chai@^4.3.10: - version "4.3.10" - resolved "https://registry.yarnpkg.com/chai/-/chai-4.3.10.tgz#d784cec635e3b7e2ffb66446a63b4e33bd390384" - integrity sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g== +chai@^5.2.0: + version "5.3.3" + resolved "https://registry.yarnpkg.com/chai/-/chai-5.3.3.tgz#dd3da955e270916a4bd3f625f4b919996ada7e06" + integrity sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw== dependencies: - assertion-error "^1.1.0" - check-error "^1.0.3" - deep-eql "^4.1.3" - get-func-name "^2.0.2" - loupe "^2.3.6" - pathval "^1.1.1" - type-detect "^4.0.8" + assertion-error "^2.0.1" + check-error "^2.1.1" + deep-eql "^5.0.1" + loupe "^3.1.0" + pathval "^2.0.0" chainsaw@~0.1.0: version "0.1.0" @@ -2149,12 +2229,10 @@ chardet@^0.7.0: resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== -check-error@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.3.tgz#a6502e4312a7ee969f646e83bb3ddd56281bd694" - integrity sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg== - dependencies: - get-func-name "^2.0.2" +check-error@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc" + integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw== cheerio-select@^2.1.0: version "2.1.0" @@ -2498,12 +2576,10 @@ decompress-response@^6.0.0: dependencies: mimic-response "^3.1.0" -deep-eql@^4.1.3: - version "4.1.3" - resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-4.1.3.tgz#7c7775513092f7df98d8df9996dd085eb668cc6d" - integrity sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw== - dependencies: - type-detect "^4.0.0" +deep-eql@^5.0.1: + version "5.0.2" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" + integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== deep-extend@^0.6.0: version "0.6.0" @@ -2613,11 +2689,6 @@ detect-newline@4.0.1, detect-newline@^4.0.1: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-4.0.1.tgz#fcefdb5713e1fb8cb2839b8b6ee22e6716ab8f23" integrity sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog== -diff-sequences@^29.4.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" - integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== - diff@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/diff/-/diff-7.0.0.tgz#3fb34d387cd76d803f6eebea67b921dab0182a9a" @@ -2928,6 +2999,11 @@ es-module-lexer@^1.2.1: resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.3.1.tgz#c1b0dd5ada807a3b3155315911f364dc4e909db1" integrity sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q== +es-module-lexer@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" + integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== + es-object-atoms@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.0.0.tgz#ddb55cd47ac2e240701260bc2a8e31ecb643d941" @@ -2998,34 +3074,37 @@ es6-error@^4.0.1: resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d" integrity sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg== -esbuild@^0.21.3: - version "0.21.5" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" - integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== +esbuild@^0.25.0: + version "0.25.9" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.9.tgz#15ab8e39ae6cdc64c24ff8a2c0aef5b3fd9fa976" + integrity sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g== optionalDependencies: - "@esbuild/aix-ppc64" "0.21.5" - "@esbuild/android-arm" "0.21.5" - "@esbuild/android-arm64" "0.21.5" - "@esbuild/android-x64" "0.21.5" - "@esbuild/darwin-arm64" "0.21.5" - "@esbuild/darwin-x64" "0.21.5" - "@esbuild/freebsd-arm64" "0.21.5" - "@esbuild/freebsd-x64" "0.21.5" - "@esbuild/linux-arm" "0.21.5" - "@esbuild/linux-arm64" "0.21.5" - "@esbuild/linux-ia32" "0.21.5" - "@esbuild/linux-loong64" "0.21.5" - "@esbuild/linux-mips64el" "0.21.5" - "@esbuild/linux-ppc64" "0.21.5" - "@esbuild/linux-riscv64" "0.21.5" - "@esbuild/linux-s390x" "0.21.5" - "@esbuild/linux-x64" "0.21.5" - "@esbuild/netbsd-x64" "0.21.5" - "@esbuild/openbsd-x64" "0.21.5" - "@esbuild/sunos-x64" "0.21.5" - "@esbuild/win32-arm64" "0.21.5" - "@esbuild/win32-ia32" "0.21.5" - "@esbuild/win32-x64" "0.21.5" + "@esbuild/aix-ppc64" "0.25.9" + "@esbuild/android-arm" "0.25.9" + "@esbuild/android-arm64" "0.25.9" + "@esbuild/android-x64" "0.25.9" + "@esbuild/darwin-arm64" "0.25.9" + "@esbuild/darwin-x64" "0.25.9" + "@esbuild/freebsd-arm64" "0.25.9" + "@esbuild/freebsd-x64" "0.25.9" + "@esbuild/linux-arm" "0.25.9" + "@esbuild/linux-arm64" "0.25.9" + "@esbuild/linux-ia32" "0.25.9" + "@esbuild/linux-loong64" "0.25.9" + "@esbuild/linux-mips64el" "0.25.9" + "@esbuild/linux-ppc64" "0.25.9" + "@esbuild/linux-riscv64" "0.25.9" + "@esbuild/linux-s390x" "0.25.9" + "@esbuild/linux-x64" "0.25.9" + "@esbuild/netbsd-arm64" "0.25.9" + "@esbuild/netbsd-x64" "0.25.9" + "@esbuild/openbsd-arm64" "0.25.9" + "@esbuild/openbsd-x64" "0.25.9" + "@esbuild/openharmony-arm64" "0.25.9" + "@esbuild/sunos-x64" "0.25.9" + "@esbuild/win32-arm64" "0.25.9" + "@esbuild/win32-ia32" "0.25.9" + "@esbuild/win32-x64" "0.25.9" escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" @@ -3308,6 +3387,13 @@ estraverse@^5.1.0, estraverse@^5.2.0: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + esutils@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" @@ -3335,6 +3421,11 @@ expand-template@^2.0.3: resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== +expect-type@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.2.2.tgz#c030a329fb61184126c8447585bc75a7ec6fbff3" + integrity sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA== + extend@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" @@ -3427,6 +3518,11 @@ fdir@^6.4.4: resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.4.6.tgz#2b268c0232697063111bbf3f64810a2a741ba281" integrity sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w== +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + figures@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" @@ -3670,11 +3766,6 @@ get-east-asian-width@^1.0.0: resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz#21b4071ee58ed04ee0db653371b55b4299875389" integrity sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ== -get-func-name@^2.0.0, get-func-name@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.2.tgz#0d7cf20cd13fda808669ffa88f4ffc7a3943fc41" - integrity sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ== - get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3: version "1.2.0" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.0.tgz#7ad1dc0535f3a2904bba075772763e5051f6d05f" @@ -3801,7 +3892,7 @@ glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -glob@^10.3.10, glob@^10.4.2, glob@^10.4.5: +glob@^10.3.10, glob@^10.4.1, glob@^10.4.2, glob@^10.4.5: version "10.4.5" resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== @@ -4539,6 +4630,11 @@ istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz#189e7909d0a39fa5a3dfad5b03f71947770191d3" integrity sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw== +istanbul-lib-coverage@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" + integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== + istanbul-lib-hook@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz#8f84c9434888cc6b1d0a9d7092a76d239ebf0cc6" @@ -4596,6 +4692,15 @@ istanbul-lib-source-maps@^4.0.0: istanbul-lib-coverage "^3.0.0" source-map "^0.6.1" +istanbul-lib-source-maps@^5.0.6: + version "5.0.6" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz#acaef948df7747c8eb5fbf1265cb980f6353a441" + integrity sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A== + dependencies: + "@jridgewell/trace-mapping" "^0.3.23" + debug "^4.1.1" + istanbul-lib-coverage "^3.0.0" + istanbul-reports@^3.0.2: version "3.1.5" resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.5.tgz#cc9a6ab25cb25659810e4785ed9d9fb742578bae" @@ -4612,6 +4717,14 @@ istanbul-reports@^3.1.6: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" +istanbul-reports@^3.1.7: + version "3.2.0" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz#cb4535162b5784aa623cee21a7252cf2c807ac93" + integrity sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + istextorbinary@^9.5.0: version "9.5.0" resolved "https://registry.yarnpkg.com/istextorbinary/-/istextorbinary-9.5.0.tgz#e6e13febf1c1685100ae264809a4f8f46e01dfd3" @@ -4651,6 +4764,11 @@ js-tokens@^4.0.0: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== +js-tokens@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-9.0.1.tgz#2ec43964658435296f6761b34e10671c2d9527f4" + integrity sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ== + js-yaml@^3.13.1, js-yaml@^3.14.1: version "3.14.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" @@ -4833,11 +4951,6 @@ loader-runner@^4.2.0: resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1" integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== -local-pkg@^0.4.3: - version "0.4.3" - resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.4.3.tgz#0ff361ab3ae7f1c19113d9bb97b98b905dbc4963" - integrity sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g== - locate-path@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" @@ -4933,12 +5046,10 @@ longest-streak@^2.0.1: resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-2.0.4.tgz#b8599957da5b5dab64dee3fe316fa774597d90e4" integrity sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg== -loupe@^2.3.6: - version "2.3.6" - resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.6.tgz#76e4af498103c532d1ecc9be102036a21f787b53" - integrity sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA== - dependencies: - get-func-name "^2.0.0" +loupe@^3.1.0, loupe@^3.1.4: + version "3.2.1" + resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.2.1.tgz#0095cf56dc5b7a9a7c08ff5b1a8796ec8ad17e76" + integrity sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ== lru-cache@^10.0.1: version "10.4.3" @@ -4974,12 +5085,21 @@ lru-cache@^7.14.1: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== -magic-string@^0.30.1: - version "0.30.4" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.4.tgz#c2c683265fc18dda49b56fc7318d33ca0332c98c" - integrity sha512-Q/TKtsC5BPm0kGqgBIF9oXAs/xEf2vRKiIB4wCRQTJOQIByZ1d+NnUOotvJOvNpi5RNIgVOMC3pOuaP1ZTDlVg== +magic-string@^0.30.17: + version "0.30.19" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.19.tgz#cebe9f104e565602e5d2098c5f2e79a77cc86da9" + integrity sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + +magicast@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/magicast/-/magicast-0.3.5.tgz#8301c3c7d66704a0771eb1bad74274f0ec036739" + integrity sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ== dependencies: - "@jridgewell/sourcemap-codec" "^1.4.15" + "@babel/parser" "^7.25.4" + "@babel/types" "^7.25.4" + source-map-js "^1.2.0" make-dir@^3.0.0, make-dir@^3.0.2: version "3.1.0" @@ -5166,16 +5286,6 @@ mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: dependencies: minimist "^1.2.6" -mlly@^1.2.0, mlly@^1.4.0: - version "1.4.2" - resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.4.2.tgz#7cf406aa319ff6563d25da6b36610a93f2a8007e" - integrity sha512-i/Ykufi2t1EZ6NaPLdfnZk2AX8cs0d+mTzVKuPfqPKPatxLApaBoxJQ9x1/uckXtrS/U5oisPMDkNs0yQTaBRg== - dependencies: - acorn "^8.10.0" - pathe "^1.1.1" - pkg-types "^1.0.3" - ufo "^1.3.0" - mocha@^11.1.0: version "11.7.2" resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.2.tgz#3c0079fe5cc2f8ea86d99124debcc42bb1ab22b5" @@ -5212,7 +5322,7 @@ mute-stream@0.0.8, mute-stream@~0.0.4: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== -nanoid@^3.3.8: +nanoid@^3.3.11: version "3.3.11" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== @@ -5495,13 +5605,6 @@ p-limit@^3.0.2: dependencies: yocto-queue "^0.1.0" -p-limit@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-4.0.0.tgz#914af6544ed32bfa54670b061cafcbd04984b644" - integrity sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ== - dependencies: - yocto-queue "^1.0.0" - p-locate@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" @@ -5683,20 +5786,15 @@ path-type@^6.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-6.0.0.tgz#2f1bb6791a91ce99194caede5d6c5920ed81eb51" integrity sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ== -pathe@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.0.tgz#e2e13f6c62b31a3289af4ba19886c230f295ec03" - integrity sha512-ODbEPR0KKHqECXW1GoxdDb+AZvULmXjVPy4rt+pGo2+TnjJTIPJQSVS6N63n8T2Ip+syHhbn52OewKicV0373w== - -pathe@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.1.tgz#1dd31d382b974ba69809adc9a7a347e65d84829a" - integrity sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q== +pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== -pathval@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" - integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== +pathval@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.1.tgz#8855c5a2899af072d6ac05d11e46045ad0dc605d" + integrity sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ== pend@~1.2.0: version "1.2.0" @@ -5723,6 +5821,11 @@ picomatch@^4.0.2: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab" integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== +picomatch@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" + integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== + pkg-dir@^4.1.0, pkg-dir@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" @@ -5730,15 +5833,6 @@ pkg-dir@^4.1.0, pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -pkg-types@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.0.3.tgz#988b42ab19254c01614d13f4f65a2cfc7880f868" - integrity sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A== - dependencies: - jsonc-parser "^3.2.0" - mlly "^1.2.0" - pathe "^1.1.0" - plur@^3.0.0: version "3.1.1" resolved "https://registry.yarnpkg.com/plur/-/plur-3.1.1.tgz#60267967866a8d811504fe58f2faaba237546a5b" @@ -5761,12 +5855,12 @@ possible-typed-array-names@^1.0.0: resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz#89bb63c6fada2c3e90adc4a647beeeb39cc7bf8f" integrity sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q== -postcss@^8.4.43: - version "8.5.3" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.3.tgz#1463b6f1c7fb16fe258736cba29a2de35237eafb" - integrity sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A== +postcss@^8.5.6: + version "8.5.6" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" + integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== dependencies: - nanoid "^3.3.8" + nanoid "^3.3.11" picocolors "^1.1.1" source-map-js "^1.2.1" @@ -5815,15 +5909,6 @@ pretty-bytes@^7.0.0: resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-7.0.0.tgz#8652cbf0aa81daeeaf72802e0fd059e5e1046cdb" integrity sha512-U5otLYPR3L0SVjHGrkEUx5mf7MxV2ceXeE7VwWPk+hyzC5drNohsOGNPDZqxCqyX1lkbEN4kl1LiI8QFd7r0ZA== -pretty-format@^29.5.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" - integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== - dependencies: - "@jest/schemas" "^29.6.3" - ansi-styles "^5.0.0" - react-is "^18.0.0" - process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" @@ -5917,11 +6002,6 @@ rc@^1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-is@^18.0.0: - version "18.2.0" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" - integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== - read-pkg@^9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-9.0.1.tgz#b1b81fb15104f5dbb121b6bbdee9bbc9739f569b" @@ -6668,33 +6748,34 @@ rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" -rollup@^4.20.0: - version "4.39.0" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.39.0.tgz#9dc1013b70c0e2cb70ef28350142e9b81b3f640c" - integrity sha512-thI8kNc02yNvnmJp8dr3fNWJ9tCONDhp6TV35X6HkKGGs9E6q7YWCHbe5vKiTa7TAiNcFEmXKj3X/pG2b3ci0g== +rollup@^4.43.0: + version "4.50.2" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.50.2.tgz#938d898394939f3386d1e367ee6410a796b8f268" + integrity sha512-BgLRGy7tNS9H66aIMASq1qSYbAAJV6Z6WR4QYTvj5FgF15rZ/ympT1uixHXwzbZUBDbkvqUI1KR0fH1FhMaQ9w== dependencies: - "@types/estree" "1.0.7" + "@types/estree" "1.0.8" optionalDependencies: - "@rollup/rollup-android-arm-eabi" "4.39.0" - "@rollup/rollup-android-arm64" "4.39.0" - "@rollup/rollup-darwin-arm64" "4.39.0" - "@rollup/rollup-darwin-x64" "4.39.0" - "@rollup/rollup-freebsd-arm64" "4.39.0" - "@rollup/rollup-freebsd-x64" "4.39.0" - "@rollup/rollup-linux-arm-gnueabihf" "4.39.0" - "@rollup/rollup-linux-arm-musleabihf" "4.39.0" - "@rollup/rollup-linux-arm64-gnu" "4.39.0" - "@rollup/rollup-linux-arm64-musl" "4.39.0" - "@rollup/rollup-linux-loongarch64-gnu" "4.39.0" - "@rollup/rollup-linux-powerpc64le-gnu" "4.39.0" - "@rollup/rollup-linux-riscv64-gnu" "4.39.0" - "@rollup/rollup-linux-riscv64-musl" "4.39.0" - "@rollup/rollup-linux-s390x-gnu" "4.39.0" - "@rollup/rollup-linux-x64-gnu" "4.39.0" - "@rollup/rollup-linux-x64-musl" "4.39.0" - "@rollup/rollup-win32-arm64-msvc" "4.39.0" - "@rollup/rollup-win32-ia32-msvc" "4.39.0" - "@rollup/rollup-win32-x64-msvc" "4.39.0" + "@rollup/rollup-android-arm-eabi" "4.50.2" + "@rollup/rollup-android-arm64" "4.50.2" + "@rollup/rollup-darwin-arm64" "4.50.2" + "@rollup/rollup-darwin-x64" "4.50.2" + "@rollup/rollup-freebsd-arm64" "4.50.2" + "@rollup/rollup-freebsd-x64" "4.50.2" + "@rollup/rollup-linux-arm-gnueabihf" "4.50.2" + "@rollup/rollup-linux-arm-musleabihf" "4.50.2" + "@rollup/rollup-linux-arm64-gnu" "4.50.2" + "@rollup/rollup-linux-arm64-musl" "4.50.2" + "@rollup/rollup-linux-loong64-gnu" "4.50.2" + "@rollup/rollup-linux-ppc64-gnu" "4.50.2" + "@rollup/rollup-linux-riscv64-gnu" "4.50.2" + "@rollup/rollup-linux-riscv64-musl" "4.50.2" + "@rollup/rollup-linux-s390x-gnu" "4.50.2" + "@rollup/rollup-linux-x64-gnu" "4.50.2" + "@rollup/rollup-linux-x64-musl" "4.50.2" + "@rollup/rollup-openharmony-arm64" "4.50.2" + "@rollup/rollup-win32-arm64-msvc" "4.50.2" + "@rollup/rollup-win32-ia32-msvc" "4.50.2" + "@rollup/rollup-win32-x64-msvc" "4.50.2" fsevents "~2.3.2" run-applescript@^7.0.0: @@ -7008,7 +7089,7 @@ sort-package-json@^3.0.0: sort-object-keys "^1.1.3" tinyglobby "^0.2.12" -source-map-js@^1.2.1: +source-map-js@^1.2.0, source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== @@ -7089,10 +7170,10 @@ state-toggle@^1.0.0: resolved "https://registry.yarnpkg.com/state-toggle/-/state-toggle-1.0.3.tgz#e123b16a88e143139b09c6852221bc9815917dfe" integrity sha512-d/5Z4/2iiCnHw6Xzghyhb+GcmF89bxwgXG60wjIiZaxnymbyOmI8Hk4VqHXiVVp6u2ysaskFfXg3ekCj4WNftQ== -std-env@^3.3.3: - version "3.4.3" - resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.4.3.tgz#326f11db518db751c83fd58574f449b7c3060910" - integrity sha512-f9aPhy8fYBuMN+sNfakZV18U39PbalgjXG3lLB9WkaYTxijru61wb57V9wxxNthXM5Sd88ETBWi29qLAsHO52Q== +std-env@^3.9.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.9.0.tgz#1a6f7243b339dca4c9fd55e1c7504c77ef23e8f1" + integrity sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw== stdin-discarder@^0.2.2: version "0.2.2" @@ -7263,12 +7344,12 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== -strip-literal@^1.0.1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-1.3.0.tgz#db3942c2ec1699e6836ad230090b84bb458e3a07" - integrity sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg== +strip-literal@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-3.0.0.tgz#ce9c452a91a0af2876ed1ae4e583539a353df3fc" + integrity sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA== dependencies: - acorn "^8.10.0" + js-tokens "^9.0.1" structured-source@^4.0.0: version "4.0.0" @@ -7408,6 +7489,15 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" +test-exclude@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-7.0.1.tgz#20b3ba4906ac20994e275bbcafd68d510264c2a2" + integrity sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg== + dependencies: + "@istanbuljs/schema" "^0.1.2" + glob "^10.4.1" + minimatch "^9.0.4" + text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" @@ -7430,10 +7520,15 @@ through@^2.3.6: resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== -tinybench@^2.5.0: - version "2.5.1" - resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.5.1.tgz#3408f6552125e53a5a48adee31261686fd71587e" - integrity sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg== +tinybench@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== + +tinyexec@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2" + integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== tinyglobby@^0.2.12: version "0.2.14" @@ -7443,15 +7538,28 @@ tinyglobby@^0.2.12: fdir "^6.4.4" picomatch "^4.0.2" -tinypool@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.7.0.tgz#88053cc99b4a594382af23190c609d93fddf8021" - integrity sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww== +tinyglobby@^0.2.14, tinyglobby@^0.2.15: + version "0.2.15" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" + integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.3" -tinyspy@^2.1.1: - version "2.2.0" - resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-2.2.0.tgz#9dc04b072746520b432f77ea2c2d17933de5d6ce" - integrity sha512-d2eda04AN/cPOR89F7Xv5bK/jrQEhmcLFe6HFldoeO9AJtps+fqEnh486vnT/8y4bw38pSyxDcTCAq+Ks2aJTg== +tinypool@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.1.1.tgz#059f2d042bd37567fbc017d3d426bdd2a2612591" + integrity sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg== + +tinyrainbow@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz#9509b2162436315e80e3eee0fcce4474d2444294" + integrity sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw== + +tinyspy@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-4.0.3.tgz#d1d0f0602f4c15f1aae083a34d6d0df3363b1b52" + integrity sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A== tmp@^0.0.33: version "0.0.33" @@ -7559,11 +7667,6 @@ type-check@~0.3.2: dependencies: prelude-ls "~1.1.2" -type-detect@^4.0.0, type-detect@^4.0.8: - version "4.0.8" - resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" - integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== - type-fest@^0.20.2: version "0.20.2" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" @@ -7698,11 +7801,6 @@ uc.micro@^2.0.0, uc.micro@^2.1.0: resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee" integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A== -ufo@^1.3.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.3.1.tgz#e085842f4627c41d4c1b60ebea1f75cdab4ce86b" - integrity sha512-uY/99gMLIOlJPwATcMVYfqDSxUR9//AUcgZMzwfSTJPDKzA1S8mX4VLqa+fiAtveraQUBCz4FFcwVZBGbwBXIw== - unbox-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" @@ -7924,58 +8022,59 @@ vfile@^4.0.0: unist-util-stringify-position "^2.0.0" vfile-message "^2.0.0" -vite-node@0.34.6: - version "0.34.6" - resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-0.34.6.tgz#34d19795de1498562bf21541a58edcd106328a17" - integrity sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA== +vite-node@3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.2.4.tgz#f3676d94c4af1e76898c162c92728bca65f7bb07" + integrity sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg== dependencies: cac "^6.7.14" - debug "^4.3.4" - mlly "^1.4.0" - pathe "^1.1.1" - picocolors "^1.0.0" - vite "^3.0.0 || ^4.0.0 || ^5.0.0-0" - -"vite@^3.0.0 || ^4.0.0 || ^5.0.0-0", "vite@^3.1.0 || ^4.0.0 || ^5.0.0-0": - version "5.4.19" - resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.19.tgz#20efd060410044b3ed555049418a5e7d1998f959" - integrity sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA== - dependencies: - esbuild "^0.21.3" - postcss "^8.4.43" - rollup "^4.20.0" + debug "^4.4.1" + es-module-lexer "^1.7.0" + pathe "^2.0.3" + vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" + +"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0": + version "7.1.5" + resolved "https://registry.yarnpkg.com/vite/-/vite-7.1.5.tgz#4dbcb48c6313116689be540466fc80faa377be38" + integrity sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ== + dependencies: + esbuild "^0.25.0" + fdir "^6.5.0" + picomatch "^4.0.3" + postcss "^8.5.6" + rollup "^4.43.0" + tinyglobby "^0.2.15" optionalDependencies: fsevents "~2.3.3" -vitest@^0.34.6: - version "0.34.6" - resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.34.6.tgz#44880feeeef493c04b7f795ed268f24a543250d7" - integrity sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q== - dependencies: - "@types/chai" "^4.3.5" - "@types/chai-subset" "^1.3.3" - "@types/node" "*" - "@vitest/expect" "0.34.6" - "@vitest/runner" "0.34.6" - "@vitest/snapshot" "0.34.6" - "@vitest/spy" "0.34.6" - "@vitest/utils" "0.34.6" - acorn "^8.9.0" - acorn-walk "^8.2.0" - cac "^6.7.14" - chai "^4.3.10" - debug "^4.3.4" - local-pkg "^0.4.3" - magic-string "^0.30.1" - pathe "^1.1.1" - picocolors "^1.0.0" - std-env "^3.3.3" - strip-literal "^1.0.1" - tinybench "^2.5.0" - tinypool "^0.7.0" - vite "^3.1.0 || ^4.0.0 || ^5.0.0-0" - vite-node "0.34.6" - why-is-node-running "^2.2.2" +vitest@^3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-3.2.4.tgz#0637b903ad79d1539a25bc34c0ed54b5c67702ea" + integrity sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/expect" "3.2.4" + "@vitest/mocker" "3.2.4" + "@vitest/pretty-format" "^3.2.4" + "@vitest/runner" "3.2.4" + "@vitest/snapshot" "3.2.4" + "@vitest/spy" "3.2.4" + "@vitest/utils" "3.2.4" + chai "^5.2.0" + debug "^4.4.1" + expect-type "^1.2.1" + magic-string "^0.30.17" + pathe "^2.0.3" + picomatch "^4.0.2" + std-env "^3.9.0" + tinybench "^2.9.0" + tinyexec "^0.3.2" + tinyglobby "^0.2.14" + tinypool "^1.1.1" + tinyrainbow "^2.0.0" + vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" + vite-node "3.2.4" + why-is-node-running "^2.3.0" vscode-test@^1.5.0: version "1.6.1" @@ -8119,10 +8218,10 @@ which@^2.0.1: dependencies: isexe "^2.0.0" -why-is-node-running@^2.2.2: - version "2.2.2" - resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.2.2.tgz#4185b2b4699117819e7154594271e7e344c9973e" - integrity sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA== +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== dependencies: siginfo "^2.0.0" stackback "0.0.2" @@ -8357,11 +8456,6 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -yocto-queue@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" - integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== - zod@^3.25.65: version "3.25.65" resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.65.tgz#190cb604e1b45e0f789a315f65463953d4d4beee" From 143297909423fb25cf13d49c27701827374ff343 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 17 Sep 2025 12:57:29 +0300 Subject: [PATCH 07/12] Rebase fallout --- src/core/binaryManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/binaryManager.ts b/src/core/binaryManager.ts index e851d8a7..5b7df04a 100644 --- a/src/core/binaryManager.ts +++ b/src/core/binaryManager.ts @@ -10,7 +10,7 @@ import path from "path"; import prettyBytes from "pretty-bytes"; import * as semver from "semver"; -import { errToStr } from "../api-helper"; +import { errToStr } from "../api/api-helper"; import * as cli from "../cliManager"; import { Logger } from "../logging/logger"; import * as pgp from "../pgp"; From 3c3cb5e3b120f72984cb834051b68c852a6f5f94 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 18 Sep 2025 14:48:00 +0300 Subject: [PATCH 08/12] Mock VS Code fully instead of DI --- src/__mocks__/testHelpers.ts | 223 +++++++ src/__mocks__/vscode.runtime.ts | 142 +++++ src/core/binaryManager.adapters.ts | 71 --- src/core/binaryManager.interfaces.ts | 68 --- src/core/binaryManager.test.ts | 836 ++++++++++++++------------- src/core/binaryManager.ts | 50 +- src/core/pathResolver.ts | 9 +- src/extension.ts | 16 +- vitest.config.ts | 6 + 9 files changed, 838 insertions(+), 583 deletions(-) create mode 100644 src/__mocks__/testHelpers.ts create mode 100644 src/__mocks__/vscode.runtime.ts delete mode 100644 src/core/binaryManager.adapters.ts delete mode 100644 src/core/binaryManager.interfaces.ts diff --git a/src/__mocks__/testHelpers.ts b/src/__mocks__/testHelpers.ts new file mode 100644 index 00000000..1574a369 --- /dev/null +++ b/src/__mocks__/testHelpers.ts @@ -0,0 +1,223 @@ +import { vi } from "vitest"; +import * as vscode from "vscode"; + +/** + * Mock configuration provider that integrates with the vscode workspace configuration mock. + * Use this to set configuration values that will be returned by vscode.workspace.getConfiguration(). + */ +export class MockConfigurationProvider { + private config = new Map(); + + /** + * Set a configuration value that will be returned by vscode.workspace.getConfiguration().get() + */ + set(key: string, value: unknown): void { + this.config.set(key, value); + this.setupVSCodeMock(); + } + + /** + * Get a configuration value (for testing purposes) + */ + get(key: string): T | undefined; + get(key: string, defaultValue: T): T; + get(key: string, defaultValue?: T): T | undefined { + const value = this.config.get(key); + return value !== undefined ? (value as T) : defaultValue; + } + + /** + * Clear all configuration values + */ + clear(): void { + this.config.clear(); + this.setupVSCodeMock(); + } + + /** + * Setup the vscode.workspace.getConfiguration mock to return our values + */ + setupVSCodeMock(): void { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key: string, defaultValue?: unknown) => { + const value = this.config.get(key); + return value !== undefined ? value : defaultValue; + }), + has: vi.fn((key: string) => this.config.has(key)), + inspect: vi.fn(), + update: vi.fn(), + } as unknown as vscode.WorkspaceConfiguration); + } +} + +/** + * Mock progress reporter that integrates with vscode.window.withProgress. + * Use this to control progress reporting behavior and cancellation in tests. + */ +export class MockProgressReporter { + private shouldCancel = false; + private progressReports: Array<{ message?: string; increment?: number }> = []; + + /** + * Set whether the progress should be cancelled + */ + setCancellation(cancel: boolean): void { + this.shouldCancel = cancel; + } + + /** + * Get all progress reports that were made + */ + getProgressReports(): Array<{ message?: string; increment?: number }> { + return [...this.progressReports]; + } + + /** + * Clear all progress reports + */ + clearProgressReports(): void { + this.progressReports = []; + } + + /** + * Setup the vscode.window.withProgress mock + */ + setupVSCodeMock(): void { + vi.mocked(vscode.window.withProgress).mockImplementation( + async ( + _options: vscode.ProgressOptions, + task: ( + progress: vscode.Progress<{ message?: string; increment?: number }>, + token: vscode.CancellationToken, + ) => Thenable, + ): Promise => { + const progress = { + report: vi.fn((value: { message?: string; increment?: number }) => { + this.progressReports.push(value); + }), + }; + + const cancellationToken: vscode.CancellationToken = { + isCancellationRequested: this.shouldCancel, + onCancellationRequested: vi.fn((listener: (x: unknown) => void) => { + if (this.shouldCancel) { + setTimeout(listener, 0); + } + return { dispose: vi.fn() }; + }), + }; + + return task(progress, cancellationToken); + }, + ); + } +} + +/** + * Mock user interaction that integrates with vscode.window message dialogs. + * Use this to control user responses in tests. + */ +export class MockUserInteraction { + private responses = new Map(); + private externalUrls: string[] = []; + + /** + * Set a response for a specific message or set a default response + */ + setResponse(response: string | undefined): void; + setResponse(message: string, response: string | undefined): void; + setResponse( + messageOrResponse: string | undefined, + response?: string | undefined, + ): void { + if (response === undefined && messageOrResponse !== undefined) { + // Single argument - set default response + this.responses.set("default", messageOrResponse); + } else if (messageOrResponse !== undefined) { + // Two arguments - set specific response + this.responses.set(messageOrResponse, response); + } + } + + /** + * Get all URLs that were opened externally + */ + getExternalUrls(): string[] { + return [...this.externalUrls]; + } + + /** + * Clear all external URLs + */ + clearExternalUrls(): void { + this.externalUrls = []; + } + + /** + * Clear all responses + */ + clearResponses(): void { + this.responses.clear(); + } + + /** + * Setup the vscode.window message dialog mocks + */ + setupVSCodeMock(): void { + const getResponse = (message: string): string | undefined => { + return this.responses.get(message) ?? this.responses.get("default"); + }; + + vi.mocked(vscode.window.showErrorMessage).mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (message: string): Thenable => { + const response = getResponse(message); + return Promise.resolve(response); + }, + ); + + vi.mocked(vscode.window.showWarningMessage).mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (message: string): Thenable => { + const response = getResponse(message); + return Promise.resolve(response); + }, + ); + + vi.mocked(vscode.window.showInformationMessage).mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (message: string): Thenable => { + const response = getResponse(message); + return Promise.resolve(response); + }, + ); + + vi.mocked(vscode.env.openExternal).mockImplementation( + (target: vscode.Uri): Promise => { + this.externalUrls.push(target.toString()); + return Promise.resolve(true); + }, + ); + } +} + +/** + * Helper function to setup all VS Code mocks for testing. + * Call this in your test setup to initialize all the mock integrations. + */ +export function setupVSCodeMocks(): { + mockConfig: MockConfigurationProvider; + mockProgress: MockProgressReporter; + mockUI: MockUserInteraction; +} { + const mockConfig = new MockConfigurationProvider(); + const mockProgress = new MockProgressReporter(); + const mockUI = new MockUserInteraction(); + + // Setup all the VS Code API mocks + mockConfig.setupVSCodeMock(); + mockProgress.setupVSCodeMock(); + mockUI.setupVSCodeMock(); + + return { mockConfig, mockProgress, mockUI }; +} diff --git a/src/__mocks__/vscode.runtime.ts b/src/__mocks__/vscode.runtime.ts new file mode 100644 index 00000000..2201a851 --- /dev/null +++ b/src/__mocks__/vscode.runtime.ts @@ -0,0 +1,142 @@ +import { vi } from "vitest"; + +// enum-like helpers +const E = >(o: T) => Object.freeze(o); + +export const ProgressLocation = E({ + SourceControl: 1, + Window: 10, + Notification: 15, +}); +export const ViewColumn = E({ + Active: -1, + Beside: -2, + One: 1, + Two: 2, + Three: 3, +}); +export const ConfigurationTarget = E({ + Global: 1, + Workspace: 2, + WorkspaceFolder: 3, +}); +export const TreeItemCollapsibleState = E({ + None: 0, + Collapsed: 1, + Expanded: 2, +}); +export const StatusBarAlignment = E({ Left: 1, Right: 2 }); +export const ExtensionMode = E({ Production: 1, Development: 2, Test: 3 }); +export const UIKind = E({ Desktop: 1, Web: 2 }); + +export class Uri { + constructor( + public scheme: string, + public path: string, + ) {} + static file(p: string) { + return new Uri("file", p); + } + static parse(v: string) { + if (v.startsWith("file://")) { + return Uri.file(v.slice("file://".length)); + } + const [scheme, ...rest] = v.split(":"); + return new Uri(scheme, rest.join(":")); + } + toString() { + return this.scheme === "file" + ? `file://${this.path}` + : `${this.scheme}:${this.path}`; + } + static joinPath(base: Uri, ...paths: string[]) { + const sep = base.path.endsWith("/") ? "" : "/"; + return new Uri(base.scheme, base.path + sep + paths.join("/")); + } +} + +// mini event +const makeEvent = () => { + const listeners = new Set<(e: T) => void>(); + const event = (listener: (e: T) => void) => { + listeners.add(listener); + return { dispose: () => listeners.delete(listener) }; + }; + return { event, fire: (e: T) => listeners.forEach((l) => l(e)) }; +}; + +const onDidChangeConfiguration = makeEvent(); +const onDidChangeWorkspaceFolders = makeEvent(); + +export const window = { + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + showErrorMessage: vi.fn(), + showQuickPick: vi.fn(), + showInputBox: vi.fn(), + withProgress: vi.fn(), + createOutputChannel: vi.fn(() => ({ + appendLine: vi.fn(), + append: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn(), + clear: vi.fn(), + })), +}; + +export const commands = { + registerCommand: vi.fn(), + executeCommand: vi.fn(), +}; + +export const workspace = { + getConfiguration: vi.fn(), // your helpers override this + workspaceFolders: [] as unknown[], + fs: { + readFile: vi.fn(), + writeFile: vi.fn(), + stat: vi.fn(), + readDirectory: vi.fn(), + }, + onDidChangeConfiguration: onDidChangeConfiguration.event, + onDidChangeWorkspaceFolders: onDidChangeWorkspaceFolders.event, + + // test-only triggers: + __fireDidChangeConfiguration: onDidChangeConfiguration.fire, + __fireDidChangeWorkspaceFolders: onDidChangeWorkspaceFolders.fire, +}; + +export const env = { + appName: "Visual Studio Code", + appRoot: "/app", + language: "en", + machineId: "test-machine-id", + sessionId: "test-session-id", + remoteName: undefined as string | undefined, + shell: "/bin/bash", + openExternal: vi.fn(), +}; + +export const extensions = { + getExtension: vi.fn(), + all: [] as unknown[], +}; + +const vscode = { + ProgressLocation, + ViewColumn, + ConfigurationTarget, + TreeItemCollapsibleState, + StatusBarAlignment, + ExtensionMode, + UIKind, + Uri, + window, + commands, + workspace, + env, + extensions, +}; + +export default vscode; diff --git a/src/core/binaryManager.adapters.ts b/src/core/binaryManager.adapters.ts deleted file mode 100644 index 7a8f8ec1..00000000 --- a/src/core/binaryManager.adapters.ts +++ /dev/null @@ -1,71 +0,0 @@ -import * as vscode from "vscode"; -import { - ConfigurationProvider, - ProgressReporter, - UserInteraction, -} from "./binaryManager.interfaces"; - -/** - * VS Code implementation of ConfigurationProvider - */ -export class VSCodeConfigurationProvider implements ConfigurationProvider { - get(key: string): T | undefined; - get(key: string, defaultValue: T): T; - get(key: string, defaultValue?: T): T | undefined { - const config = vscode.workspace.getConfiguration(); - return defaultValue !== undefined - ? config.get(key, defaultValue) - : config.get(key); - } -} - -/** - * VS Code implementation of ProgressReporter - */ -export class VSCodeProgressReporter implements ProgressReporter { - async withProgress( - title: string, - operation: ( - progress: { - report: (value: { message?: string; increment?: number }) => void; - }, - cancellationToken: { - onCancellationRequested: (listener: () => void) => void; - }, - ) => Promise, - ): Promise { - return vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title, - cancellable: true, - }, - operation, - ); - } -} - -/** - * VS Code implementation of UserInteraction - */ -export class VSCodeUserInteraction implements UserInteraction { - async showErrorMessage( - message: string, - options?: { detail?: string; modal?: boolean; useCustom?: boolean }, - ...items: string[] - ): Promise { - return vscode.window.showErrorMessage(message, options || {}, ...items); - } - - async showWarningMessage( - message: string, - options?: { detail?: string; modal?: boolean; useCustom?: boolean }, - ...items: string[] - ): Promise { - return vscode.window.showWarningMessage(message, options || {}, ...items); - } - - async openExternal(url: string): Promise { - await vscode.env.openExternal(vscode.Uri.parse(url)); - } -} diff --git a/src/core/binaryManager.interfaces.ts b/src/core/binaryManager.interfaces.ts deleted file mode 100644 index 8d5a2be1..00000000 --- a/src/core/binaryManager.interfaces.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Provides access to configuration settings - */ -export interface ConfigurationProvider { - get(key: string): T | undefined; - get(key: string, defaultValue: T): T; -} - -/** - * Provides progress reporting capabilities for long-running operations - */ -export interface ProgressReporter { - /** - * Reports progress for a download operation with cancellation support - * @param title The title to display for the progress - * @param operation The operation to run with progress reporting - * @returns Promise that resolves to true if completed, false if cancelled - */ - withProgress( - title: string, - operation: ( - progress: { - report: (value: { message?: string; increment?: number }) => void; - }, - cancellationToken: { - onCancellationRequested: (listener: () => void) => void; - }, - ) => Promise, - ): Promise; -} - -/** - * User interaction capabilities for showing dialogs and opening external URLs - */ -export interface UserInteraction { - /** - * Shows an error message with optional action buttons - * @param message The message to display - * @param options Additional options for the dialog - * @param items Action button labels - * @returns Promise that resolves to the selected action or undefined - */ - showErrorMessage( - message: string, - options?: { detail?: string; modal?: boolean; useCustom?: boolean }, - ...items: string[] - ): Promise; - - /** - * Shows a warning message with optional action buttons - * @param message The message to display - * @param options Additional options for the dialog - * @param items Action button labels - * @returns Promise that resolves to the selected action or undefined - */ - showWarningMessage( - message: string, - options?: { detail?: string; modal?: boolean; useCustom?: boolean }, - ...items: string[] - ): Promise; - - /** - * Opens an external URL - * @param url The URL to open - * @returns Promise that resolves when the URL is opened - */ - openExternal(url: string): Promise; -} diff --git a/src/core/binaryManager.test.ts b/src/core/binaryManager.test.ts index 8764ea0f..1a237b5c 100644 --- a/src/core/binaryManager.test.ts +++ b/src/core/binaryManager.test.ts @@ -1,34 +1,30 @@ import globalAxios, { AxiosInstance } from "axios"; import { Api } from "coder/site/src/api/api"; -import { BuildInfoResponse } from "coder/site/src/api/typesGenerated"; -import type { Stats, WriteStream } from "fs"; import * as fs from "fs"; import * as fse from "fs/promises"; import { IncomingMessage } from "http"; -import * as path from "path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { WorkspaceConfiguration } from "vscode"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; +import { + MockConfigurationProvider, + MockProgressReporter, + MockUserInteraction, + setupVSCodeMocks, +} from "../__mocks__/testHelpers"; import * as cli from "../cliManager"; import { Logger } from "../logging/logger"; import * as pgp from "../pgp"; import { BinaryManager } from "./binaryManager"; -import { - ConfigurationProvider, - ProgressReporter, - UserInteraction, -} from "./binaryManager.interfaces"; import { PathResolver } from "./pathResolver"; // Mock all external modules +vi.mock("axios"); vi.mock("fs/promises"); -vi.mock("fs", () => ({ - createWriteStream: vi.fn(), -})); +vi.mock("fs"); vi.mock("../cliManager"); vi.mock("../pgp"); -vi.mock("axios"); -describe("Binary Manager", () => { +describe("BinaryManager", () => { let manager: BinaryManager; let mockLogger: Logger; let mockConfig: MockConfigurationProvider; @@ -37,537 +33,569 @@ describe("Binary Manager", () => { let mockApi: Api; let mockAxios: AxiosInstance; - // Test constants const TEST_VERSION = "1.2.3"; const TEST_URL = "https://test.coder.com"; - const BINARY_PATH = "/path/binary/coder"; + const BINARY_PATH = "/path/base/test/bin/coder"; beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); - // Initialize all mocks + // Core setup mockLogger = createMockLogger(); - mockConfig = new MockConfigurationProvider(); - mockProgress = new MockProgressReporter(); - mockUI = new MockUserInteraction(); mockApi = createMockApi(TEST_VERSION, TEST_URL); mockAxios = mockApi.getAxiosInstance(); - vi.mocked(globalAxios.create).mockReturnValue(mockAxios); - - const config = { - get: (key: string) => - key === "coder.binaryDestination" - ? path.dirname(BINARY_PATH) - : undefined, - } as unknown as WorkspaceConfiguration; - const pathResolver = new PathResolver("/path/base", "/code/log", config); - + ({ mockConfig, mockProgress, mockUI } = setupVSCodeMocks()); manager = new BinaryManager( mockLogger, - pathResolver, - mockConfig, - mockProgress, - mockUI, + new PathResolver("/path/base", "/code/log"), ); - // Setup default CLI mocks - setupDefaultCliMocks(); + // Default mocks - most tests rely on these + vi.mocked(cli.name).mockReturnValue("coder"); + vi.mocked(cli.stat).mockResolvedValue(undefined); // No existing binary by default + vi.mocked(cli.rmOld).mockResolvedValue([]); + vi.mocked(cli.eTag).mockResolvedValue(""); + vi.mocked(cli.version).mockResolvedValue(TEST_VERSION); + vi.mocked(cli.goos).mockReturnValue("linux"); + vi.mocked(cli.goarch).mockReturnValue("amd64"); + vi.mocked(fse.mkdir).mockResolvedValue(undefined); + vi.mocked(fse.rename).mockResolvedValue(undefined); + vi.mocked(pgp.readPublicKeys).mockResolvedValue([]); }); - describe("Configuration", () => { - it("respects disabled downloads setting", async () => { - mockConfig.set("coder.enableDownloads", false); + afterEach(() => { + mockProgress?.setCancellation(false); + vi.clearAllTimers(); + }); + describe("Version Validation", () => { + it("rejects invalid server versions", async () => { + mockApi.getBuildInfo = vi.fn().mockResolvedValue({ version: "invalid" }); await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( - "Unable to download CLI because downloads are disabled", + "Got invalid version from deployment", ); }); - it("validates server version", async () => { - mockApi.getBuildInfo = vi.fn().mockResolvedValue({ - version: "invalid-version", - }); + it("accepts valid semver versions", async () => { + withExistingBinary(TEST_VERSION); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + }); + }); - await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( - "Got invalid version from deployment", - ); + describe("Existing Binary Handling", () => { + beforeEach(() => { + // Disable signature verification for these tests + mockConfig.set("coder.disableSignatureVerification", true); }); - it("uses existing binary when versions match", async () => { - setupExistingBinary(TEST_VERSION); + it("reuses matching binary without downloading", async () => { + withExistingBinary(TEST_VERSION); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(mockAxios.get).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining( + "Using existing binary since it matches the server version", + ), + ); + }); + it("downloads when versions differ", async () => { + withExistingBinary("1.0.0"); + withSuccessfulDownload(); const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(mockAxios.get).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + "Downloaded binary version is", + TEST_VERSION, + ); + }); + it("keeps mismatched binary when downloads disabled", async () => { + mockConfig.set("coder.enableDownloads", false); + withExistingBinary("1.0.0"); + const result = await manager.fetchBinary(mockApi, "test"); expect(result).toBe(BINARY_PATH); + expect(mockAxios.get).not.toHaveBeenCalled(); expect(mockLogger.info).toHaveBeenCalledWith( - "Using existing binary since it matches the server version", + expect.stringContaining( + "Using existing binary even though it does not match the server version", + ), ); }); - it("handles corrupted existing binary gracefully", async () => { - vi.mocked(cli.stat).mockResolvedValue({ size: 1024 } as Stats); - vi.mocked(cli.version).mockRejectedValueOnce(new Error("corrupted")); + it("downloads fresh binary when corrupted", async () => { + withCorruptedBinary(); + withSuccessfulDownload(); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("Unable to get version"), + ); - setupSuccessfulDownload(mockApi); + // Should attempt to download now + expect(mockAxios.get).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + "Downloaded binary version is", + TEST_VERSION, + ); + }); + it("downloads when no binary exists", async () => { + withSuccessfulDownload(); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining("Unable to get version of existing binary"), + expect(mockAxios.get).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + "Downloaded binary version is", + TEST_VERSION, ); }); - }); - describe("Download Flow", () => { - it("downloads binary successfully", async () => { - setupSuccessfulDownload(mockApi); + it("fails when downloads disabled and no binary", async () => { + mockConfig.set("coder.enableDownloads", false); + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Unable to download CLI because downloads are disabled", + ); + }); + }); - const result = await manager.fetchBinary(mockApi, "test"); + describe("Download Behavior", () => { + beforeEach(() => { + // Disable signature verification for download behavior tests + mockConfig.set("coder.disableSignatureVerification", true); + }); - expect(result).toBe(BINARY_PATH); + it("downloads with correct headers", async () => { + withSuccessfulDownload(); + await manager.fetchBinary(mockApi, "test"); expect(mockAxios.get).toHaveBeenCalledWith( "/bin/coder", expect.objectContaining({ responseType: "stream", headers: expect.objectContaining({ "Accept-Encoding": "gzip", + "If-None-Match": '""', }), }), ); }); - it("handles 304 Not Modified response", async () => { - setupExistingBinary("1.2.5"); // Different version - mockAxios.get = vi.fn().mockResolvedValue({ - status: 304, - headers: {}, - data: undefined, - }); + it("uses custom binary source", async () => { + mockConfig.set("coder.binarySource", "/custom/path"); + withSuccessfulDownload(); + await manager.fetchBinary(mockApi, "test"); + expect(mockAxios.get).toHaveBeenCalledWith( + "/custom/path", + expect.any(Object), + ); + }); - const result = await manager.fetchBinary(mockApi, "test"); + it("uses ETag for existing binaries", async () => { + withExistingBinary("1.0.0"); + vi.mocked(cli.eTag).mockResolvedValueOnce("abc123"); + withSuccessfulDownload(); + await manager.fetchBinary(mockApi, "test"); + expect(mockAxios.get).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ "If-None-Match": '"abc123"' }), + }), + ); + }); - expect(result).toBe(BINARY_PATH); + it("cleans up old files before download", async () => { + vi.mocked(cli.rmOld).mockResolvedValueOnce([ + { fileName: "coder.old-xyz", error: undefined }, + { fileName: "coder.temp-abc", error: undefined }, + ]); + withSuccessfulDownload(); + await manager.fetchBinary(mockApi, "test"); + expect(cli.rmOld).toHaveBeenCalledWith(BINARY_PATH); + expect(mockLogger.info).toHaveBeenCalledWith("Removed", "coder.old-xyz"); + expect(mockLogger.info).toHaveBeenCalledWith("Removed", "coder.temp-abc"); + }); + + it("backs up existing binary before replacement", async () => { + // Setup existing old binary + vi.mocked(cli.stat).mockReset(); + vi.mocked(cli.stat) + .mockResolvedValueOnce({ size: 1024 } as fs.Stats) // Existing binary + .mockResolvedValueOnce({ size: 5242880 } as fs.Stats); // After download + vi.mocked(cli.version).mockReset(); + vi.mocked(cli.version) + .mockResolvedValueOnce("1.0.0") // Old version + .mockResolvedValueOnce(TEST_VERSION); // New version after download + + // Setup download + const stream = createMockStream(); + const writeStream = createMockWriteStream(); + withHttpResponse(200, { "content-length": "1024" }, stream); + vi.mocked(fs.createWriteStream).mockReturnValue(writeStream); + + await manager.fetchBinary(mockApi, "test"); + expect(fse.rename).toHaveBeenCalledWith( + BINARY_PATH, + expect.stringMatching(/\.old-[a-z0-9]+$/), + ); expect(mockLogger.info).toHaveBeenCalledWith( - "Using existing binary since server returned a 304", + "Moving existing binary to", + expect.stringMatching(/\.old-[a-z0-9]+$/), ); }); + }); + + describe("HTTP Response Handling", () => { + beforeEach(() => { + // Disable signature verification for these tests + mockConfig.set("coder.disableSignatureVerification", true); + }); + + it("handles 304 Not Modified", async () => { + withExistingBinary("1.0.0"); + withHttpResponse(304); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + }); it("handles 404 platform not supported", async () => { - mockAxios.get = vi.fn().mockResolvedValue({ - status: 404, - headers: {}, - data: undefined, - }); + withHttpResponse(404); mockUI.setResponse("Open an Issue"); - await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( "Platform not supported", ); - - expect(mockUI.openExternal).toHaveBeenCalledWith( - expect.stringContaining("github.com/coder/vscode-coder/issues/new"), - ); + expect(vscode.env.openExternal).toHaveBeenCalled(); }); - it("handles download failure", async () => { - mockAxios.get = vi.fn().mockResolvedValue({ - status: 500, - headers: {}, - data: undefined, - }); - + it("handles server errors", async () => { + withHttpResponse(500); await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( "Failed to download binary", ); }); }); - describe("Stream Error Handling", () => { - it("handles write stream errors", async () => { - const writeError = new Error("disk full"); - const { mockWriteStream, mockReadStream } = setupStreamMocks(); - - // Trigger write error after setup - mockWriteStream.on = vi.fn((event, callback) => { - if (event === "error") { - setTimeout(() => callback(writeError), 5); - } - return mockWriteStream; - }); - - mockAxios.get = vi.fn().mockResolvedValue({ - status: 200, - headers: { "content-length": "1024" }, - data: mockReadStream, - }); - - vi.mocked(fs.createWriteStream).mockReturnValue(mockWriteStream); + describe("Stream Handling", () => { + beforeEach(() => { + // Disable signature verification for these tests + mockConfig.set("coder.disableSignatureVerification", true); + }); + it("handles write stream errors", async () => { + withStreamError("write", "disk full"); await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( "Unable to download binary: disk full", ); - - expect(mockReadStream.destroy).toHaveBeenCalled(); }); it("handles read stream errors", async () => { - const { mockWriteStream } = setupStreamMocks(); - const mockReadStream = createMockReadStream((event, callback) => { - if (event === "error") { - setTimeout(() => callback(new Error("network timeout")), 5); - } - }); - - mockAxios.get = vi.fn().mockResolvedValue({ - status: 200, - headers: { "content-length": "1024" }, - data: mockReadStream, - }); - - vi.mocked(fs.createWriteStream).mockReturnValue(mockWriteStream); - + withStreamError("read", "network timeout"); await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( "Unable to download binary: network timeout", ); + }); - expect(mockWriteStream.close).toHaveBeenCalled(); + it("handles missing content-length", async () => { + withSuccessfulDownload({ headers: {} }); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(mockLogger.warn).toHaveBeenCalledWith( + "Got invalid or missing content length", + undefined, + ); }); }); - describe("Progress Monitor", () => { - it("rejects with 'Download aborted' when cancelled", async () => { - const { mockWriteStream, mockReadStream } = setupStreamMocks(); - - // Enable cancellation for this test - mockProgress.setCancellation(true); - - mockAxios.get = vi.fn().mockResolvedValue({ - status: 200, - headers: { "content-length": "1024" }, - data: mockReadStream, - }); + describe("Progress Tracking", () => { + beforeEach(() => { + // Disable signature verification for these tests + mockConfig.set("coder.disableSignatureVerification", true); + }); - vi.mocked(fs.createWriteStream).mockReturnValue(mockWriteStream); + it("shows download progress", async () => { + withSuccessfulDownload(); + await manager.fetchBinary(mockApi, "test"); + expect(vscode.window.withProgress).toHaveBeenCalledWith( + expect.objectContaining({ title: `Downloading ${TEST_URL}` }), + expect.any(Function), + ); + }); + it("handles user cancellation", async () => { + mockProgress.setCancellation(true); + withSuccessfulDownload(); await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( "Download aborted", ); - - expect(mockReadStream.destroy).toHaveBeenCalled(); - - // Reset cancellation state - mockProgress.setCancellation(false); }); }); describe("Signature Verification", () => { - beforeEach(() => { - vi.mocked(pgp.readPublicKeys).mockResolvedValue([]); - }); - - it("verifies signature successfully", async () => { - vi.mocked(pgp.verifySignature).mockResolvedValue(); - setupSuccessfulDownloadWithSignature(mockApi); - + it("verifies valid signatures", async () => { + withSuccessfulDownloadAndSignature(); + vi.mocked(pgp.verifySignature).mockResolvedValueOnce(); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); expect(pgp.verifySignature).toHaveBeenCalled(); }); - it("tries alternative signature source on 404", async () => { - vi.mocked(pgp.verifySignature).mockResolvedValue(); - - const { mockWriteStream, mockReadStream } = setupStreamMocks(); - mockAxios.get = vi - .fn() - .mockResolvedValueOnce(createStreamResponse(200, mockReadStream)) // Binary - .mockResolvedValueOnce({ status: 404, headers: {}, data: undefined }) // First sig - .mockResolvedValueOnce(createStreamResponse(200, mockReadStream)); // Alt sig - - vi.mocked(fs.createWriteStream).mockReturnValue(mockWriteStream); - vi.mocked(cli.stat) - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce({ size: 1024 } as Stats); - + it("tries fallback signature on 404", async () => { + withBinaryDownload(); + withSignatureResponses([404, 200]); + vi.mocked(pgp.verifySignature).mockResolvedValueOnce(); mockUI.setResponse("Download signature"); - const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(mockAxios.get).toHaveBeenCalledTimes(3); + }); + it("allows running despite invalid signature", async () => { + withSuccessfulDownloadAndSignature(); + vi.mocked(pgp.verifySignature).mockRejectedValueOnce( + createVerificationError("Invalid signature"), + ); + mockUI.setResponse("Run anyway"); + const result = await manager.fetchBinary(mockApi, "test"); expect(result).toBe(BINARY_PATH); - expect(mockUI.showWarningMessage).toHaveBeenCalledWith( - "Signature not found", - expect.any(Object), - expect.any(String), - expect.any(String), + }); + + it("aborts on signature rejection", async () => { + withSuccessfulDownloadAndSignature(); + vi.mocked(pgp.verifySignature).mockRejectedValueOnce( + createVerificationError("Invalid signature"), + ); + mockUI.setResponse(undefined); + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Signature verification aborted", ); }); - it("allows running without verification on user request", async () => { - setupSuccessfulDownload(mockApi); - mockAxios.get = vi - .fn() - .mockResolvedValueOnce( - createStreamResponse(200, createMockReadStream()), - ) - .mockResolvedValueOnce({ status: 404, headers: {}, data: undefined }); + it("skips verification when disabled", async () => { + mockConfig.set("coder.disableSignatureVerification", true); + withSuccessfulDownload(); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(pgp.verifySignature).not.toHaveBeenCalled(); + }); + it("allows skipping verification on 404", async () => { + withBinaryDownload(); + withHttpResponse(404); mockUI.setResponse("Run without verification"); - const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); expect(pgp.verifySignature).not.toHaveBeenCalled(); }); - it("handles invalid signature with user override", async () => { - const verificationError = new pgp.VerificationError( - pgp.VerificationErrorCode.Invalid, - "Invalid signature", - ); - verificationError.summary = () => "Signature does not match"; - vi.mocked(pgp.verifySignature).mockRejectedValue(verificationError); - - setupSuccessfulDownloadWithSignature(mockApi); - mockUI.setResponse("Run anyway"); - + it("handles signature download failure", async () => { + withBinaryDownload(); + withHttpResponse(500); + mockUI.setResponse("Run without verification"); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); - expect(mockLogger.info).toHaveBeenCalledWith( - "Binary will be ran anyway at user request", + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + "Failed to download signature", + expect.any(Object), + "Download signature", // from the next source + "Run without verification", ); }); - it("aborts on signature verification rejection", async () => { - const verificationError = new pgp.VerificationError( - pgp.VerificationErrorCode.Invalid, - "Invalid signature", - ); - verificationError.summary = () => "Signature does not match"; - vi.mocked(pgp.verifySignature).mockRejectedValue(verificationError); - - setupSuccessfulDownloadWithSignature(mockApi); - mockUI.setResponse(undefined); // User rejects - + it("aborts when user declines missing signature", async () => { + withBinaryDownload(); + withHttpResponse(404); + mockUI.setResponse(undefined); // User cancels await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( - "Signature verification aborted", + "Signature download aborted", ); }); + }); - it("skips verification when disabled in config", async () => { + describe("File System Operations", () => { + beforeEach(() => { + // Disable signature verification for these tests mockConfig.set("coder.disableSignatureVerification", true); - vi.mocked(cli.version).mockResolvedValueOnce("1.5.9"); // No existing binary - setupSuccessfulDownload(mockApi); + }); - const result = await manager.fetchBinary(mockApi, "test"); + it("creates binary directory", async () => { + withSuccessfulDownload(); + await manager.fetchBinary(mockApi, "test"); + expect(fse.mkdir).toHaveBeenCalledWith(expect.stringContaining("/bin"), { + recursive: true, + }); + }); - expect(result).toBe(BINARY_PATH); - expect(pgp.verifySignature).not.toHaveBeenCalled(); + it("validates downloaded binary version", async () => { + withSuccessfulDownload(); + await manager.fetchBinary(mockApi, "test"); + expect(cli.version).toHaveBeenCalledWith(BINARY_PATH); expect(mockLogger.info).toHaveBeenCalledWith( - "Skipping binary signature verification due to settings", + "Downloaded binary version is", + TEST_VERSION, ); }); - }); -}); -// Helper Classes -class MockConfigurationProvider implements ConfigurationProvider { - private config = new Map(); + it("logs file sizes for debugging", async () => { + withSuccessfulDownload(); + vi.mocked(cli.stat).mockResolvedValueOnce({ size: 5242880 } as fs.Stats); + await manager.fetchBinary(mockApi, "test"); + expect(mockLogger.info).toHaveBeenCalledWith( + "Downloaded binary size is", + "5.24 MB", + ); + }); + }); - set(key: string, value: unknown): void { - this.config.set(key, value); + function createMockLogger(): Logger { + return { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; } - get(key: string): T | undefined; - get(key: string, defaultValue: T): T; - get(key: string, defaultValue?: T): T | undefined { - const value = this.config.get(key); - return value !== undefined ? (value as T) : defaultValue; + function createMockApi(version: string, url: string): Api { + const axios = { + defaults: { baseURL: url }, + get: vi.fn(), + } as unknown as AxiosInstance; + return { + getBuildInfo: vi.fn().mockResolvedValue({ version }), + getAxiosInstance: () => axios, + } as unknown as Api; } -} -class MockProgressReporter implements ProgressReporter { - private shouldCancel = false; + function withExistingBinary(version: string) { + vi.mocked(cli.stat).mockReset(); + vi.mocked(cli.stat).mockResolvedValueOnce({ size: 1024 } as fs.Stats); + vi.mocked(cli.version).mockReset(); + vi.mocked(cli.version).mockResolvedValueOnce(version); + } - setCancellation(cancel: boolean): void { - this.shouldCancel = cancel; + function withCorruptedBinary() { + vi.mocked(cli.stat).mockReset(); + vi.mocked(cli.stat).mockResolvedValueOnce({ size: 1024 } as fs.Stats); // Existing binary exists + vi.mocked(cli.version).mockReset(); + vi.mocked(cli.version) + .mockRejectedValueOnce(new Error("corrupted")) // Existing binary is corrupted + .mockResolvedValueOnce(TEST_VERSION); // New download works } - async withProgress( - _title: string, - operation: ( - progress: { - report: (value: { message?: string; increment?: number }) => void; - }, - cancellationToken: { - onCancellationRequested: (listener: () => void) => void; - }, - ) => Promise, - ): Promise { - const mockToken = { - onCancellationRequested: vi.fn((callback: () => void) => { - if (this.shouldCancel) { - setTimeout(callback, 0); - } - }), - }; - return operation({ report: vi.fn() }, mockToken); + function withSuccessfulDownload(opts?: { + headers?: Record; + }) { + const stream = createMockStream(); + const writeStream = createMockWriteStream(); + withHttpResponse( + 200, + opts?.headers ?? { "content-length": "1024" }, + stream, + ); + vi.mocked(fs.createWriteStream).mockReturnValue(writeStream); + // Ensure no existing binary initially, then file exists after download + vi.mocked(cli.stat) + .mockResolvedValueOnce(undefined) // No existing binary + .mockResolvedValueOnce({ size: 5242880 } as fs.Stats); // After download + // Version check after download + vi.mocked(cli.version).mockResolvedValueOnce(TEST_VERSION); } -} - -class MockUserInteraction implements UserInteraction { - private responses = new Map(); - - setResponse(response: string | undefined): void; - setResponse(message: string, response: string | undefined): void; - setResponse( - messageOrResponse: string | undefined, - response?: string | undefined, - ): void { - if (response === undefined && messageOrResponse !== undefined) { - // Single argument - set default response - this.responses.set("default", messageOrResponse); - } else if (messageOrResponse !== undefined) { - // Two arguments - set specific response - this.responses.set(messageOrResponse, response); - } + + function withBinaryDownload() { + const stream = createMockStream(); + const writeStream = createMockWriteStream(); + withHttpResponse(200, { "content-length": "1024" }, stream); + vi.mocked(fs.createWriteStream).mockReturnValue(writeStream); + // Override to ensure no existing binary initially + vi.mocked(cli.stat).mockReset(); + vi.mocked(cli.stat) + .mockResolvedValueOnce(undefined) // No existing binary + .mockResolvedValueOnce({ size: 5242880 } as fs.Stats); // After download + vi.mocked(cli.version).mockReset(); + vi.mocked(cli.version).mockResolvedValueOnce(TEST_VERSION); } - showErrorMessage = vi.fn( - async (message: string): Promise => { - return ( - (await this.responses.get(message)) ?? this.responses.get("default") - ); - }, - ); + function withSuccessfulDownloadAndSignature() { + withBinaryDownload(); + withHttpResponse(200, { "content-length": "256" }, createMockStream()); + } - showWarningMessage = vi.fn( - async (message: string): Promise => { - return ( - (await this.responses.get(message)) ?? this.responses.get("default") - ); - }, - ); - - openExternal = vi.fn(); -} - -// Helper Functions -function createMockLogger(): Logger { - return { - trace: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }; -} - -function createMockApi(version: string, url: string): Api { - const mockAxios = { - defaults: { baseURL: url }, - get: vi.fn(), - } as unknown as AxiosInstance; - - return { - getBuildInfo: vi.fn().mockResolvedValue({ - version, - external_url: url, - dashboard_url: url, - telemetry: false, - workspace_proxy: false, - upgrade_message: "", - deployment_id: "test", - agent_api_version: "1.0", - provisioner_api_version: "1.0", - } as BuildInfoResponse), - getAxiosInstance: vi.fn().mockReturnValue(mockAxios), - } as unknown as Api; -} - -function setupDefaultCliMocks(): void { - vi.mocked(cli.name).mockReturnValue("coder"); - vi.mocked(cli.stat).mockResolvedValue(undefined); - vi.mocked(cli.rmOld).mockResolvedValue([]); - vi.mocked(cli.eTag).mockResolvedValue(""); - vi.mocked(cli.version).mockResolvedValue("1.2.3"); - vi.mocked(cli.goos).mockReturnValue("linux"); - vi.mocked(cli.goarch).mockReturnValue("amd64"); - vi.mocked(fse.mkdir).mockResolvedValue(undefined); - vi.mocked(fse.rename).mockResolvedValue(undefined); -} - -function setupExistingBinary(version: string): void { - vi.mocked(cli.stat).mockResolvedValue({ size: 1024 } as Stats); - vi.mocked(cli.version).mockResolvedValue(version); -} - -function setupStreamMocks() { - const mockWriteStream = { - on: vi.fn().mockReturnThis(), - write: vi.fn((_buffer: Buffer, callback?: () => void) => { - callback?.(); - }), - close: vi.fn(), - } as unknown as WriteStream; - - const mockReadStream = createMockReadStream(); - - return { mockWriteStream, mockReadStream }; -} - -function createMockReadStream( - customHandler?: (event: string, callback: (data?: unknown) => void) => void, -): IncomingMessage { - return { - on: vi.fn((event: string, callback: (data?: unknown) => void) => { - if (customHandler) { - customHandler(event, callback); + function withSignatureResponses(statuses: number[]) { + statuses.forEach((status) => { + if (status === 200) { + withHttpResponse(200, { "content-length": "256" }, createMockStream()); } else { + withHttpResponse(status); + } + }); + } + + function withHttpResponse( + status: number, + headers: Record = {}, + data?: unknown, + ) { + vi.mocked(mockAxios.get).mockResolvedValueOnce({ + status, + headers, + data, + }); + } + + function withStreamError(type: "read" | "write", message: string) { + const writeStream = createMockWriteStream(); + const readStream = createMockStream(); + + if (type === "write") { + writeStream.on = vi.fn((event, callback) => { + if (event === "error") { + setTimeout(() => callback(new Error(message)), 5); + } + return writeStream; + }); + } else { + readStream.on = vi.fn((event, callback) => { + if (event === "error") { + setTimeout(() => callback(new Error(message)), 5); + } + return readStream; + }); + } + + withHttpResponse(200, { "content-length": "1024" }, readStream); + vi.mocked(fs.createWriteStream).mockReturnValue(writeStream); + } + + function createMockStream(): IncomingMessage { + return { + on: vi.fn((event: string, callback: (data: unknown) => void) => { if (event === "data") { - setTimeout(() => callback(Buffer.from("mock-data")), 0); + setTimeout(() => callback(Buffer.from("mock")), 0); } else if (event === "close") { setTimeout(callback, 10); } - } - }), - destroy: vi.fn(), - } as unknown as IncomingMessage; -} - -function createStreamResponse(status: number, stream: IncomingMessage) { - return { - status, - headers: { "content-length": "1024" }, - data: stream, - }; -} - -function setupSuccessfulDownload(mockApi: Api): void { - const { mockWriteStream, mockReadStream } = setupStreamMocks(); - - vi.mocked(fs.createWriteStream).mockReturnValue(mockWriteStream); - const axios = mockApi.getAxiosInstance(); - axios.get = vi - .fn() - .mockResolvedValue(createStreamResponse(200, mockReadStream)); -} - -function setupSuccessfulDownloadWithSignature(mockApi: Api): void { - const { mockWriteStream, mockReadStream } = setupStreamMocks(); - const signatureStream = createMockReadStream(); - - vi.mocked(fs.createWriteStream).mockReturnValue(mockWriteStream); - vi.mocked(cli.stat) - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce({ size: 1024 } as Stats); - - const axios = mockApi.getAxiosInstance() as AxiosInstance; - axios.get = vi - .fn() - .mockResolvedValueOnce(createStreamResponse(200, mockReadStream)) // Binary - .mockResolvedValueOnce(createStreamResponse(200, signatureStream)); // Signature -} + }), + destroy: vi.fn(), + } as unknown as IncomingMessage; + } + + function createMockWriteStream(): fs.WriteStream { + return { + on: vi.fn().mockReturnThis(), + write: vi.fn((_: Buffer, cb?: () => void) => cb?.()), + close: vi.fn(), + } as unknown as fs.WriteStream; + } + + function createVerificationError(msg: string): pgp.VerificationError { + const error = new pgp.VerificationError( + pgp.VerificationErrorCode.Invalid, + msg, + ); + return error; + } +}); diff --git a/src/core/binaryManager.ts b/src/core/binaryManager.ts index 5b7df04a..09317228 100644 --- a/src/core/binaryManager.ts +++ b/src/core/binaryManager.ts @@ -10,24 +10,17 @@ import path from "path"; import prettyBytes from "pretty-bytes"; import * as semver from "semver"; +import * as vscode from "vscode"; import { errToStr } from "../api/api-helper"; import * as cli from "../cliManager"; import { Logger } from "../logging/logger"; import * as pgp from "../pgp"; -import { - ConfigurationProvider, - ProgressReporter, - UserInteraction, -} from "./binaryManager.interfaces"; import { PathResolver } from "./pathResolver"; export class BinaryManager { constructor( private readonly output: Logger, private readonly pathResolver: PathResolver, - private readonly config: ConfigurationProvider, - private readonly progressReporter: ProgressReporter, - private readonly userInteraction: UserInteraction, ) {} /** @@ -44,7 +37,9 @@ export class BinaryManager { public async fetchBinary(restClient: Api, label: string): Promise { // Settings can be undefined when set to their defaults (true in this case), // so explicitly check against false. - const enableDownloads = this.config.get("coder.enableDownloads") !== false; + const enableDownloads = + vscode.workspace.getConfiguration().get("coder.enableDownloads") !== + false; this.output.info("Downloads are", enableDownloads ? "enabled" : "disabled"); // Get the build info to compare with the existing binary version, if any, @@ -113,7 +108,9 @@ export class BinaryManager { // Figure out where to get the binary. const binName = cli.name(); - const configSource = this.config.get("coder.binarySource"); + const configSource = vscode.workspace + .getConfiguration() + .get("coder.binarySource"); const binSource = configSource && String(configSource).trim().length > 0 ? String(configSource) @@ -141,7 +138,11 @@ export class BinaryManager { switch (status) { case 200: { - if (this.config.get("coder.disableSignatureVerification")) { + if ( + vscode.workspace + .getConfiguration() + .get("coder.disableSignatureVerification") + ) { this.output.info( "Skipping binary signature verification due to settings", ); @@ -195,7 +196,7 @@ export class BinaryManager { return binPath; } case 404: { - this.userInteraction + vscode.window .showErrorMessage( "Coder isn't supported for your platform. Please open an issue, we'd love to support it!", {}, @@ -211,13 +212,15 @@ export class BinaryManager { title: `Support the \`${os}-${arch}\` platform`, body: `I'd like to use the \`${os}-${arch}\` architecture with the VS Code extension.`, }); - const url = `https://github.com/coder/vscode-coder/issues/new?${params.toString()}`; - this.userInteraction.openExternal(url); + const url = vscode.Uri.parse( + `https://github.com/coder/vscode-coder/issues/new?${params.toString()}`, + ); + vscode.env.openExternal(url); }); throw new Error("Platform not supported"); } default: { - this.userInteraction + vscode.window .showErrorMessage( "Failed to download binary. Please open an issue.", {}, @@ -231,8 +234,10 @@ export class BinaryManager { title: `Failed to download binary on \`${cli.goos()}-${cli.goarch()}\``, body: `Received status code \`${status}\` when downloading the binary.`, }); - const url = `https://github.com/coder/vscode-coder/issues/new?${params.toString()}`; - this.userInteraction.openExternal(url); + const url = vscode.Uri.parse( + `https://github.com/coder/vscode-coder/issues/new?${params.toString()}`, + ); + vscode.env.openExternal(url); }); throw new Error("Failed to download binary"); } @@ -278,8 +283,11 @@ export class BinaryManager { // Track how many bytes were written. let written = 0; - const completed = await this.progressReporter.withProgress( - `Downloading ${baseUrl}`, + const completed = await vscode.window.withProgress( + { + title: `Downloading ${baseUrl}`, + location: vscode.ProgressLocation.Notification, + }, async (progress, token) => { const readStream = resp.data as IncomingMessage; let cancelled = false; @@ -393,7 +401,7 @@ export class BinaryManager { options.push("Download signature"); } options.push("Run without verification"); - const action = await this.userInteraction.showWarningMessage( + const action = await vscode.window.showWarningMessage( status === 404 ? "Signature not found" : "Failed to download signature", { useCustom: true, @@ -447,7 +455,7 @@ export class BinaryManager { this.output, ); } catch (error) { - const action = await this.userInteraction.showWarningMessage( + const action = await vscode.window.showWarningMessage( // VerificationError should be the only thing that throws, but // unfortunately caught errors are always type unknown. error instanceof pgp.VerificationError diff --git a/src/core/pathResolver.ts b/src/core/pathResolver.ts index 2f9f08bc..6c1ee7ef 100644 --- a/src/core/pathResolver.ts +++ b/src/core/pathResolver.ts @@ -1,11 +1,10 @@ import * as path from "path"; -import type { WorkspaceConfiguration } from "vscode"; +import * as vscode from "vscode"; export class PathResolver { constructor( private readonly basePath: string, private readonly codeLogPath: string, - private readonly configurations: WorkspaceConfiguration, ) {} /** @@ -29,9 +28,9 @@ export class PathResolver { * The caller must ensure this directory exists before use. */ public getBinaryCachePath(label: string): string { - const configPath = this.configurations.get( - "coder.binaryDestination", - ); + const configPath = vscode.workspace + .getConfiguration() + .get("coder.binaryDestination"); return configPath && configPath.trim().length > 0 ? path.normalize(configPath) : path.join(this.getGlobalConfigDir(label), "bin"); diff --git a/src/extension.ts b/src/extension.ts index 144a53d1..bf3f6eb2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,11 +8,6 @@ import { CoderApi } from "./api/coderApi"; import { needToken } from "./api/utils"; import { Commands } from "./commands"; import { BinaryManager } from "./core/binaryManager"; -import { - VSCodeConfigurationProvider, - VSCodeProgressReporter, - VSCodeUserInteraction, -} from "./core/binaryManager.adapters"; import { CliConfigManager } from "./core/cliConfig"; import { MementoManager } from "./core/mementoManager"; import { PathResolver } from "./core/pathResolver"; @@ -20,7 +15,7 @@ import { SecretsManager } from "./core/secretsManager"; import { CertificateError, getErrorDetail } from "./error"; import { Remote } from "./remote"; import { toSafeHost } from "./util"; -import { WorkspaceQuery, WorkspaceProvider } from "./workspacesProvider"; +import { WorkspaceProvider, WorkspaceQuery } from "./workspacesProvider"; export async function activate(ctx: vscode.ExtensionContext): Promise { // The Remote SSH extension's proposed APIs are used to override the SSH host @@ -60,7 +55,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const pathResolver = new PathResolver( ctx.globalStorageUri.fsPath, ctx.logUri.fsPath, - vscode.workspace.getConfiguration(), ); const cliConfigManager = new CliConfigManager(pathResolver); const mementoManager = new MementoManager(ctx.globalState); @@ -261,13 +255,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }, }); - const binaryManager = new BinaryManager( - output, - pathResolver, - new VSCodeConfigurationProvider(), - new VSCodeProgressReporter(), - new VSCodeUserInteraction(), - ); + const binaryManager = new BinaryManager(output, pathResolver); // Register globally available commands. Many of these have visibility // controlled by contexts, see `when` in the package.json. diff --git a/vitest.config.ts b/vitest.config.ts index 6cf29157..af067d95 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,3 +1,4 @@ +import path from "path"; import { defineConfig } from "vitest/config"; export default defineConfig({ @@ -17,4 +18,9 @@ export default defineConfig({ provider: "v8", }, }, + resolve: { + alias: { + vscode: path.resolve(__dirname, "src/__mocks__/vscode.runtime.ts"), + }, + }, }); From 0b42207a591cc0e495cf6d2a955ba4bd8013e22f Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Sat, 20 Sep 2025 17:07:46 +0300 Subject: [PATCH 09/12] Add unit tests for the split modules --- src/__mocks__/testHelpers.ts | 41 ++++------ src/core/binaryManager.test.ts | 5 +- src/core/cliConfig.test.ts | 139 ++++++++++++++++++++++++++++++++ src/core/mementoManager.test.ts | 105 ++++++++++++++++++++++++ src/core/mementoManager.ts | 2 +- src/core/pathResolver.test.ts | 74 +++++++++++++++++ src/core/secretsManager.test.ts | 75 +++++++++++++++++ 7 files changed, 412 insertions(+), 29 deletions(-) create mode 100644 src/core/cliConfig.test.ts create mode 100644 src/core/mementoManager.test.ts create mode 100644 src/core/pathResolver.test.ts create mode 100644 src/core/secretsManager.test.ts diff --git a/src/__mocks__/testHelpers.ts b/src/__mocks__/testHelpers.ts index 1574a369..55f276ff 100644 --- a/src/__mocks__/testHelpers.ts +++ b/src/__mocks__/testHelpers.ts @@ -8,12 +8,15 @@ import * as vscode from "vscode"; export class MockConfigurationProvider { private config = new Map(); + constructor() { + this.setupVSCodeMock(); + } + /** * Set a configuration value that will be returned by vscode.workspace.getConfiguration().get() */ set(key: string, value: unknown): void { this.config.set(key, value); - this.setupVSCodeMock(); } /** @@ -31,13 +34,12 @@ export class MockConfigurationProvider { */ clear(): void { this.config.clear(); - this.setupVSCodeMock(); } /** * Setup the vscode.workspace.getConfiguration mock to return our values */ - setupVSCodeMock(): void { + private setupVSCodeMock(): void { vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: vi.fn((key: string, defaultValue?: unknown) => { const value = this.config.get(key); @@ -58,6 +60,10 @@ export class MockProgressReporter { private shouldCancel = false; private progressReports: Array<{ message?: string; increment?: number }> = []; + constructor() { + this.setupVSCodeMock(); + } + /** * Set whether the progress should be cancelled */ @@ -82,7 +88,7 @@ export class MockProgressReporter { /** * Setup the vscode.window.withProgress mock */ - setupVSCodeMock(): void { + private setupVSCodeMock(): void { vi.mocked(vscode.window.withProgress).mockImplementation( async ( _options: vscode.ProgressOptions, @@ -121,6 +127,10 @@ export class MockUserInteraction { private responses = new Map(); private externalUrls: string[] = []; + constructor() { + this.setupVSCodeMock(); + } + /** * Set a response for a specific message or set a default response */ @@ -163,7 +173,7 @@ export class MockUserInteraction { /** * Setup the vscode.window message dialog mocks */ - setupVSCodeMock(): void { + private setupVSCodeMock(): void { const getResponse = (message: string): string | undefined => { return this.responses.get(message) ?? this.responses.get("default"); }; @@ -200,24 +210,3 @@ export class MockUserInteraction { ); } } - -/** - * Helper function to setup all VS Code mocks for testing. - * Call this in your test setup to initialize all the mock integrations. - */ -export function setupVSCodeMocks(): { - mockConfig: MockConfigurationProvider; - mockProgress: MockProgressReporter; - mockUI: MockUserInteraction; -} { - const mockConfig = new MockConfigurationProvider(); - const mockProgress = new MockProgressReporter(); - const mockUI = new MockUserInteraction(); - - // Setup all the VS Code API mocks - mockConfig.setupVSCodeMock(); - mockProgress.setupVSCodeMock(); - mockUI.setupVSCodeMock(); - - return { mockConfig, mockProgress, mockUI }; -} diff --git a/src/core/binaryManager.test.ts b/src/core/binaryManager.test.ts index 1a237b5c..8530097a 100644 --- a/src/core/binaryManager.test.ts +++ b/src/core/binaryManager.test.ts @@ -9,7 +9,6 @@ import { MockConfigurationProvider, MockProgressReporter, MockUserInteraction, - setupVSCodeMocks, } from "../__mocks__/testHelpers"; import * as cli from "../cliManager"; import { Logger } from "../logging/logger"; @@ -45,7 +44,9 @@ describe("BinaryManager", () => { mockApi = createMockApi(TEST_VERSION, TEST_URL); mockAxios = mockApi.getAxiosInstance(); vi.mocked(globalAxios.create).mockReturnValue(mockAxios); - ({ mockConfig, mockProgress, mockUI } = setupVSCodeMocks()); + mockConfig = new MockConfigurationProvider(); + mockProgress = new MockProgressReporter(); + mockUI = new MockUserInteraction(); manager = new BinaryManager( mockLogger, new PathResolver("/path/base", "/code/log"), diff --git a/src/core/cliConfig.test.ts b/src/core/cliConfig.test.ts new file mode 100644 index 00000000..86a32777 --- /dev/null +++ b/src/core/cliConfig.test.ts @@ -0,0 +1,139 @@ +import fs from "fs/promises"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { CliConfigManager } from "./cliConfig"; +import { PathResolver } from "./pathResolver"; + +vi.mock("fs/promises"); + +describe("CliConfigManager", () => { + let pathResolver: PathResolver; + let cliConfigManager: CliConfigManager; + const mockFs = vi.mocked(fs); + const writtenFiles = new Map(); + + beforeEach(() => { + vi.resetAllMocks(); + writtenFiles.clear(); + pathResolver = new PathResolver("/test/base", "/test/log"); + cliConfigManager = new CliConfigManager(pathResolver); + + mockFs.mkdir.mockResolvedValue(undefined); + mockFs.writeFile.mockImplementation(async (path, content) => { + writtenFiles.set(path.toString(), content.toString()); + return Promise.resolve(); + }); + }); + + describe("configure", () => { + it("should write both url and token to correct paths", async () => { + await cliConfigManager.configure( + "deployment", + "https://coder.example.com", + "test-token", + ); + + expect([...writtenFiles.entries()]).toEqual([ + ["/test/base/deployment/url", "https://coder.example.com"], + ["/test/base/deployment/session", "test-token"], + ]); + }); + + it("should skip URL when undefined but write token", async () => { + await cliConfigManager.configure("deployment", undefined, "test-token"); + + // No entry for the url + expect([...writtenFiles.entries()]).toEqual([ + ["/test/base/deployment/session", "test-token"], + ]); + }); + + it("should skip token when null but write URL", async () => { + await cliConfigManager.configure( + "deployment", + "https://coder.example.com", + null, + ); + + // No entry for the session + expect([...writtenFiles.entries()]).toEqual([ + ["/test/base/deployment/url", "https://coder.example.com"], + ]); + }); + + it("should write empty string for token when provided", async () => { + await cliConfigManager.configure( + "deployment", + "https://coder.example.com", + "", + ); + + expect([...writtenFiles.entries()]).toEqual([ + ["/test/base/deployment/url", "https://coder.example.com"], + ["/test/base/deployment/session", ""], + ]); + }); + + it("should use base path directly when label is empty", async () => { + await cliConfigManager.configure( + "", + "https://coder.example.com", + "token", + ); + + expect([...writtenFiles.entries()]).toEqual([ + ["/test/base/url", "https://coder.example.com"], + ["/test/base/session", "token"], + ]); + }); + }); + + describe("readConfig", () => { + beforeEach(() => { + mockFs.readFile.mockImplementation(async (filePath) => { + const path = filePath.toString(); + if (writtenFiles.has(path)) { + return writtenFiles.get(path)!; + } + return Promise.reject(new Error("ENOENT: no such file or directory")); + }); + }); + + it("should read and trim stored configuration", async () => { + writtenFiles.set( + "/test/base/deployment/url", + " https://coder.example.com \n", + ); + writtenFiles.set("/test/base/deployment/session", "\t test-token \r\n"); + + const result = await cliConfigManager.readConfig("deployment"); + + expect(result).toEqual({ + url: "https://coder.example.com", + token: "test-token", + }); + }); + + it("should return empty strings for missing files", async () => { + const result = await cliConfigManager.readConfig("deployment"); + + expect(result).toEqual({ + url: "", + token: "", + }); + }); + + it("should handle partial configuration", async () => { + writtenFiles.set( + "/test/base/deployment/url", + "https://coder.example.com", + ); + + const result = await cliConfigManager.readConfig("deployment"); + + expect(result).toEqual({ + url: "https://coder.example.com", + token: "", + }); + }); + }); +}); diff --git a/src/core/mementoManager.test.ts b/src/core/mementoManager.test.ts new file mode 100644 index 00000000..fddfbbfa --- /dev/null +++ b/src/core/mementoManager.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import type { Memento } from "vscode"; +import { MementoManager } from "./mementoManager"; + +// Simple in-memory implementation of Memento +class InMemoryMemento implements Memento { + private storage = new Map(); + + get(key: string): T | undefined; + get(key: string, defaultValue: T): T; + get(key: string, defaultValue?: T): T | undefined { + return this.storage.has(key) ? (this.storage.get(key) as T) : defaultValue; + } + + async update(key: string, value: unknown): Promise { + if (value === undefined) { + this.storage.delete(key); + } else { + this.storage.set(key, value); + } + return Promise.resolve(); + } + + keys(): readonly string[] { + return Array.from(this.storage.keys()); + } +} + +describe("MementoManager", () => { + let memento: InMemoryMemento; + let mementoManager: MementoManager; + + beforeEach(() => { + memento = new InMemoryMemento(); + mementoManager = new MementoManager(memento); + }); + + describe("setUrl", () => { + it("should store URL and add to history", async () => { + await mementoManager.setUrl("https://coder.example.com"); + + expect(mementoManager.getUrl()).toBe("https://coder.example.com"); + expect(memento.get("urlHistory")).toEqual(["https://coder.example.com"]); + }); + + it("should not update history for falsy values", async () => { + await mementoManager.setUrl(undefined); + expect(mementoManager.getUrl()).toBeUndefined(); + expect(memento.get("urlHistory")).toBeUndefined(); + + await mementoManager.setUrl(""); + expect(mementoManager.getUrl()).toBe(""); + expect(memento.get("urlHistory")).toBeUndefined(); + }); + + it("should deduplicate URLs in history", async () => { + await mementoManager.setUrl("url1"); + await mementoManager.setUrl("url2"); + await mementoManager.setUrl("url1"); // Re-add first URL + + expect(memento.get("urlHistory")).toEqual(["url2", "url1"]); + }); + }); + + describe("withUrlHistory", () => { + it("should append URLs and remove duplicates", async () => { + await memento.update("urlHistory", ["existing1", "existing2"]); + + const result = mementoManager.withUrlHistory("existing2", "new1"); + + expect(result).toEqual(["existing1", "existing2", "new1"]); + }); + + it("should limit to 10 URLs", async () => { + const urls = Array.from({ length: 10 }, (_, i) => `url${i}`); + await memento.update("urlHistory", urls); + + const result = mementoManager.withUrlHistory("url20"); + + expect(result).toHaveLength(10); + expect(result[0]).toBe("url1"); + expect(result[9]).toBe("url20"); + }); + + it("should handle non-array storage gracefully", async () => { + await memento.update("urlHistory", "not-an-array"); + const result = mementoManager.withUrlHistory("url1"); + expect(result).toEqual(["url1"]); + }); + }); + + describe("firstConnect", () => { + it("should return true only once", async () => { + await mementoManager.setFirstConnect(); + + expect(await mementoManager.getAndClearFirstConnect()).toBe(true); + expect(await mementoManager.getAndClearFirstConnect()).toBe(false); + }); + + it("should return false for non-boolean values", async () => { + await memento.update("firstConnect", "truthy-string"); + expect(await mementoManager.getAndClearFirstConnect()).toBe(false); + }); + }); +}); diff --git a/src/core/mementoManager.ts b/src/core/mementoManager.ts index af99ee58..f79be46c 100644 --- a/src/core/mementoManager.ts +++ b/src/core/mementoManager.ts @@ -30,7 +30,7 @@ export class MementoManager { /** * Get the most recently accessed URLs (oldest to newest) with the provided - * values appended. Duplicates will be removed. + * values appended. Duplicates will be removed. */ public withUrlHistory(...append: (string | undefined)[]): string[] { const val = this.memento.get("urlHistory"); diff --git a/src/core/pathResolver.test.ts b/src/core/pathResolver.test.ts new file mode 100644 index 00000000..83700f69 --- /dev/null +++ b/src/core/pathResolver.test.ts @@ -0,0 +1,74 @@ +import * as path from "path"; +import { describe, it, expect, beforeEach } from "vitest"; +import { MockConfigurationProvider } from "../__mocks__/testHelpers"; +import { PathResolver } from "./pathResolver"; + +describe("PathResolver", () => { + const basePath = + "/home/user/.vscode-server/data/User/globalStorage/coder.coder-remote"; + const codeLogPath = "/home/user/.vscode-server/data/logs/coder.coder-remote"; + let pathResolver: PathResolver; + let mockConfig: MockConfigurationProvider; + + beforeEach(() => { + pathResolver = new PathResolver(basePath, codeLogPath); + mockConfig = new MockConfigurationProvider(); + }); + + it("should generate deployment-specific paths", () => { + const label = "my-deployment"; + + expect(pathResolver.getGlobalConfigDir(label)).toBe( + path.join(basePath, label), + ); + expect(pathResolver.getSessionTokenPath(label)).toBe( + path.join(basePath, label, "session"), + ); + expect(pathResolver.getLegacySessionTokenPath(label)).toBe( + path.join(basePath, label, "session_token"), + ); + expect(pathResolver.getUrlPath(label)).toBe( + path.join(basePath, label, "url"), + ); + }); + + it("should use base path for empty labels", () => { + expect(pathResolver.getGlobalConfigDir("")).toBe(basePath); + expect(pathResolver.getSessionTokenPath("")).toBe( + path.join(basePath, "session"), + ); + expect(pathResolver.getUrlPath("")).toBe(path.join(basePath, "url")); + }); + + it("should return static paths correctly", () => { + expect(pathResolver.getNetworkInfoPath()).toBe(path.join(basePath, "net")); + expect(pathResolver.getLogPath()).toBe(path.join(basePath, "log")); + expect(pathResolver.getCodeLogDir()).toBe(codeLogPath); + expect(pathResolver.getUserSettingsPath()).toBe( + path.join(basePath, "..", "..", "..", "User", "settings.json"), + ); + }); + + describe("getBinaryCachePath", () => { + it("should use custom binary destination when configured", () => { + mockConfig.set("coder.binaryDestination", "/custom/binary/path"); + expect(pathResolver.getBinaryCachePath("deployment")).toBe( + "/custom/binary/path", + ); + }); + + it("should use default path when custom destination is empty or whitespace", () => { + mockConfig.set("coder.binaryDestination", " "); + expect(pathResolver.getBinaryCachePath("deployment")).toBe( + path.join(basePath, "deployment", "bin"), + ); + }); + + it("should normalize custom paths", () => { + mockConfig.set("coder.binaryDestination", "/custom/../binary/./path"); + expect(pathResolver.getBinaryCachePath("deployment")).toBe( + path.normalize("/custom/../binary/./path"), + ); + }); + }); +}); diff --git a/src/core/secretsManager.test.ts b/src/core/secretsManager.test.ts new file mode 100644 index 00000000..839021f9 --- /dev/null +++ b/src/core/secretsManager.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import type { SecretStorage, Event, SecretStorageChangeEvent } from "vscode"; +import { SecretsManager } from "./secretsManager"; + +// Simple in-memory implementation of SecretStorage +class InMemorySecretStorage implements SecretStorage { + private secrets = new Map(); + private isCorrupted = false; + + onDidChange: Event = () => ({ dispose: () => {} }); + + async get(key: string): Promise { + if (this.isCorrupted) { + return Promise.reject(new Error("Storage corrupted")); + } + return this.secrets.get(key); + } + + async store(key: string, value: string): Promise { + if (this.isCorrupted) { + return Promise.reject(new Error("Storage corrupted")); + } + this.secrets.set(key, value); + } + + async delete(key: string): Promise { + if (this.isCorrupted) { + return Promise.reject(new Error("Storage corrupted")); + } + this.secrets.delete(key); + } + + corruptStorage(): void { + this.isCorrupted = true; + } +} + +describe("SecretsManager", () => { + let secretStorage: InMemorySecretStorage; + let secretsManager: SecretsManager; + + beforeEach(() => { + secretStorage = new InMemorySecretStorage(); + secretsManager = new SecretsManager(secretStorage); + }); + + describe("setSessionToken", () => { + it("should store and retrieve tokens", async () => { + await secretsManager.setSessionToken("test-token"); + expect(await secretsManager.getSessionToken()).toBe("test-token"); + + await secretsManager.setSessionToken("new-token"); + expect(await secretsManager.getSessionToken()).toBe("new-token"); + }); + + it("should delete token when empty or undefined", async () => { + await secretsManager.setSessionToken("test-token"); + await secretsManager.setSessionToken(""); + expect(await secretsManager.getSessionToken()).toBeUndefined(); + + await secretsManager.setSessionToken("test-token"); + await secretsManager.setSessionToken(undefined); + expect(await secretsManager.getSessionToken()).toBeUndefined(); + }); + }); + + describe("getSessionToken", () => { + it("should return undefined for corrupted storage", async () => { + await secretStorage.store("sessionToken", "valid-token"); + secretStorage.corruptStorage(); + + expect(await secretsManager.getSessionToken()).toBeUndefined(); + }); + }); +}); From ac418d11447ad4e13917bdd1cde4d6b4c79c4bbc Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 22 Sep 2025 14:36:24 +0300 Subject: [PATCH 10/12] Fix binary manager tests --- src/__mocks__/testHelpers.ts | 25 +++++--- src/core/binaryManager.test.ts | 101 +++++++++++++++++---------------- src/core/binaryManager.ts | 28 ++++----- 3 files changed, 79 insertions(+), 75 deletions(-) diff --git a/src/__mocks__/testHelpers.ts b/src/__mocks__/testHelpers.ts index 55f276ff..2b23ec0f 100644 --- a/src/__mocks__/testHelpers.ts +++ b/src/__mocks__/testHelpers.ts @@ -40,15 +40,22 @@ export class MockConfigurationProvider { * Setup the vscode.workspace.getConfiguration mock to return our values */ private setupVSCodeMock(): void { - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ - get: vi.fn((key: string, defaultValue?: unknown) => { - const value = this.config.get(key); - return value !== undefined ? value : defaultValue; - }), - has: vi.fn((key: string) => this.config.has(key)), - inspect: vi.fn(), - update: vi.fn(), - } as unknown as vscode.WorkspaceConfiguration); + vi.mocked(vscode.workspace.getConfiguration).mockImplementation( + (section?: string) => + ({ + get: vi.fn((key: string, defaultValue?: unknown) => { + const fullKey = section ? `${section}.${key}` : key; + const value = this.config.get(fullKey); + return value !== undefined ? value : defaultValue; + }), + has: vi.fn((key: string) => { + const fullKey = section ? `${section}.${key}` : key; + return this.config.has(fullKey); + }), + inspect: vi.fn(), + update: vi.fn(), + }) as unknown as vscode.WorkspaceConfiguration, + ); } } diff --git a/src/core/binaryManager.test.ts b/src/core/binaryManager.test.ts index 8530097a..9b0c68a4 100644 --- a/src/core/binaryManager.test.ts +++ b/src/core/binaryManager.test.ts @@ -136,7 +136,6 @@ describe("BinaryManager", () => { expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining("Unable to get version"), ); - // Should attempt to download now expect(mockAxios.get).toHaveBeenCalled(); expect(mockLogger.info).toHaveBeenCalledWith( @@ -150,6 +149,9 @@ describe("BinaryManager", () => { const result = await manager.fetchBinary(mockApi, "test"); expect(result).toBe(BINARY_PATH); expect(mockAxios.get).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + "No existing binary found, starting download", + ); expect(mockLogger.info).toHaveBeenCalledWith( "Downloaded binary version is", TEST_VERSION, @@ -221,21 +223,8 @@ describe("BinaryManager", () => { }); it("backs up existing binary before replacement", async () => { - // Setup existing old binary - vi.mocked(cli.stat).mockReset(); - vi.mocked(cli.stat) - .mockResolvedValueOnce({ size: 1024 } as fs.Stats) // Existing binary - .mockResolvedValueOnce({ size: 5242880 } as fs.Stats); // After download - vi.mocked(cli.version).mockReset(); - vi.mocked(cli.version) - .mockResolvedValueOnce("1.0.0") // Old version - .mockResolvedValueOnce(TEST_VERSION); // New version after download - - // Setup download - const stream = createMockStream(); - const writeStream = createMockWriteStream(); - withHttpResponse(200, { "content-length": "1024" }, stream); - vi.mocked(fs.createWriteStream).mockReturnValue(writeStream); + withExistingBinary("1.0.0"); + withSuccessfulDownload(); await manager.fetchBinary(mockApi, "test"); expect(fse.rename).toHaveBeenCalledWith( @@ -260,6 +249,9 @@ describe("BinaryManager", () => { withHttpResponse(304); const result = await manager.fetchBinary(mockApi, "test"); expect(result).toBe(BINARY_PATH); + expect(mockLogger.info).toHaveBeenCalledWith( + "Using existing binary since server returned a 304", + ); }); it("handles 404 platform not supported", async () => { @@ -268,14 +260,28 @@ describe("BinaryManager", () => { await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( "Platform not supported", ); - expect(vscode.env.openExternal).toHaveBeenCalled(); + expect(vscode.env.openExternal).toHaveBeenCalledWith( + expect.objectContaining({ + path: expect.stringContaining( + "github.com/coder/vscode-coder/issues/new?", + ), + }), + ); }); it("handles server errors", async () => { withHttpResponse(500); + mockUI.setResponse("Open an Issue"); await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( "Failed to download binary", ); + expect(vscode.env.openExternal).toHaveBeenCalledWith( + expect.objectContaining({ + path: expect.stringContaining( + "github.com/coder/vscode-coder/issues/new?", + ), + }), + ); }); }); @@ -336,35 +342,46 @@ describe("BinaryManager", () => { describe("Signature Verification", () => { it("verifies valid signatures", async () => { - withSuccessfulDownloadAndSignature(); - vi.mocked(pgp.verifySignature).mockResolvedValueOnce(); + withSuccessfulDownload(); + withSignatureResponses([200]); const result = await manager.fetchBinary(mockApi, "test"); expect(result).toBe(BINARY_PATH); expect(pgp.verifySignature).toHaveBeenCalled(); }); it("tries fallback signature on 404", async () => { - withBinaryDownload(); + withSuccessfulDownload(); withSignatureResponses([404, 200]); - vi.mocked(pgp.verifySignature).mockResolvedValueOnce(); mockUI.setResponse("Download signature"); const result = await manager.fetchBinary(mockApi, "test"); expect(result).toBe(BINARY_PATH); + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + "Signature not found", + expect.any(Object), + expect.any(String), + expect.any(String), + ); + // First download and when verfiying twice (404 then 200) expect(mockAxios.get).toHaveBeenCalledTimes(3); }); it("allows running despite invalid signature", async () => { - withSuccessfulDownloadAndSignature(); + withSuccessfulDownload(); + withSignatureResponses([200]); vi.mocked(pgp.verifySignature).mockRejectedValueOnce( createVerificationError("Invalid signature"), ); mockUI.setResponse("Run anyway"); const result = await manager.fetchBinary(mockApi, "test"); expect(result).toBe(BINARY_PATH); + expect(mockLogger.info).toHaveBeenCalledWith( + "Binary will be ran anyway at user request", + ); }); it("aborts on signature rejection", async () => { - withSuccessfulDownloadAndSignature(); + withSuccessfulDownload(); + withSignatureResponses([200]); vi.mocked(pgp.verifySignature).mockRejectedValueOnce( createVerificationError("Invalid signature"), ); @@ -380,19 +397,25 @@ describe("BinaryManager", () => { const result = await manager.fetchBinary(mockApi, "test"); expect(result).toBe(BINARY_PATH); expect(pgp.verifySignature).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + "Skipping binary signature verification due to settings", + ); }); it("allows skipping verification on 404", async () => { - withBinaryDownload(); + withSuccessfulDownload(); withHttpResponse(404); mockUI.setResponse("Run without verification"); const result = await manager.fetchBinary(mockApi, "test"); expect(result).toBe(BINARY_PATH); expect(pgp.verifySignature).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringMatching(/Signature download from (.+) declined/), + ); }); it("handles signature download failure", async () => { - withBinaryDownload(); + withSuccessfulDownload(); withHttpResponse(500); mockUI.setResponse("Run without verification"); const result = await manager.fetchBinary(mockApi, "test"); @@ -406,7 +429,7 @@ describe("BinaryManager", () => { }); it("aborts when user declines missing signature", async () => { - withBinaryDownload(); + withSuccessfulDownload(); withHttpResponse(404); mockUI.setResponse(undefined); // User cancels await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( @@ -482,11 +505,12 @@ describe("BinaryManager", () => { vi.mocked(cli.stat).mockReset(); vi.mocked(cli.stat).mockResolvedValueOnce({ size: 1024 } as fs.Stats); // Existing binary exists vi.mocked(cli.version).mockReset(); - vi.mocked(cli.version) - .mockRejectedValueOnce(new Error("corrupted")) // Existing binary is corrupted - .mockResolvedValueOnce(TEST_VERSION); // New download works + vi.mocked(cli.version).mockRejectedValueOnce(new Error("corrupted")); // Existing binary is corrupted } + /** + * Shouldn't reset mocks since this method is combined with other mocks. + */ function withSuccessfulDownload(opts?: { headers?: Record; }) { @@ -506,25 +530,6 @@ describe("BinaryManager", () => { vi.mocked(cli.version).mockResolvedValueOnce(TEST_VERSION); } - function withBinaryDownload() { - const stream = createMockStream(); - const writeStream = createMockWriteStream(); - withHttpResponse(200, { "content-length": "1024" }, stream); - vi.mocked(fs.createWriteStream).mockReturnValue(writeStream); - // Override to ensure no existing binary initially - vi.mocked(cli.stat).mockReset(); - vi.mocked(cli.stat) - .mockResolvedValueOnce(undefined) // No existing binary - .mockResolvedValueOnce({ size: 5242880 } as fs.Stats); // After download - vi.mocked(cli.version).mockReset(); - vi.mocked(cli.version).mockResolvedValueOnce(TEST_VERSION); - } - - function withSuccessfulDownloadAndSignature() { - withBinaryDownload(); - withHttpResponse(200, { "content-length": "256" }, createMockStream()); - } - function withSignatureResponses(statuses: number[]) { statuses.forEach((status) => { if (status === 200) { diff --git a/src/core/binaryManager.ts b/src/core/binaryManager.ts index 09317228..6722a447 100644 --- a/src/core/binaryManager.ts +++ b/src/core/binaryManager.ts @@ -35,11 +35,10 @@ export class BinaryManager { * downloads being disabled. */ public async fetchBinary(restClient: Api, label: string): Promise { + const cfg = vscode.workspace.getConfiguration("coder"); // Settings can be undefined when set to their defaults (true in this case), // so explicitly check against false. - const enableDownloads = - vscode.workspace.getConfiguration().get("coder.enableDownloads") !== - false; + const enableDownloads = cfg.get("enableDownloads") !== false; this.output.info("Downloads are", enableDownloads ? "enabled" : "disabled"); // Get the build info to compare with the existing binary version, if any, @@ -108,9 +107,7 @@ export class BinaryManager { // Figure out where to get the binary. const binName = cli.name(); - const configSource = vscode.workspace - .getConfiguration() - .get("coder.binarySource"); + const configSource = cfg.get("binarySource"); const binSource = configSource && String(configSource).trim().length > 0 ? String(configSource) @@ -138,11 +135,7 @@ export class BinaryManager { switch (status) { case 200: { - if ( - vscode.workspace - .getConfiguration() - .get("coder.disableSignatureVerification") - ) { + if (cfg.get("disableSignatureVerification")) { this.output.info( "Skipping binary signature verification due to settings", ); @@ -199,7 +192,6 @@ export class BinaryManager { vscode.window .showErrorMessage( "Coder isn't supported for your platform. Please open an issue, we'd love to support it!", - {}, "Open an Issue", ) .then((value) => { @@ -212,10 +204,10 @@ export class BinaryManager { title: `Support the \`${os}-${arch}\` platform`, body: `I'd like to use the \`${os}-${arch}\` architecture with the VS Code extension.`, }); - const url = vscode.Uri.parse( + const uri = vscode.Uri.parse( `https://github.com/coder/vscode-coder/issues/new?${params.toString()}`, ); - vscode.env.openExternal(url); + vscode.env.openExternal(uri); }); throw new Error("Platform not supported"); } @@ -223,7 +215,6 @@ export class BinaryManager { vscode.window .showErrorMessage( "Failed to download binary. Please open an issue.", - {}, "Open an Issue", ) .then((value) => { @@ -234,10 +225,10 @@ export class BinaryManager { title: `Failed to download binary on \`${cli.goos()}-${cli.goarch()}\``, body: `Received status code \`${status}\` when downloading the binary.`, }); - const url = vscode.Uri.parse( + const uri = vscode.Uri.parse( `https://github.com/coder/vscode-coder/issues/new?${params.toString()}`, ); - vscode.env.openExternal(url); + vscode.env.openExternal(uri); }); throw new Error("Failed to download binary"); } @@ -285,8 +276,9 @@ export class BinaryManager { const completed = await vscode.window.withProgress( { - title: `Downloading ${baseUrl}`, location: vscode.ProgressLocation.Notification, + title: `Downloading ${baseUrl}`, + cancellable: true, }, async (progress, token) => { const readStream = resp.data as IncomingMessage; From 784bb1fece2451b6c89438a6be210261aa82122f Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 24 Sep 2025 13:56:55 +0300 Subject: [PATCH 11/12] Review comments 1 --- src/__mocks__/testHelpers.ts | 42 +++++++++++++++------------------- src/core/binaryManager.test.ts | 39 +++++++++++++++---------------- src/error.test.ts | 1 - 3 files changed, 38 insertions(+), 44 deletions(-) diff --git a/src/__mocks__/testHelpers.ts b/src/__mocks__/testHelpers.ts index 2b23ec0f..57baf1b3 100644 --- a/src/__mocks__/testHelpers.ts +++ b/src/__mocks__/testHelpers.ts @@ -41,20 +41,27 @@ export class MockConfigurationProvider { */ private setupVSCodeMock(): void { vi.mocked(vscode.workspace.getConfiguration).mockImplementation( - (section?: string) => - ({ + (section?: string) => { + // Create a snapshot of the current config when getConfiguration is called + const snapshot = new Map(this.config); + const getFullKey = (part: string) => + section ? `${section}.${part}` : part; + + return { get: vi.fn((key: string, defaultValue?: unknown) => { - const fullKey = section ? `${section}.${key}` : key; - const value = this.config.get(fullKey); + const value = snapshot.get(getFullKey(key)); return value !== undefined ? value : defaultValue; }), has: vi.fn((key: string) => { - const fullKey = section ? `${section}.${key}` : key; - return this.config.has(fullKey); + return snapshot.has(getFullKey(key)); }), inspect: vi.fn(), - update: vi.fn(), - }) as unknown as vscode.WorkspaceConfiguration, + update: vi.fn((key: string, value: unknown) => { + this.config.set(getFullKey(key), value); + return Promise.resolve(); + }), + }; + }, ); } } @@ -139,21 +146,10 @@ export class MockUserInteraction { } /** - * Set a response for a specific message or set a default response + * Set a response for a specific message */ - setResponse(response: string | undefined): void; - setResponse(message: string, response: string | undefined): void; - setResponse( - messageOrResponse: string | undefined, - response?: string | undefined, - ): void { - if (response === undefined && messageOrResponse !== undefined) { - // Single argument - set default response - this.responses.set("default", messageOrResponse); - } else if (messageOrResponse !== undefined) { - // Two arguments - set specific response - this.responses.set(messageOrResponse, response); - } + setResponse(message: string, response: string | undefined): void { + this.responses.set(message, response); } /** @@ -182,7 +178,7 @@ export class MockUserInteraction { */ private setupVSCodeMock(): void { const getResponse = (message: string): string | undefined => { - return this.responses.get(message) ?? this.responses.get("default"); + return this.responses.get(message); }; vi.mocked(vscode.window.showErrorMessage).mockImplementation( diff --git a/src/core/binaryManager.test.ts b/src/core/binaryManager.test.ts index 9b0c68a4..e5157b5e 100644 --- a/src/core/binaryManager.test.ts +++ b/src/core/binaryManager.test.ts @@ -256,7 +256,10 @@ describe("BinaryManager", () => { it("handles 404 platform not supported", async () => { withHttpResponse(404); - mockUI.setResponse("Open an Issue"); + mockUI.setResponse( + "Coder isn't supported for your platform. Please open an issue, we'd love to support it!", + "Open an Issue", + ); await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( "Platform not supported", ); @@ -271,7 +274,10 @@ describe("BinaryManager", () => { it("handles server errors", async () => { withHttpResponse(500); - mockUI.setResponse("Open an Issue"); + mockUI.setResponse( + "Failed to download binary. Please open an issue.", + "Open an Issue", + ); await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( "Failed to download binary", ); @@ -352,16 +358,10 @@ describe("BinaryManager", () => { it("tries fallback signature on 404", async () => { withSuccessfulDownload(); withSignatureResponses([404, 200]); - mockUI.setResponse("Download signature"); + mockUI.setResponse("Signature not found", "Download signature"); const result = await manager.fetchBinary(mockApi, "test"); expect(result).toBe(BINARY_PATH); - expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( - "Signature not found", - expect.any(Object), - expect.any(String), - expect.any(String), - ); - // First download and when verfiying twice (404 then 200) + // First download and then verfiying twice (404 then 200) expect(mockAxios.get).toHaveBeenCalledTimes(3); }); @@ -371,7 +371,7 @@ describe("BinaryManager", () => { vi.mocked(pgp.verifySignature).mockRejectedValueOnce( createVerificationError("Invalid signature"), ); - mockUI.setResponse("Run anyway"); + mockUI.setResponse("Signature does not match", "Run anyway"); const result = await manager.fetchBinary(mockApi, "test"); expect(result).toBe(BINARY_PATH); expect(mockLogger.info).toHaveBeenCalledWith( @@ -385,7 +385,7 @@ describe("BinaryManager", () => { vi.mocked(pgp.verifySignature).mockRejectedValueOnce( createVerificationError("Invalid signature"), ); - mockUI.setResponse(undefined); + mockUI.setResponse("Signature does not match", undefined); await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( "Signature verification aborted", ); @@ -405,7 +405,7 @@ describe("BinaryManager", () => { it("allows skipping verification on 404", async () => { withSuccessfulDownload(); withHttpResponse(404); - mockUI.setResponse("Run without verification"); + mockUI.setResponse("Signature not found", "Run without verification"); const result = await manager.fetchBinary(mockApi, "test"); expect(result).toBe(BINARY_PATH); expect(pgp.verifySignature).not.toHaveBeenCalled(); @@ -417,21 +417,19 @@ describe("BinaryManager", () => { it("handles signature download failure", async () => { withSuccessfulDownload(); withHttpResponse(500); - mockUI.setResponse("Run without verification"); - const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); - expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + mockUI.setResponse( "Failed to download signature", - expect.any(Object), - "Download signature", // from the next source "Run without verification", ); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + // TODO test that a file was run? }); it("aborts when user declines missing signature", async () => { withSuccessfulDownload(); withHttpResponse(404); - mockUI.setResponse(undefined); // User cancels + mockUI.setResponse("Signature not found", undefined); // User cancels await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( "Signature download aborted", ); @@ -602,6 +600,7 @@ describe("BinaryManager", () => { pgp.VerificationErrorCode.Invalid, msg, ); + vi.mocked(error.summary).mockReturnValue("Signature does not match"); return error; } }); diff --git a/src/error.test.ts b/src/error.test.ts index d91d08b6..84c1e14b 100644 --- a/src/error.test.ts +++ b/src/error.test.ts @@ -19,7 +19,6 @@ describe("Certificate errors", () => { (process.versions.electron || process.env.ELECTRON_RUN_AS_NODE) && !process.env.VSCODE_PID; // Running from the test explorer in VS Code - // TODO: Remove the vscode mock once we revert the testing framework. beforeAll(() => { vi.mock("vscode", () => { return {}; From 2c674e4f3987681bd1bdbceea1542deef6f9420e Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 25 Sep 2025 11:32:15 +0300 Subject: [PATCH 12/12] Review comments 2 --- package.json | 2 +- src/__mocks__/testHelpers.ts | 59 ++ src/{cliManager.test.ts => cliUtils.test.ts} | 12 +- src/{cliManager.ts => cliUtils.ts} | 14 - src/commands.ts | 14 +- src/core/binaryManager.test.ts | 606 -------------- src/core/cliConfig.test.ts | 139 ---- src/core/cliConfig.ts | 81 -- src/core/cliManager.test.ts | 795 +++++++++++++++++++ src/core/{binaryManager.ts => cliManager.ts} | 83 +- src/core/mementoManager.test.ts | 26 +- src/core/pathResolver.test.ts | 26 - src/core/secretsManager.test.ts | 35 +- src/extension.ts | 14 +- src/remote.ts | 20 +- yarn.lock | 87 +- 16 files changed, 1016 insertions(+), 997 deletions(-) rename src/{cliManager.test.ts => cliUtils.test.ts} (95%) rename src/{cliManager.ts => cliUtils.ts} (92%) delete mode 100644 src/core/binaryManager.test.ts delete mode 100644 src/core/cliConfig.test.ts delete mode 100644 src/core/cliConfig.ts create mode 100644 src/core/cliManager.test.ts rename src/core/{binaryManager.ts => cliManager.ts} (86%) diff --git a/package.json b/package.json index c6335411..9fb96fcb 100644 --- a/package.json +++ b/package.json @@ -316,7 +316,6 @@ "eventsource": "^3.0.6", "find-process": "https://github.com/coder/find-process#fix/sequoia-compat", "jsonc-parser": "^3.3.1", - "memfs": "^4.17.1", "node-forge": "^1.3.1", "openpgp": "^6.2.0", "pretty-bytes": "^7.0.0", @@ -351,6 +350,7 @@ "eslint-plugin-prettier": "^5.4.1", "glob": "^10.4.2", "jsonc-eslint-parser": "^2.4.0", + "memfs": "^4.46.0", "nyc": "^17.1.0", "prettier": "^3.5.3", "ts-loader": "^9.5.1", diff --git a/src/__mocks__/testHelpers.ts b/src/__mocks__/testHelpers.ts index 57baf1b3..3a4ce407 100644 --- a/src/__mocks__/testHelpers.ts +++ b/src/__mocks__/testHelpers.ts @@ -213,3 +213,62 @@ export class MockUserInteraction { ); } } + +// Simple in-memory implementation of Memento +export class InMemoryMemento implements vscode.Memento { + private storage = new Map(); + + get(key: string): T | undefined; + get(key: string, defaultValue: T): T; + get(key: string, defaultValue?: T): T | undefined { + return this.storage.has(key) ? (this.storage.get(key) as T) : defaultValue; + } + + async update(key: string, value: unknown): Promise { + if (value === undefined) { + this.storage.delete(key); + } else { + this.storage.set(key, value); + } + return Promise.resolve(); + } + + keys(): readonly string[] { + return Array.from(this.storage.keys()); + } +} + +// Simple in-memory implementation of SecretStorage +export class InMemorySecretStorage implements vscode.SecretStorage { + private secrets = new Map(); + private isCorrupted = false; + + onDidChange: vscode.Event = () => ({ + dispose: () => {}, + }); + + async get(key: string): Promise { + if (this.isCorrupted) { + return Promise.reject(new Error("Storage corrupted")); + } + return this.secrets.get(key); + } + + async store(key: string, value: string): Promise { + if (this.isCorrupted) { + return Promise.reject(new Error("Storage corrupted")); + } + this.secrets.set(key, value); + } + + async delete(key: string): Promise { + if (this.isCorrupted) { + return Promise.reject(new Error("Storage corrupted")); + } + this.secrets.delete(key); + } + + corruptStorage(): void { + this.isCorrupted = true; + } +} diff --git a/src/cliManager.test.ts b/src/cliUtils.test.ts similarity index 95% rename from src/cliManager.test.ts rename to src/cliUtils.test.ts index 87540a61..aec78e87 100644 --- a/src/cliManager.test.ts +++ b/src/cliUtils.test.ts @@ -2,9 +2,9 @@ import fs from "fs/promises"; import os from "os"; import path from "path"; import { beforeAll, describe, expect, it } from "vitest"; -import * as cli from "./cliManager"; +import * as cli from "./cliUtils"; -describe("cliManager", () => { +describe("cliUtils", () => { const tmp = path.join(os.tmpdir(), "vscode-coder-tests"); beforeAll(async () => { @@ -25,14 +25,6 @@ describe("cliManager", () => { expect((await cli.stat(binPath))?.size).toBe(4); }); - it("rm", async () => { - const binPath = path.join(tmp, "rm"); - await cli.rm(binPath); - - await fs.writeFile(binPath, "test"); - await cli.rm(binPath); - }); - // TODO: CI only runs on Linux but we should run it on Windows too. it("version", async () => { const binPath = path.join(tmp, "version"); diff --git a/src/cliManager.ts b/src/cliUtils.ts similarity index 92% rename from src/cliManager.ts rename to src/cliUtils.ts index 60b63f92..cc92a345 100644 --- a/src/cliManager.ts +++ b/src/cliUtils.ts @@ -21,20 +21,6 @@ export async function stat(binPath: string): Promise { } } -/** - * Remove the path. Throw if unable to remove. - */ -export async function rm(binPath: string): Promise { - try { - await fs.rm(binPath, { force: true }); - } catch (error) { - // Just in case; we should never get an ENOENT because of force: true. - if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { - throw error; - } - } -} - // util.promisify types are dynamic so there is no concrete type we can import // and we have to make our own. type ExecException = ExecFileException & { stdout?: string; stderr?: string }; diff --git a/src/commands.ts b/src/commands.ts index a0538845..914adbfc 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -9,8 +9,7 @@ import * as vscode from "vscode"; import { createWorkspaceIdentifier, extractAgents } from "./api/api-helper"; import { CoderApi } from "./api/coderApi"; import { needToken } from "./api/utils"; -import { BinaryManager } from "./core/binaryManager"; -import { CliConfigManager } from "./core/cliConfig"; +import { CliManager } from "./core/cliManager"; import { MementoManager } from "./core/mementoManager"; import { PathResolver } from "./core/pathResolver"; import { SecretsManager } from "./core/secretsManager"; @@ -25,7 +24,6 @@ import { } from "./workspacesProvider"; export class Commands { - private readonly cliConfigManager: CliConfigManager; // These will only be populated when actively connected to a workspace and are // used in commands. Because commands can be executed by the user, it is not // possible to pass in arguments, so we have to store the current workspace @@ -44,10 +42,8 @@ export class Commands { private readonly pathResolver: PathResolver, private readonly mementoManager: MementoManager, private readonly secretsManager: SecretsManager, - private readonly binaryManager: BinaryManager, - ) { - this.cliConfigManager = new CliConfigManager(pathResolver); - } + private readonly cliManager: CliManager, + ) {} /** * Find the requested agent if specified, otherwise return the agent if there @@ -209,7 +205,7 @@ export class Commands { await this.secretsManager.setSessionToken(res.token); // Store on disk to be used by the cli. - await this.cliConfigManager.configure(label, url, res.token); + await this.cliManager.configure(label, url, res.token); // These contexts control various menu items and the sidebar. await vscode.commands.executeCommand( @@ -515,7 +511,7 @@ export class Commands { if (!url) { throw new Error("No coder url found for sidebar"); } - const binary = await this.binaryManager.fetchBinary( + const binary = await this.cliManager.fetchBinary( this.restClient, toSafeHost(url), ); diff --git a/src/core/binaryManager.test.ts b/src/core/binaryManager.test.ts deleted file mode 100644 index e5157b5e..00000000 --- a/src/core/binaryManager.test.ts +++ /dev/null @@ -1,606 +0,0 @@ -import globalAxios, { AxiosInstance } from "axios"; -import { Api } from "coder/site/src/api/api"; -import * as fs from "fs"; -import * as fse from "fs/promises"; -import { IncomingMessage } from "http"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import * as vscode from "vscode"; -import { - MockConfigurationProvider, - MockProgressReporter, - MockUserInteraction, -} from "../__mocks__/testHelpers"; -import * as cli from "../cliManager"; -import { Logger } from "../logging/logger"; -import * as pgp from "../pgp"; -import { BinaryManager } from "./binaryManager"; -import { PathResolver } from "./pathResolver"; - -// Mock all external modules -vi.mock("axios"); -vi.mock("fs/promises"); -vi.mock("fs"); -vi.mock("../cliManager"); -vi.mock("../pgp"); - -describe("BinaryManager", () => { - let manager: BinaryManager; - let mockLogger: Logger; - let mockConfig: MockConfigurationProvider; - let mockProgress: MockProgressReporter; - let mockUI: MockUserInteraction; - let mockApi: Api; - let mockAxios: AxiosInstance; - - const TEST_VERSION = "1.2.3"; - const TEST_URL = "https://test.coder.com"; - const BINARY_PATH = "/path/base/test/bin/coder"; - - beforeEach(() => { - vi.resetAllMocks(); - - // Core setup - mockLogger = createMockLogger(); - mockApi = createMockApi(TEST_VERSION, TEST_URL); - mockAxios = mockApi.getAxiosInstance(); - vi.mocked(globalAxios.create).mockReturnValue(mockAxios); - mockConfig = new MockConfigurationProvider(); - mockProgress = new MockProgressReporter(); - mockUI = new MockUserInteraction(); - manager = new BinaryManager( - mockLogger, - new PathResolver("/path/base", "/code/log"), - ); - - // Default mocks - most tests rely on these - vi.mocked(cli.name).mockReturnValue("coder"); - vi.mocked(cli.stat).mockResolvedValue(undefined); // No existing binary by default - vi.mocked(cli.rmOld).mockResolvedValue([]); - vi.mocked(cli.eTag).mockResolvedValue(""); - vi.mocked(cli.version).mockResolvedValue(TEST_VERSION); - vi.mocked(cli.goos).mockReturnValue("linux"); - vi.mocked(cli.goarch).mockReturnValue("amd64"); - vi.mocked(fse.mkdir).mockResolvedValue(undefined); - vi.mocked(fse.rename).mockResolvedValue(undefined); - vi.mocked(pgp.readPublicKeys).mockResolvedValue([]); - }); - - afterEach(() => { - mockProgress?.setCancellation(false); - vi.clearAllTimers(); - }); - - describe("Version Validation", () => { - it("rejects invalid server versions", async () => { - mockApi.getBuildInfo = vi.fn().mockResolvedValue({ version: "invalid" }); - await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( - "Got invalid version from deployment", - ); - }); - - it("accepts valid semver versions", async () => { - withExistingBinary(TEST_VERSION); - const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); - }); - }); - - describe("Existing Binary Handling", () => { - beforeEach(() => { - // Disable signature verification for these tests - mockConfig.set("coder.disableSignatureVerification", true); - }); - - it("reuses matching binary without downloading", async () => { - withExistingBinary(TEST_VERSION); - const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); - expect(mockAxios.get).not.toHaveBeenCalled(); - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining( - "Using existing binary since it matches the server version", - ), - ); - }); - - it("downloads when versions differ", async () => { - withExistingBinary("1.0.0"); - withSuccessfulDownload(); - const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); - expect(mockAxios.get).toHaveBeenCalled(); - expect(mockLogger.info).toHaveBeenCalledWith( - "Downloaded binary version is", - TEST_VERSION, - ); - }); - - it("keeps mismatched binary when downloads disabled", async () => { - mockConfig.set("coder.enableDownloads", false); - withExistingBinary("1.0.0"); - const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); - expect(mockAxios.get).not.toHaveBeenCalled(); - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining( - "Using existing binary even though it does not match the server version", - ), - ); - }); - - it("downloads fresh binary when corrupted", async () => { - withCorruptedBinary(); - withSuccessfulDownload(); - const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining("Unable to get version"), - ); - // Should attempt to download now - expect(mockAxios.get).toHaveBeenCalled(); - expect(mockLogger.info).toHaveBeenCalledWith( - "Downloaded binary version is", - TEST_VERSION, - ); - }); - - it("downloads when no binary exists", async () => { - withSuccessfulDownload(); - const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); - expect(mockAxios.get).toHaveBeenCalled(); - expect(mockLogger.info).toHaveBeenCalledWith( - "No existing binary found, starting download", - ); - expect(mockLogger.info).toHaveBeenCalledWith( - "Downloaded binary version is", - TEST_VERSION, - ); - }); - - it("fails when downloads disabled and no binary", async () => { - mockConfig.set("coder.enableDownloads", false); - await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( - "Unable to download CLI because downloads are disabled", - ); - }); - }); - - describe("Download Behavior", () => { - beforeEach(() => { - // Disable signature verification for download behavior tests - mockConfig.set("coder.disableSignatureVerification", true); - }); - - it("downloads with correct headers", async () => { - withSuccessfulDownload(); - await manager.fetchBinary(mockApi, "test"); - expect(mockAxios.get).toHaveBeenCalledWith( - "/bin/coder", - expect.objectContaining({ - responseType: "stream", - headers: expect.objectContaining({ - "Accept-Encoding": "gzip", - "If-None-Match": '""', - }), - }), - ); - }); - - it("uses custom binary source", async () => { - mockConfig.set("coder.binarySource", "/custom/path"); - withSuccessfulDownload(); - await manager.fetchBinary(mockApi, "test"); - expect(mockAxios.get).toHaveBeenCalledWith( - "/custom/path", - expect.any(Object), - ); - }); - - it("uses ETag for existing binaries", async () => { - withExistingBinary("1.0.0"); - vi.mocked(cli.eTag).mockResolvedValueOnce("abc123"); - withSuccessfulDownload(); - await manager.fetchBinary(mockApi, "test"); - expect(mockAxios.get).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - headers: expect.objectContaining({ "If-None-Match": '"abc123"' }), - }), - ); - }); - - it("cleans up old files before download", async () => { - vi.mocked(cli.rmOld).mockResolvedValueOnce([ - { fileName: "coder.old-xyz", error: undefined }, - { fileName: "coder.temp-abc", error: undefined }, - ]); - withSuccessfulDownload(); - await manager.fetchBinary(mockApi, "test"); - expect(cli.rmOld).toHaveBeenCalledWith(BINARY_PATH); - expect(mockLogger.info).toHaveBeenCalledWith("Removed", "coder.old-xyz"); - expect(mockLogger.info).toHaveBeenCalledWith("Removed", "coder.temp-abc"); - }); - - it("backs up existing binary before replacement", async () => { - withExistingBinary("1.0.0"); - withSuccessfulDownload(); - - await manager.fetchBinary(mockApi, "test"); - expect(fse.rename).toHaveBeenCalledWith( - BINARY_PATH, - expect.stringMatching(/\.old-[a-z0-9]+$/), - ); - expect(mockLogger.info).toHaveBeenCalledWith( - "Moving existing binary to", - expect.stringMatching(/\.old-[a-z0-9]+$/), - ); - }); - }); - - describe("HTTP Response Handling", () => { - beforeEach(() => { - // Disable signature verification for these tests - mockConfig.set("coder.disableSignatureVerification", true); - }); - - it("handles 304 Not Modified", async () => { - withExistingBinary("1.0.0"); - withHttpResponse(304); - const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); - expect(mockLogger.info).toHaveBeenCalledWith( - "Using existing binary since server returned a 304", - ); - }); - - it("handles 404 platform not supported", async () => { - withHttpResponse(404); - mockUI.setResponse( - "Coder isn't supported for your platform. Please open an issue, we'd love to support it!", - "Open an Issue", - ); - await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( - "Platform not supported", - ); - expect(vscode.env.openExternal).toHaveBeenCalledWith( - expect.objectContaining({ - path: expect.stringContaining( - "github.com/coder/vscode-coder/issues/new?", - ), - }), - ); - }); - - it("handles server errors", async () => { - withHttpResponse(500); - mockUI.setResponse( - "Failed to download binary. Please open an issue.", - "Open an Issue", - ); - await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( - "Failed to download binary", - ); - expect(vscode.env.openExternal).toHaveBeenCalledWith( - expect.objectContaining({ - path: expect.stringContaining( - "github.com/coder/vscode-coder/issues/new?", - ), - }), - ); - }); - }); - - describe("Stream Handling", () => { - beforeEach(() => { - // Disable signature verification for these tests - mockConfig.set("coder.disableSignatureVerification", true); - }); - - it("handles write stream errors", async () => { - withStreamError("write", "disk full"); - await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( - "Unable to download binary: disk full", - ); - }); - - it("handles read stream errors", async () => { - withStreamError("read", "network timeout"); - await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( - "Unable to download binary: network timeout", - ); - }); - - it("handles missing content-length", async () => { - withSuccessfulDownload({ headers: {} }); - const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); - expect(mockLogger.warn).toHaveBeenCalledWith( - "Got invalid or missing content length", - undefined, - ); - }); - }); - - describe("Progress Tracking", () => { - beforeEach(() => { - // Disable signature verification for these tests - mockConfig.set("coder.disableSignatureVerification", true); - }); - - it("shows download progress", async () => { - withSuccessfulDownload(); - await manager.fetchBinary(mockApi, "test"); - expect(vscode.window.withProgress).toHaveBeenCalledWith( - expect.objectContaining({ title: `Downloading ${TEST_URL}` }), - expect.any(Function), - ); - }); - - it("handles user cancellation", async () => { - mockProgress.setCancellation(true); - withSuccessfulDownload(); - await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( - "Download aborted", - ); - }); - }); - - describe("Signature Verification", () => { - it("verifies valid signatures", async () => { - withSuccessfulDownload(); - withSignatureResponses([200]); - const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); - expect(pgp.verifySignature).toHaveBeenCalled(); - }); - - it("tries fallback signature on 404", async () => { - withSuccessfulDownload(); - withSignatureResponses([404, 200]); - mockUI.setResponse("Signature not found", "Download signature"); - const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); - // First download and then verfiying twice (404 then 200) - expect(mockAxios.get).toHaveBeenCalledTimes(3); - }); - - it("allows running despite invalid signature", async () => { - withSuccessfulDownload(); - withSignatureResponses([200]); - vi.mocked(pgp.verifySignature).mockRejectedValueOnce( - createVerificationError("Invalid signature"), - ); - mockUI.setResponse("Signature does not match", "Run anyway"); - const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); - expect(mockLogger.info).toHaveBeenCalledWith( - "Binary will be ran anyway at user request", - ); - }); - - it("aborts on signature rejection", async () => { - withSuccessfulDownload(); - withSignatureResponses([200]); - vi.mocked(pgp.verifySignature).mockRejectedValueOnce( - createVerificationError("Invalid signature"), - ); - mockUI.setResponse("Signature does not match", undefined); - await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( - "Signature verification aborted", - ); - }); - - it("skips verification when disabled", async () => { - mockConfig.set("coder.disableSignatureVerification", true); - withSuccessfulDownload(); - const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); - expect(pgp.verifySignature).not.toHaveBeenCalled(); - expect(mockLogger.info).toHaveBeenCalledWith( - "Skipping binary signature verification due to settings", - ); - }); - - it("allows skipping verification on 404", async () => { - withSuccessfulDownload(); - withHttpResponse(404); - mockUI.setResponse("Signature not found", "Run without verification"); - const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); - expect(pgp.verifySignature).not.toHaveBeenCalled(); - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringMatching(/Signature download from (.+) declined/), - ); - }); - - it("handles signature download failure", async () => { - withSuccessfulDownload(); - withHttpResponse(500); - mockUI.setResponse( - "Failed to download signature", - "Run without verification", - ); - const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); - // TODO test that a file was run? - }); - - it("aborts when user declines missing signature", async () => { - withSuccessfulDownload(); - withHttpResponse(404); - mockUI.setResponse("Signature not found", undefined); // User cancels - await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( - "Signature download aborted", - ); - }); - }); - - describe("File System Operations", () => { - beforeEach(() => { - // Disable signature verification for these tests - mockConfig.set("coder.disableSignatureVerification", true); - }); - - it("creates binary directory", async () => { - withSuccessfulDownload(); - await manager.fetchBinary(mockApi, "test"); - expect(fse.mkdir).toHaveBeenCalledWith(expect.stringContaining("/bin"), { - recursive: true, - }); - }); - - it("validates downloaded binary version", async () => { - withSuccessfulDownload(); - await manager.fetchBinary(mockApi, "test"); - expect(cli.version).toHaveBeenCalledWith(BINARY_PATH); - expect(mockLogger.info).toHaveBeenCalledWith( - "Downloaded binary version is", - TEST_VERSION, - ); - }); - - it("logs file sizes for debugging", async () => { - withSuccessfulDownload(); - vi.mocked(cli.stat).mockResolvedValueOnce({ size: 5242880 } as fs.Stats); - await manager.fetchBinary(mockApi, "test"); - expect(mockLogger.info).toHaveBeenCalledWith( - "Downloaded binary size is", - "5.24 MB", - ); - }); - }); - - function createMockLogger(): Logger { - return { - trace: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }; - } - - function createMockApi(version: string, url: string): Api { - const axios = { - defaults: { baseURL: url }, - get: vi.fn(), - } as unknown as AxiosInstance; - return { - getBuildInfo: vi.fn().mockResolvedValue({ version }), - getAxiosInstance: () => axios, - } as unknown as Api; - } - - function withExistingBinary(version: string) { - vi.mocked(cli.stat).mockReset(); - vi.mocked(cli.stat).mockResolvedValueOnce({ size: 1024 } as fs.Stats); - vi.mocked(cli.version).mockReset(); - vi.mocked(cli.version).mockResolvedValueOnce(version); - } - - function withCorruptedBinary() { - vi.mocked(cli.stat).mockReset(); - vi.mocked(cli.stat).mockResolvedValueOnce({ size: 1024 } as fs.Stats); // Existing binary exists - vi.mocked(cli.version).mockReset(); - vi.mocked(cli.version).mockRejectedValueOnce(new Error("corrupted")); // Existing binary is corrupted - } - - /** - * Shouldn't reset mocks since this method is combined with other mocks. - */ - function withSuccessfulDownload(opts?: { - headers?: Record; - }) { - const stream = createMockStream(); - const writeStream = createMockWriteStream(); - withHttpResponse( - 200, - opts?.headers ?? { "content-length": "1024" }, - stream, - ); - vi.mocked(fs.createWriteStream).mockReturnValue(writeStream); - // Ensure no existing binary initially, then file exists after download - vi.mocked(cli.stat) - .mockResolvedValueOnce(undefined) // No existing binary - .mockResolvedValueOnce({ size: 5242880 } as fs.Stats); // After download - // Version check after download - vi.mocked(cli.version).mockResolvedValueOnce(TEST_VERSION); - } - - function withSignatureResponses(statuses: number[]) { - statuses.forEach((status) => { - if (status === 200) { - withHttpResponse(200, { "content-length": "256" }, createMockStream()); - } else { - withHttpResponse(status); - } - }); - } - - function withHttpResponse( - status: number, - headers: Record = {}, - data?: unknown, - ) { - vi.mocked(mockAxios.get).mockResolvedValueOnce({ - status, - headers, - data, - }); - } - - function withStreamError(type: "read" | "write", message: string) { - const writeStream = createMockWriteStream(); - const readStream = createMockStream(); - - if (type === "write") { - writeStream.on = vi.fn((event, callback) => { - if (event === "error") { - setTimeout(() => callback(new Error(message)), 5); - } - return writeStream; - }); - } else { - readStream.on = vi.fn((event, callback) => { - if (event === "error") { - setTimeout(() => callback(new Error(message)), 5); - } - return readStream; - }); - } - - withHttpResponse(200, { "content-length": "1024" }, readStream); - vi.mocked(fs.createWriteStream).mockReturnValue(writeStream); - } - - function createMockStream(): IncomingMessage { - return { - on: vi.fn((event: string, callback: (data: unknown) => void) => { - if (event === "data") { - setTimeout(() => callback(Buffer.from("mock")), 0); - } else if (event === "close") { - setTimeout(callback, 10); - } - }), - destroy: vi.fn(), - } as unknown as IncomingMessage; - } - - function createMockWriteStream(): fs.WriteStream { - return { - on: vi.fn().mockReturnThis(), - write: vi.fn((_: Buffer, cb?: () => void) => cb?.()), - close: vi.fn(), - } as unknown as fs.WriteStream; - } - - function createVerificationError(msg: string): pgp.VerificationError { - const error = new pgp.VerificationError( - pgp.VerificationErrorCode.Invalid, - msg, - ); - vi.mocked(error.summary).mockReturnValue("Signature does not match"); - return error; - } -}); diff --git a/src/core/cliConfig.test.ts b/src/core/cliConfig.test.ts deleted file mode 100644 index 86a32777..00000000 --- a/src/core/cliConfig.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import fs from "fs/promises"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { CliConfigManager } from "./cliConfig"; -import { PathResolver } from "./pathResolver"; - -vi.mock("fs/promises"); - -describe("CliConfigManager", () => { - let pathResolver: PathResolver; - let cliConfigManager: CliConfigManager; - const mockFs = vi.mocked(fs); - const writtenFiles = new Map(); - - beforeEach(() => { - vi.resetAllMocks(); - writtenFiles.clear(); - pathResolver = new PathResolver("/test/base", "/test/log"); - cliConfigManager = new CliConfigManager(pathResolver); - - mockFs.mkdir.mockResolvedValue(undefined); - mockFs.writeFile.mockImplementation(async (path, content) => { - writtenFiles.set(path.toString(), content.toString()); - return Promise.resolve(); - }); - }); - - describe("configure", () => { - it("should write both url and token to correct paths", async () => { - await cliConfigManager.configure( - "deployment", - "https://coder.example.com", - "test-token", - ); - - expect([...writtenFiles.entries()]).toEqual([ - ["/test/base/deployment/url", "https://coder.example.com"], - ["/test/base/deployment/session", "test-token"], - ]); - }); - - it("should skip URL when undefined but write token", async () => { - await cliConfigManager.configure("deployment", undefined, "test-token"); - - // No entry for the url - expect([...writtenFiles.entries()]).toEqual([ - ["/test/base/deployment/session", "test-token"], - ]); - }); - - it("should skip token when null but write URL", async () => { - await cliConfigManager.configure( - "deployment", - "https://coder.example.com", - null, - ); - - // No entry for the session - expect([...writtenFiles.entries()]).toEqual([ - ["/test/base/deployment/url", "https://coder.example.com"], - ]); - }); - - it("should write empty string for token when provided", async () => { - await cliConfigManager.configure( - "deployment", - "https://coder.example.com", - "", - ); - - expect([...writtenFiles.entries()]).toEqual([ - ["/test/base/deployment/url", "https://coder.example.com"], - ["/test/base/deployment/session", ""], - ]); - }); - - it("should use base path directly when label is empty", async () => { - await cliConfigManager.configure( - "", - "https://coder.example.com", - "token", - ); - - expect([...writtenFiles.entries()]).toEqual([ - ["/test/base/url", "https://coder.example.com"], - ["/test/base/session", "token"], - ]); - }); - }); - - describe("readConfig", () => { - beforeEach(() => { - mockFs.readFile.mockImplementation(async (filePath) => { - const path = filePath.toString(); - if (writtenFiles.has(path)) { - return writtenFiles.get(path)!; - } - return Promise.reject(new Error("ENOENT: no such file or directory")); - }); - }); - - it("should read and trim stored configuration", async () => { - writtenFiles.set( - "/test/base/deployment/url", - " https://coder.example.com \n", - ); - writtenFiles.set("/test/base/deployment/session", "\t test-token \r\n"); - - const result = await cliConfigManager.readConfig("deployment"); - - expect(result).toEqual({ - url: "https://coder.example.com", - token: "test-token", - }); - }); - - it("should return empty strings for missing files", async () => { - const result = await cliConfigManager.readConfig("deployment"); - - expect(result).toEqual({ - url: "", - token: "", - }); - }); - - it("should handle partial configuration", async () => { - writtenFiles.set( - "/test/base/deployment/url", - "https://coder.example.com", - ); - - const result = await cliConfigManager.readConfig("deployment"); - - expect(result).toEqual({ - url: "https://coder.example.com", - token: "", - }); - }); - }); -}); diff --git a/src/core/cliConfig.ts b/src/core/cliConfig.ts deleted file mode 100644 index 2dd8b694..00000000 --- a/src/core/cliConfig.ts +++ /dev/null @@ -1,81 +0,0 @@ -import fs from "fs/promises"; -import path from "path"; -import { PathResolver } from "./pathResolver"; - -export class CliConfigManager { - constructor(private readonly pathResolver: PathResolver) {} - - /** - * Configure the CLI for the deployment with the provided label. - * - * Falsey URLs and null tokens are a no-op; we avoid unconfiguring the CLI to - * avoid breaking existing connections. - */ - public async configure( - label: string, - url: string | undefined, - token: string | null, - ) { - await Promise.all([ - this.updateUrlForCli(label, url), - this.updateTokenForCli(label, token), - ]); - } - - /** - * Update the URL for the deployment with the provided label on disk which can - * be used by the CLI via --url-file. If the URL is falsey, do nothing. - * - * If the label is empty, read the old deployment-unaware config instead. - */ - private async updateUrlForCli( - label: string, - url: string | undefined, - ): Promise { - if (url) { - const urlPath = this.pathResolver.getUrlPath(label); - await fs.mkdir(path.dirname(urlPath), { recursive: true }); - await fs.writeFile(urlPath, url); - } - } - - /** - * Update the session token for a deployment with the provided label on disk - * which can be used by the CLI via --session-token-file. If the token is - * null, do nothing. - * - * If the label is empty, read the old deployment-unaware config instead. - */ - private async updateTokenForCli( - label: string, - token: string | undefined | null, - ) { - if (token !== null) { - const tokenPath = this.pathResolver.getSessionTokenPath(label); - await fs.mkdir(path.dirname(tokenPath), { recursive: true }); - await fs.writeFile(tokenPath, token ?? ""); - } - } - - /** - * Read the CLI config for a deployment with the provided label. - * - * IF a config file does not exist, return an empty string. - * - * If the label is empty, read the old deployment-unaware config. - */ - public async readConfig( - label: string, - ): Promise<{ url: string; token: string }> { - const urlPath = this.pathResolver.getUrlPath(label); - const tokenPath = this.pathResolver.getSessionTokenPath(label); - const [url, token] = await Promise.allSettled([ - fs.readFile(urlPath, "utf8"), - fs.readFile(tokenPath, "utf8"), - ]); - return { - url: url.status === "fulfilled" ? url.value.trim() : "", - token: token.status === "fulfilled" ? token.value.trim() : "", - }; - } -} diff --git a/src/core/cliManager.test.ts b/src/core/cliManager.test.ts new file mode 100644 index 00000000..676de44c --- /dev/null +++ b/src/core/cliManager.test.ts @@ -0,0 +1,795 @@ +import globalAxios, { AxiosInstance } from "axios"; +import { Api } from "coder/site/src/api/api"; +import EventEmitter from "events"; +import * as fs from "fs"; +import { IncomingMessage } from "http"; +import { fs as memfs, vol } from "memfs"; +import * as os from "os"; +import * as path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; +import { + MockConfigurationProvider, + MockProgressReporter, + MockUserInteraction, +} from "../__mocks__/testHelpers"; +import * as cli from "../cliUtils"; +import { Logger } from "../logging/logger"; +import * as pgp from "../pgp"; +import { CliManager } from "./cliManager"; +import { PathResolver } from "./pathResolver"; + +vi.mock("os"); +vi.mock("axios"); +vi.mock("../pgp"); + +vi.mock("fs", async () => { + const memfs: { fs: typeof fs } = await vi.importActual("memfs"); + return { + ...memfs.fs, + default: memfs.fs, + }; +}); + +vi.mock("fs/promises", async () => { + const memfs: { fs: typeof fs } = await vi.importActual("memfs"); + return { + ...memfs.fs.promises, + default: memfs.fs.promises, + }; +}); + +// Only mock the platform detection functions from CLI manager +vi.mock("../cliUtils", async () => { + const actual = + await vi.importActual("../cliUtils"); + return { + ...actual, + // No need to test script execution here + version: vi.fn(), + }; +}); + +describe("CliManager", () => { + let manager: CliManager; + let mockConfig: MockConfigurationProvider; + let mockProgress: MockProgressReporter; + let mockUI: MockUserInteraction; + let mockApi: Api; + let mockAxios: AxiosInstance; + + const TEST_VERSION = "1.2.3"; + const TEST_URL = "https://test.coder.com"; + const BASE_PATH = "/path/base"; + const BINARY_DIR = `${BASE_PATH}/test/bin`; + const PLATFORM = "linux"; + const ARCH = "amd64"; + const BINARY_NAME = `coder-${PLATFORM}-${ARCH}`; + const BINARY_PATH = `${BINARY_DIR}/${BINARY_NAME}`; + + beforeEach(() => { + vi.resetAllMocks(); + vol.reset(); + + // Core setup + mockApi = createMockApi(TEST_VERSION, TEST_URL); + mockAxios = mockApi.getAxiosInstance(); + vi.mocked(globalAxios.create).mockReturnValue(mockAxios); + mockConfig = new MockConfigurationProvider(); + mockProgress = new MockProgressReporter(); + mockUI = new MockUserInteraction(); + manager = new CliManager( + vscode, + createMockLogger(), + new PathResolver(BASE_PATH, "/code/log"), + ); + + // Mock only what's necessary + vi.mocked(os.platform).mockReturnValue(PLATFORM); + vi.mocked(os.arch).mockReturnValue(ARCH); + vi.mocked(pgp.readPublicKeys).mockResolvedValue([]); + }); + + afterEach(async () => { + mockProgress?.setCancellation(false); + vi.clearAllTimers(); + // memfs internally schedules some FS operations so we have to wait for them to finish + await new Promise((resolve) => setImmediate(resolve)); + vol.reset(); + }); + + describe("Configure CLI", () => { + it("should write both url and token to correct paths", async () => { + await manager.configure( + "deployment", + "https://coder.example.com", + "test-token", + ); + + expect(memfs.readFileSync("/path/base/deployment/url", "utf8")).toBe( + "https://coder.example.com", + ); + expect(memfs.readFileSync("/path/base/deployment/session", "utf8")).toBe( + "test-token", + ); + }); + + it("should skip URL when undefined but write token", async () => { + await manager.configure("deployment", undefined, "test-token"); + + // No entry for the url + expect(memfs.existsSync("/path/base/deployment/url")).toBe(false); + expect(memfs.readFileSync("/path/base/deployment/session", "utf8")).toBe( + "test-token", + ); + }); + + it("should skip token when null but write URL", async () => { + await manager.configure("deployment", "https://coder.example.com", null); + + // No entry for the session + expect(memfs.readFileSync("/path/base/deployment/url", "utf8")).toBe( + "https://coder.example.com", + ); + expect(memfs.existsSync("/path/base/deployment/session")).toBe(false); + }); + + it("should write empty string for token when provided", async () => { + await manager.configure("deployment", "https://coder.example.com", ""); + + expect(memfs.readFileSync("/path/base/deployment/url", "utf8")).toBe( + "https://coder.example.com", + ); + expect(memfs.readFileSync("/path/base/deployment/session", "utf8")).toBe( + "", + ); + }); + + it("should use base path directly when label is empty", async () => { + await manager.configure("", "https://coder.example.com", "token"); + + expect(memfs.readFileSync("/path/base/url", "utf8")).toBe( + "https://coder.example.com", + ); + expect(memfs.readFileSync("/path/base/session", "utf8")).toBe("token"); + }); + }); + + describe("Read CLI Configuration", () => { + it("should read and trim stored configuration", async () => { + // Create directories and write files with whitespace + vol.mkdirSync("/path/base/deployment", { recursive: true }); + memfs.writeFileSync( + "/path/base/deployment/url", + " https://coder.example.com \n", + ); + memfs.writeFileSync( + "/path/base/deployment/session", + "\t test-token \r\n", + ); + + const result = await manager.readConfig("deployment"); + + expect(result).toEqual({ + url: "https://coder.example.com", + token: "test-token", + }); + }); + + it("should return empty strings for missing files", async () => { + const result = await manager.readConfig("deployment"); + + expect(result).toEqual({ + url: "", + token: "", + }); + }); + + it("should handle partial configuration", async () => { + vol.mkdirSync("/path/base/deployment", { recursive: true }); + memfs.writeFileSync( + "/path/base/deployment/url", + "https://coder.example.com", + ); + + const result = await manager.readConfig("deployment"); + + expect(result).toEqual({ + url: "https://coder.example.com", + token: "", + }); + }); + }); + + describe("Binary Version Validation", () => { + it("rejects invalid server versions", async () => { + mockApi.getBuildInfo = vi.fn().mockResolvedValue({ version: "invalid" }); + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Got invalid version from deployment", + ); + }); + + it("accepts valid semver versions", async () => { + withExistingBinary(TEST_VERSION); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + }); + }); + + describe("Existing Binary Handling", () => { + beforeEach(() => { + // Disable signature verification for these tests + mockConfig.set("coder.disableSignatureVerification", true); + }); + + it("reuses matching binary without downloading", async () => { + withExistingBinary(TEST_VERSION); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(mockAxios.get).not.toHaveBeenCalled(); + // Verify binary still exists + expect(memfs.existsSync(BINARY_PATH)).toBe(true); + }); + + it("downloads when versions differ", async () => { + withExistingBinary("1.0.0"); + withSuccessfulDownload(); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(mockAxios.get).toHaveBeenCalled(); + // Verify new binary exists + expect(memfs.existsSync(BINARY_PATH)).toBe(true); + expect(memfs.readFileSync(BINARY_PATH).toString()).toBe( + mockBinaryContent(TEST_VERSION), + ); + }); + + it("keeps mismatched binary when downloads disabled", async () => { + mockConfig.set("coder.enableDownloads", false); + withExistingBinary("1.0.0"); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(mockAxios.get).not.toHaveBeenCalled(); + // Should still have the old version + expect(memfs.existsSync(BINARY_PATH)).toBe(true); + expect(memfs.readFileSync(BINARY_PATH).toString()).toBe( + mockBinaryContent("1.0.0"), + ); + }); + + it("downloads fresh binary when corrupted", async () => { + withCorruptedBinary(); + withSuccessfulDownload(); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(mockAxios.get).toHaveBeenCalled(); + expect(memfs.existsSync(BINARY_PATH)).toBe(true); + expect(memfs.readFileSync(BINARY_PATH).toString()).toBe( + mockBinaryContent(TEST_VERSION), + ); + }); + + it("downloads when no binary exists", async () => { + // Ensure directory doesn't exist initially + expect(memfs.existsSync(BINARY_DIR)).toBe(false); + + withSuccessfulDownload(); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(mockAxios.get).toHaveBeenCalled(); + + // Verify directory was created and binary exists + expect(memfs.existsSync(BINARY_DIR)).toBe(true); + expect(memfs.existsSync(BINARY_PATH)).toBe(true); + expect(memfs.readFileSync(BINARY_PATH).toString()).toBe( + mockBinaryContent(TEST_VERSION), + ); + }); + + it("fails when downloads disabled and no binary", async () => { + mockConfig.set("coder.enableDownloads", false); + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Unable to download CLI because downloads are disabled", + ); + expect(memfs.existsSync(BINARY_PATH)).toBe(false); + }); + }); + + describe("Binary Download Behavior", () => { + beforeEach(() => { + mockConfig.set("coder.disableSignatureVerification", true); + }); + + it("downloads with correct headers", async () => { + withSuccessfulDownload(); + await manager.fetchBinary(mockApi, "test"); + expect(mockAxios.get).toHaveBeenCalledWith( + `/bin/${BINARY_NAME}`, + expect.objectContaining({ + responseType: "stream", + headers: expect.objectContaining({ + "Accept-Encoding": "gzip", + "If-None-Match": '""', + }), + }), + ); + }); + + it("uses custom binary source", async () => { + mockConfig.set("coder.binarySource", "/custom/path"); + withSuccessfulDownload(); + await manager.fetchBinary(mockApi, "test"); + expect(mockAxios.get).toHaveBeenCalledWith( + "/custom/path", + expect.objectContaining({ + responseType: "stream", + decompress: true, + validateStatus: expect.any(Function), + }), + ); + }); + + it("uses ETag for existing binaries", async () => { + withExistingBinary("1.0.0"); + withSuccessfulDownload(); + await manager.fetchBinary(mockApi, "test"); + + // Verify ETag was computed from actual file content + expect(mockAxios.get).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + "If-None-Match": '"0c95a175da8afefd2b52057908a2e30ba2e959b3"', + }), + }), + ); + }); + + it("cleans up old files before download", async () => { + // Create old temporary files and signature files + vol.mkdirSync(BINARY_DIR, { recursive: true }); + memfs.writeFileSync(path.join(BINARY_DIR, "coder.old-xyz"), "old"); + memfs.writeFileSync(path.join(BINARY_DIR, "coder.temp-abc"), "temp"); + memfs.writeFileSync(path.join(BINARY_DIR, "coder.asc"), "signature"); + memfs.writeFileSync(path.join(BINARY_DIR, "keeper.txt"), "keep"); + + withSuccessfulDownload(); + await manager.fetchBinary(mockApi, "test"); + + // Verify old files were actually removed but other files kept + expect(memfs.existsSync(path.join(BINARY_DIR, "coder.old-xyz"))).toBe( + false, + ); + expect(memfs.existsSync(path.join(BINARY_DIR, "coder.temp-abc"))).toBe( + false, + ); + expect(memfs.existsSync(path.join(BINARY_DIR, "coder.asc"))).toBe(false); + expect(memfs.existsSync(path.join(BINARY_DIR, "keeper.txt"))).toBe(true); + }); + + it("moves existing binary to backup file before writing new version", async () => { + withExistingBinary("1.0.0"); + withSuccessfulDownload(); + + await manager.fetchBinary(mockApi, "test"); + + // Verify the old binary was backed up + const files = readdir(BINARY_DIR); + const backupFile = files.find( + (f) => f.startsWith(BINARY_NAME) && f.match(/\.old-[a-z0-9]+$/), + ); + expect(backupFile).toBeDefined(); + }); + }); + + describe("Download HTTP Response Handling", () => { + beforeEach(() => { + mockConfig.set("coder.disableSignatureVerification", true); + }); + + it("handles 304 Not Modified", async () => { + withExistingBinary("1.0.0"); + withHttpResponse(304); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + // No change + expect(memfs.readFileSync(BINARY_PATH).toString()).toBe( + mockBinaryContent("1.0.0"), + ); + }); + + it("handles 404 platform not supported", async () => { + withHttpResponse(404); + mockUI.setResponse( + "Coder isn't supported for your platform. Please open an issue, we'd love to support it!", + "Open an Issue", + ); + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Platform not supported", + ); + expect(vscode.env.openExternal).toHaveBeenCalledWith( + expect.objectContaining({ + path: expect.stringContaining( + "github.com/coder/vscode-coder/issues/new?", + ), + }), + ); + }); + + it("handles server errors", async () => { + withHttpResponse(500); + mockUI.setResponse( + "Failed to download binary. Please open an issue.", + "Open an Issue", + ); + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Failed to download binary", + ); + expect(vscode.env.openExternal).toHaveBeenCalledWith( + expect.objectContaining({ + path: expect.stringContaining( + "github.com/coder/vscode-coder/issues/new?", + ), + }), + ); + }); + }); + + describe("Download Stream Handling", () => { + beforeEach(() => { + mockConfig.set("coder.disableSignatureVerification", true); + }); + + it("handles write stream errors", async () => { + withStreamError("write", "disk full"); + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Unable to download binary: disk full", + ); + expect(memfs.existsSync(BINARY_PATH)).toBe(false); + }); + + it("handles read stream errors", async () => { + withStreamError("read", "network timeout"); + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Unable to download binary: network timeout", + ); + expect(memfs.existsSync(BINARY_PATH)).toBe(false); + }); + + it("handles missing content-length", async () => { + withSuccessfulDownload({ headers: {} }); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(memfs.existsSync(BINARY_PATH)).toBe(true); + }); + }); + + describe("Download Progress Tracking", () => { + beforeEach(() => { + mockConfig.set("coder.disableSignatureVerification", true); + }); + + it("shows download progress", async () => { + withSuccessfulDownload(); + await manager.fetchBinary(mockApi, "test"); + expect(vscode.window.withProgress).toHaveBeenCalledWith( + expect.objectContaining({ title: `Downloading ${TEST_URL}` }), + expect.any(Function), + ); + }); + + it("handles user cancellation", async () => { + mockProgress.setCancellation(true); + withSuccessfulDownload(); + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Download aborted", + ); + expect(memfs.existsSync(BINARY_PATH)).toBe(false); + }); + }); + + describe("Binary Signature Verification", () => { + it("verifies valid signatures", async () => { + withSuccessfulDownload(); + withSignatureResponses([200]); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(pgp.verifySignature).toHaveBeenCalled(); + const sigFile = expectFileInDir(BINARY_DIR, ".asc"); + expect(sigFile).toBeDefined(); + }); + + it("tries fallback signature on 404", async () => { + withSuccessfulDownload(); + withSignatureResponses([404, 200]); + mockUI.setResponse("Signature not found", "Download signature"); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(mockAxios.get).toHaveBeenCalledTimes(3); + const sigFile = expectFileInDir(BINARY_DIR, ".asc"); + expect(sigFile).toBeDefined(); + }); + + it("allows running despite invalid signature", async () => { + withSuccessfulDownload(); + withSignatureResponses([200]); + vi.mocked(pgp.verifySignature).mockRejectedValueOnce( + createVerificationError("Invalid signature"), + ); + mockUI.setResponse("Signature does not match", "Run anyway"); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(memfs.existsSync(BINARY_PATH)).toBe(true); + }); + + it("aborts on signature rejection", async () => { + withSuccessfulDownload(); + withSignatureResponses([200]); + vi.mocked(pgp.verifySignature).mockRejectedValueOnce( + createVerificationError("Invalid signature"), + ); + mockUI.setResponse("Signature does not match", undefined); + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Signature verification aborted", + ); + }); + + it("skips verification when disabled", async () => { + mockConfig.set("coder.disableSignatureVerification", true); + withSuccessfulDownload(); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(pgp.verifySignature).not.toHaveBeenCalled(); + const files = readdir(BINARY_DIR); + expect(files.find((file) => file.includes(".asc"))).toBeUndefined(); + }); + + it.each([ + [404, "Signature not found"], + [500, "Failed to download signature"], + ])("allows skipping verification on %i", async (status, message) => { + withSuccessfulDownload(); + withHttpResponse(status); + mockUI.setResponse(message, "Run without verification"); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(pgp.verifySignature).not.toHaveBeenCalled(); + }); + + it.each([ + [404, "Signature not found"], + [500, "Failed to download signature"], + ])( + "aborts when user declines missing signature on %i", + async (status, message) => { + withSuccessfulDownload(); + withHttpResponse(status); + mockUI.setResponse(message, undefined); // User cancels + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Signature download aborted", + ); + }, + ); + }); + + describe("File System Operations", () => { + beforeEach(() => { + mockConfig.set("coder.disableSignatureVerification", true); + }); + + it("creates binary directory", async () => { + expect(memfs.existsSync(BINARY_DIR)).toBe(false); + withSuccessfulDownload(); + await manager.fetchBinary(mockApi, "test"); + expect(memfs.existsSync(BINARY_DIR)).toBe(true); + const stats = memfs.statSync(BINARY_DIR); + expect(stats.isDirectory()).toBe(true); + }); + + it("validates downloaded binary version", async () => { + withSuccessfulDownload(); + await manager.fetchBinary(mockApi, "test"); + expect(memfs.readFileSync(BINARY_PATH).toString()).toBe( + mockBinaryContent(TEST_VERSION), + ); + }); + + it("sets correct file permissions", async () => { + withSuccessfulDownload(); + await manager.fetchBinary(mockApi, "test"); + const stats = memfs.statSync(BINARY_PATH); + expect(stats.mode & 0o777).toBe(0o755); + }); + }); + + describe("Path Pecularities", () => { + beforeEach(() => { + mockConfig.set("coder.disableSignatureVerification", true); + }); + + it("handles binary with spaces in path", async () => { + const pathWithSpaces = "/path with spaces/bin"; + const resolver = new PathResolver(pathWithSpaces, "/log"); + const manager = new CliManager(vscode, createMockLogger(), resolver); + + withSuccessfulDownload(); + const result = await manager.fetchBinary(mockApi, "test label"); + expect(result).toBe(`${pathWithSpaces}/test label/bin/${BINARY_NAME}`); + }); + + it("handles empty deployment label", async () => { + withExistingBinary(TEST_VERSION, "/path/base/bin"); + const result = await manager.fetchBinary(mockApi, ""); + expect(result).toBe(path.join(BASE_PATH, "bin", BINARY_NAME)); + }); + }); + + function createMockLogger(): Logger { + return { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + } + + function createMockApi(version: string, url: string): Api { + const axios = { + defaults: { baseURL: url }, + get: vi.fn(), + } as unknown as AxiosInstance; + return { + getBuildInfo: vi.fn().mockResolvedValue({ version }), + getAxiosInstance: () => axios, + } as unknown as Api; + } + + function withExistingBinary(version: string, dir: string = BINARY_DIR) { + vol.mkdirSync(dir, { recursive: true }); + memfs.writeFileSync(`${dir}/${BINARY_NAME}`, mockBinaryContent(version), { + mode: 0o755, + }); + + // Mock version to return the specified version + vi.mocked(cli.version).mockResolvedValueOnce(version); + } + + function withCorruptedBinary() { + vol.mkdirSync(BINARY_DIR, { recursive: true }); + memfs.writeFileSync(BINARY_PATH, "corrupted-binary-content", { + mode: 0o755, + }); + + // Mock version to fail + vi.mocked(cli.version).mockRejectedValueOnce(new Error("corrupted")); + } + + function withSuccessfulDownload(opts?: { + headers?: Record; + }) { + const stream = createMockStream(mockBinaryContent(TEST_VERSION)); + withHttpResponse( + 200, + opts?.headers ?? { "content-length": "1024" }, + stream, + ); + + // Mock version to return TEST_VERSION after download + vi.mocked(cli.version).mockResolvedValue(TEST_VERSION); + } + + function withSignatureResponses(statuses: number[]): void { + statuses.forEach((status) => { + const data = + status === 200 ? createMockStream("mock-signature-content") : undefined; + withHttpResponse(status, {}, data); + }); + } + + function withHttpResponse( + status: number, + headers: Record = {}, + data?: unknown, + ) { + vi.mocked(mockAxios.get).mockResolvedValueOnce({ + status, + headers, + data, + }); + } + + function withStreamError(type: "read" | "write", message: string) { + if (type === "write") { + vi.spyOn(fs, "createWriteStream").mockImplementation(() => { + const stream = new EventEmitter(); + (stream as unknown as fs.WriteStream).write = vi.fn(); + (stream as unknown as fs.WriteStream).close = vi.fn(); + // Emit error on next tick after stream is returned + setImmediate(() => { + stream.emit("error", new Error(message)); + }); + + return stream as ReturnType; + }); + + // Provide a normal read stream + withHttpResponse( + 200, + { "content-length": "256" }, + createMockStream("data"), + ); + } else { + // Create a read stream that emits error + const errorStream = { + on: vi.fn((event: string, callback: (...args: unknown[]) => void) => { + if (event === "error") { + setImmediate(() => callback(new Error(message))); + } + return errorStream; + }), + destroy: vi.fn(), + } as unknown as IncomingMessage; + + withHttpResponse(200, { "content-length": "1024" }, errorStream); + } + } + + function createMockStream( + content: string, + options: { chunkSize?: number; delay?: number } = {}, + ): IncomingMessage { + const { chunkSize = 8, delay = 0 } = options; + + const buffer = Buffer.from(content); + let position = 0; + + return { + on: vi.fn((event: string, callback: (...args: unknown[]) => void) => { + if (event === "data") { + // Send data in chunks + const sendChunk = () => { + if (position < buffer.length) { + const chunk = buffer.subarray( + position, + Math.min(position + chunkSize, buffer.length), + ); + position += chunkSize; + callback(chunk); + if (position < buffer.length) { + setTimeout(sendChunk, delay); + } + } + }; + setTimeout(sendChunk, delay); + } else if (event === "close") { + // Just close after a delay + setTimeout(() => callback(), 10); + } + }), + destroy: vi.fn(), + } as unknown as IncomingMessage; + } + + function createVerificationError(msg: string): pgp.VerificationError { + const error = new pgp.VerificationError( + pgp.VerificationErrorCode.Invalid, + msg, + ); + vi.mocked(error.summary).mockReturnValue("Signature does not match"); + return error; + } + + function mockBinaryContent(version: string): string { + return `mock-binary-v${version}`; + } + + function expectFileInDir(dir: string, pattern: string): string | undefined { + const files = readdir(dir); + return files.find((f) => f.includes(pattern)); + } + + function readdir(dir: string): string[] { + return memfs.readdirSync(dir) as string[]; + } +}); diff --git a/src/core/binaryManager.ts b/src/core/cliManager.ts similarity index 86% rename from src/core/binaryManager.ts rename to src/core/cliManager.ts index 6722a447..e8a7ab25 100644 --- a/src/core/binaryManager.ts +++ b/src/core/cliManager.ts @@ -12,13 +12,14 @@ import * as semver from "semver"; import * as vscode from "vscode"; import { errToStr } from "../api/api-helper"; -import * as cli from "../cliManager"; +import * as cli from "../cliUtils"; import { Logger } from "../logging/logger"; import * as pgp from "../pgp"; import { PathResolver } from "./pathResolver"; -export class BinaryManager { +export class CliManager { constructor( + private readonly vscodeProposed: typeof vscode, private readonly output: Logger, private readonly pathResolver: PathResolver, ) {} @@ -393,7 +394,7 @@ export class BinaryManager { options.push("Download signature"); } options.push("Run without verification"); - const action = await vscode.window.showWarningMessage( + const action = await this.vscodeProposed.window.showWarningMessage( status === 404 ? "Signature not found" : "Failed to download signature", { useCustom: true, @@ -447,7 +448,7 @@ export class BinaryManager { this.output, ); } catch (error) { - const action = await vscode.window.showWarningMessage( + const action = await this.vscodeProposed.window.showWarningMessage( // VerificationError should be the only thing that throws, but // unfortunately caught errors are always type unknown. error instanceof pgp.VerificationError @@ -469,4 +470,78 @@ export class BinaryManager { } return status; } + + /** + * Configure the CLI for the deployment with the provided label. + * + * Falsey URLs and null tokens are a no-op; we avoid unconfiguring the CLI to + * avoid breaking existing connections. + */ + public async configure( + label: string, + url: string | undefined, + token: string | null, + ) { + await Promise.all([ + this.updateUrlForCli(label, url), + this.updateTokenForCli(label, token), + ]); + } + + /** + * Update the URL for the deployment with the provided label on disk which can + * be used by the CLI via --url-file. If the URL is falsey, do nothing. + * + * If the label is empty, read the old deployment-unaware config instead. + */ + private async updateUrlForCli( + label: string, + url: string | undefined, + ): Promise { + if (url) { + const urlPath = this.pathResolver.getUrlPath(label); + await fs.mkdir(path.dirname(urlPath), { recursive: true }); + await fs.writeFile(urlPath, url); + } + } + + /** + * Update the session token for a deployment with the provided label on disk + * which can be used by the CLI via --session-token-file. If the token is + * null, do nothing. + * + * If the label is empty, read the old deployment-unaware config instead. + */ + private async updateTokenForCli( + label: string, + token: string | undefined | null, + ) { + if (token !== null) { + const tokenPath = this.pathResolver.getSessionTokenPath(label); + await fs.mkdir(path.dirname(tokenPath), { recursive: true }); + await fs.writeFile(tokenPath, token ?? ""); + } + } + + /** + * Read the CLI config for a deployment with the provided label. + * + * IF a config file does not exist, return an empty string. + * + * If the label is empty, read the old deployment-unaware config. + */ + public async readConfig( + label: string, + ): Promise<{ url: string; token: string }> { + const urlPath = this.pathResolver.getUrlPath(label); + const tokenPath = this.pathResolver.getSessionTokenPath(label); + const [url, token] = await Promise.allSettled([ + fs.readFile(urlPath, "utf8"), + fs.readFile(tokenPath, "utf8"), + ]); + return { + url: url.status === "fulfilled" ? url.value.trim() : "", + token: token.status === "fulfilled" ? token.value.trim() : "", + }; + } } diff --git a/src/core/mementoManager.test.ts b/src/core/mementoManager.test.ts index fddfbbfa..f1cd6a2d 100644 --- a/src/core/mementoManager.test.ts +++ b/src/core/mementoManager.test.ts @@ -1,31 +1,7 @@ import { describe, it, expect, beforeEach } from "vitest"; -import type { Memento } from "vscode"; +import { InMemoryMemento } from "../__mocks__/testHelpers"; import { MementoManager } from "./mementoManager"; -// Simple in-memory implementation of Memento -class InMemoryMemento implements Memento { - private storage = new Map(); - - get(key: string): T | undefined; - get(key: string, defaultValue: T): T; - get(key: string, defaultValue?: T): T | undefined { - return this.storage.has(key) ? (this.storage.get(key) as T) : defaultValue; - } - - async update(key: string, value: unknown): Promise { - if (value === undefined) { - this.storage.delete(key); - } else { - this.storage.set(key, value); - } - return Promise.resolve(); - } - - keys(): readonly string[] { - return Array.from(this.storage.keys()); - } -} - describe("MementoManager", () => { let memento: InMemoryMemento; let mementoManager: MementoManager; diff --git a/src/core/pathResolver.test.ts b/src/core/pathResolver.test.ts index 83700f69..8216a547 100644 --- a/src/core/pathResolver.test.ts +++ b/src/core/pathResolver.test.ts @@ -15,23 +15,6 @@ describe("PathResolver", () => { mockConfig = new MockConfigurationProvider(); }); - it("should generate deployment-specific paths", () => { - const label = "my-deployment"; - - expect(pathResolver.getGlobalConfigDir(label)).toBe( - path.join(basePath, label), - ); - expect(pathResolver.getSessionTokenPath(label)).toBe( - path.join(basePath, label, "session"), - ); - expect(pathResolver.getLegacySessionTokenPath(label)).toBe( - path.join(basePath, label, "session_token"), - ); - expect(pathResolver.getUrlPath(label)).toBe( - path.join(basePath, label, "url"), - ); - }); - it("should use base path for empty labels", () => { expect(pathResolver.getGlobalConfigDir("")).toBe(basePath); expect(pathResolver.getSessionTokenPath("")).toBe( @@ -40,15 +23,6 @@ describe("PathResolver", () => { expect(pathResolver.getUrlPath("")).toBe(path.join(basePath, "url")); }); - it("should return static paths correctly", () => { - expect(pathResolver.getNetworkInfoPath()).toBe(path.join(basePath, "net")); - expect(pathResolver.getLogPath()).toBe(path.join(basePath, "log")); - expect(pathResolver.getCodeLogDir()).toBe(codeLogPath); - expect(pathResolver.getUserSettingsPath()).toBe( - path.join(basePath, "..", "..", "..", "User", "settings.json"), - ); - }); - describe("getBinaryCachePath", () => { it("should use custom binary destination when configured", () => { mockConfig.set("coder.binaryDestination", "/custom/binary/path"); diff --git a/src/core/secretsManager.test.ts b/src/core/secretsManager.test.ts index 839021f9..a6487e0f 100644 --- a/src/core/secretsManager.test.ts +++ b/src/core/secretsManager.test.ts @@ -1,40 +1,7 @@ import { describe, it, expect, beforeEach } from "vitest"; -import type { SecretStorage, Event, SecretStorageChangeEvent } from "vscode"; +import { InMemorySecretStorage } from "../__mocks__/testHelpers"; import { SecretsManager } from "./secretsManager"; -// Simple in-memory implementation of SecretStorage -class InMemorySecretStorage implements SecretStorage { - private secrets = new Map(); - private isCorrupted = false; - - onDidChange: Event = () => ({ dispose: () => {} }); - - async get(key: string): Promise { - if (this.isCorrupted) { - return Promise.reject(new Error("Storage corrupted")); - } - return this.secrets.get(key); - } - - async store(key: string, value: string): Promise { - if (this.isCorrupted) { - return Promise.reject(new Error("Storage corrupted")); - } - this.secrets.set(key, value); - } - - async delete(key: string): Promise { - if (this.isCorrupted) { - return Promise.reject(new Error("Storage corrupted")); - } - this.secrets.delete(key); - } - - corruptStorage(): void { - this.isCorrupted = true; - } -} - describe("SecretsManager", () => { let secretStorage: InMemorySecretStorage; let secretsManager: SecretsManager; diff --git a/src/extension.ts b/src/extension.ts index bf3f6eb2..bd8a09c6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,8 +7,7 @@ import { errToStr } from "./api/api-helper"; import { CoderApi } from "./api/coderApi"; import { needToken } from "./api/utils"; import { Commands } from "./commands"; -import { BinaryManager } from "./core/binaryManager"; -import { CliConfigManager } from "./core/cliConfig"; +import { CliManager } from "./core/cliManager"; import { MementoManager } from "./core/mementoManager"; import { PathResolver } from "./core/pathResolver"; import { SecretsManager } from "./core/secretsManager"; @@ -56,7 +55,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ctx.globalStorageUri.fsPath, ctx.logUri.fsPath, ); - const cliConfigManager = new CliConfigManager(pathResolver); const mementoManager = new MementoManager(ctx.globalState); const secretsManager = new SecretsManager(ctx.secrets); @@ -159,7 +157,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } // Store on disk to be used by the cli. - await cliConfigManager.configure(toSafeHost(url), url, token); + await cliManager.configure(toSafeHost(url), url, token); vscode.commands.executeCommand( "coder.open", @@ -237,7 +235,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { : (params.get("token") ?? ""); // Store on disk to be used by the cli. - await cliConfigManager.configure(toSafeHost(url), url, token); + await cliManager.configure(toSafeHost(url), url, token); vscode.commands.executeCommand( "coder.openDevContainer", @@ -255,7 +253,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }, }); - const binaryManager = new BinaryManager(output, pathResolver); + const cliManager = new CliManager(vscodeProposed, output, pathResolver); // Register globally available commands. Many of these have visibility // controlled by contexts, see `when` in the package.json. @@ -266,7 +264,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { pathResolver, mementoManager, secretsManager, - binaryManager, + cliManager, ); vscode.commands.registerCommand("coder.login", commands.login.bind(commands)); vscode.commands.registerCommand( @@ -327,7 +325,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { commands, ctx.extensionMode, pathResolver, - binaryManager, + cliManager, ); try { const details = await remote.setup( diff --git a/src/remote.ts b/src/remote.ts index 0c7541fc..c9765fb8 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -19,10 +19,9 @@ import { createWorkspaceIdentifier, extractAgents } from "./api/api-helper"; import { CoderApi } from "./api/coderApi"; import { needToken } from "./api/utils"; import { startWorkspaceIfStoppedOrFailed, waitForBuild } from "./api/workspace"; -import * as cli from "./cliManager"; +import * as cliUtils from "./cliUtils"; import { Commands } from "./commands"; -import { BinaryManager } from "./core/binaryManager"; -import { CliConfigManager } from "./core/cliConfig"; +import { CliManager } from "./core/cliManager"; import { PathResolver } from "./core/pathResolver"; import { featureSetForVersion, FeatureSet } from "./featureSet"; import { getGlobalFlags } from "./globalFlags"; @@ -45,7 +44,6 @@ export interface RemoteDetails extends vscode.Disposable { } export class Remote { - private readonly cliConfigManager: CliConfigManager; public constructor( // We use the proposed API to get access to useCustom in dialogs. private readonly vscodeProposed: typeof vscode, @@ -53,10 +51,8 @@ export class Remote { private readonly commands: Commands, private readonly mode: vscode.ExtensionMode, private readonly pathResolver: PathResolver, - private readonly binaryManager: BinaryManager, - ) { - this.cliConfigManager = new CliConfigManager(pathResolver); - } + private readonly cliManager: CliManager, + ) {} private async confirmStart(workspaceName: string): Promise { const action = await this.vscodeProposed.window.showInformationMessage( @@ -222,7 +218,7 @@ export class Remote { await this.migrateSessionToken(parts.label); // Get the URL and token belonging to this host. - const { url: baseUrlRaw, token } = await this.cliConfigManager.readConfig( + const { url: baseUrlRaw, token } = await this.cliManager.readConfig( parts.label, ); @@ -275,7 +271,7 @@ export class Remote { let binaryPath: string | undefined; if (this.mode === vscode.ExtensionMode.Production) { - binaryPath = await this.binaryManager.fetchBinary( + binaryPath = await this.cliManager.fetchBinary( workspaceClient, parts.label, ); @@ -286,7 +282,7 @@ export class Remote { binaryPath = path.join(os.tmpdir(), "coder"); await fs.stat(binaryPath); } catch (ex) { - binaryPath = await this.binaryManager.fetchBinary( + binaryPath = await this.cliManager.fetchBinary( workspaceClient, parts.label, ); @@ -298,7 +294,7 @@ export class Remote { let version: semver.SemVer | null = null; try { - version = semver.parse(await cli.version(binaryPath)); + version = semver.parse(await cliUtils.version(binaryPath)); } catch (e) { version = semver.parse(buildInfo.version); } diff --git a/yarn.lock b/yarn.lock index 45d34629..62565608 100644 --- a/yarn.lock +++ b/yarn.lock @@ -647,25 +647,49 @@ "@jridgewell/resolve-uri" "3.1.0" "@jridgewell/sourcemap-codec" "1.4.14" -"@jsonjoy.com/base64@^1.1.1": +"@jsonjoy.com/base64@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@jsonjoy.com/base64/-/base64-1.1.2.tgz#cf8ea9dcb849b81c95f14fc0aaa151c6b54d2578" integrity sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA== -"@jsonjoy.com/json-pack@^1.0.3": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@jsonjoy.com/json-pack/-/json-pack-1.0.4.tgz#ab59c642a2e5368e8bcfd815d817143d4f3035d0" - integrity sha512-aOcSN4MeAtFROysrbqG137b7gaDDSmVrl5mpo6sT/w+kcXpWnzhMjmY/Fh/sDx26NBxyIE7MB1seqLeCAzy9Sg== - dependencies: - "@jsonjoy.com/base64" "^1.1.1" - "@jsonjoy.com/util" "^1.1.2" +"@jsonjoy.com/buffers@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/buffers/-/buffers-1.0.0.tgz#ade6895b7d3883d70f87b5743efaa12c71dfef7a" + integrity sha512-NDigYR3PHqCnQLXYyoLbnEdzMMvzeiCWo1KOut7Q0CoIqg9tUAPKJ1iq/2nFhc5kZtexzutNY0LFjdwWL3Dw3Q== + +"@jsonjoy.com/codegen@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz#5c23f796c47675f166d23b948cdb889184b93207" + integrity sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g== + +"@jsonjoy.com/json-pack@^1.11.0": + version "1.14.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/json-pack/-/json-pack-1.14.0.tgz#eda5255ccdaeafb3aa811ff1ae4814790b958b4f" + integrity sha512-LpWbYgVnKzphN5S6uss4M25jJ/9+m6q6UJoeN6zTkK4xAGhKsiBRPVeF7OYMWonn5repMQbE5vieRXcMUrKDKw== + dependencies: + "@jsonjoy.com/base64" "^1.1.2" + "@jsonjoy.com/buffers" "^1.0.0" + "@jsonjoy.com/codegen" "^1.0.0" + "@jsonjoy.com/json-pointer" "^1.0.1" + "@jsonjoy.com/util" "^1.9.0" hyperdyperid "^1.2.0" - thingies "^1.20.0" + thingies "^2.5.0" -"@jsonjoy.com/util@^1.1.2", "@jsonjoy.com/util@^1.3.0": - version "1.5.0" - resolved "https://registry.yarnpkg.com/@jsonjoy.com/util/-/util-1.5.0.tgz#6008e35b9d9d8ee27bc4bfaa70c8cbf33a537b4c" - integrity sha512-ojoNsrIuPI9g6o8UxhraZQSyF2ByJanAY4cTFbc8Mf2AXEF4aQRGY1dJxyJpuyav8r9FGflEt/Ff3u5Nt6YMPA== +"@jsonjoy.com/json-pointer@^1.0.1": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz#049cb530ac24e84cba08590c5e36b431c4843408" + integrity sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg== + dependencies: + "@jsonjoy.com/codegen" "^1.0.0" + "@jsonjoy.com/util" "^1.9.0" + +"@jsonjoy.com/util@^1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/util/-/util-1.9.0.tgz#7ee95586aed0a766b746cd8d8363e336c3c47c46" + integrity sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ== + dependencies: + "@jsonjoy.com/buffers" "^1.0.0" + "@jsonjoy.com/codegen" "^1.0.0" "@nodelib/fs.scandir@2.1.5": version "2.1.5" @@ -3887,6 +3911,11 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" +glob-to-regex.js@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/glob-to-regex.js/-/glob-to-regex.js-1.0.1.tgz#f71cc9cb8441471a9318626160bc8a35e1306b21" + integrity sha512-CG/iEvgQqfzoVsMUbxSJcwbG2JwyZ3naEqPkeltwl0BSS8Bp83k3xlGms+0QdWFUAwV+uvo80wNswKF6FWEkKg== + glob-to-regexp@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" @@ -5176,14 +5205,16 @@ mdurl@^2.0.0: resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0" integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w== -memfs@^4.17.1: - version "4.17.1" - resolved "https://registry.yarnpkg.com/memfs/-/memfs-4.17.1.tgz#3112332cbc2b055da3f1c0ba1fd29fdcb863621a" - integrity sha512-thuTRd7F4m4dReCIy7vv4eNYnU6XI/tHMLSMMHLiortw/Y0QxqKtinG523U2aerzwYWGi606oBP4oMPy4+edag== +memfs@^4.46.0: + version "4.46.0" + resolved "https://registry.yarnpkg.com/memfs/-/memfs-4.46.0.tgz#7b110f7a47cdf28b524072b9dd028c9752e4a29c" + integrity sha512-//IxqL9OO/WMpm2kE2aq+y7vO7/xS9xgVIbFM8RUIfW7TY7lowtnuS1j9MwLGm0OwcHUa4p8Bp+40W7f1BiWGQ== dependencies: - "@jsonjoy.com/json-pack" "^1.0.3" - "@jsonjoy.com/util" "^1.3.0" - tree-dump "^1.0.1" + "@jsonjoy.com/json-pack" "^1.11.0" + "@jsonjoy.com/util" "^1.9.0" + glob-to-regex.js "^1.0.1" + thingies "^2.5.0" + tree-dump "^1.0.3" tslib "^2.0.0" merge-stream@^2.0.0: @@ -7510,10 +7541,10 @@ textextensions@^6.11.0: dependencies: editions "^6.21.0" -thingies@^1.20.0: - version "1.21.0" - resolved "https://registry.yarnpkg.com/thingies/-/thingies-1.21.0.tgz#e80fbe58fd6fdaaab8fad9b67bd0a5c943c445c1" - integrity sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g== +thingies@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/thingies/-/thingies-2.5.0.tgz#5f7b882c933b85989f8466b528a6247a6881e04f" + integrity sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw== through@^2.3.6: version "2.3.8" @@ -7585,10 +7616,10 @@ to-regex-range@^5.0.1: resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9" integrity sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ== -tree-dump@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/tree-dump/-/tree-dump-1.0.2.tgz#c460d5921caeb197bde71d0e9a7b479848c5b8ac" - integrity sha512-dpev9ABuLWdEubk+cIaI9cHwRNNDjkBBLXTwI4UCUFdQ5xXKqNXoK4FEciw/vxf+NQ7Cb7sGUyeUtORvHIdRXQ== +tree-dump@^1.0.3: + version "1.1.0" + resolved "https://registry.yarnpkg.com/tree-dump/-/tree-dump-1.1.0.tgz#ab29129169dc46004414f5a9d4a3c6e89f13e8a4" + integrity sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA== trim-trailing-lines@^1.0.0: version "1.1.4"