diff --git a/package.json b/package.json index abafdf0e..f6943a00 100644 --- a/package.json +++ b/package.json @@ -324,6 +324,11 @@ "title": "Speed Test Workspace", "category": "Coder" }, + { + "command": "coder.supportBundle", + "title": "Create Support Bundle", + "category": "Coder" + }, { "command": "coder.viewLogs", "title": "Coder: View Logs", @@ -381,6 +386,10 @@ { "command": "coder.pingWorkspace:views", "title": "Ping" + }, + { + "command": "coder.supportBundle:views", + "title": "Support Bundle" } ], "menus": { @@ -405,6 +414,10 @@ "command": "coder.speedTest", "when": "coder.authenticated" }, + { + "command": "coder.supportBundle", + "when": "coder.authenticated" + }, { "command": "coder.navigateToWorkspace", "when": "coder.workspace.connected" @@ -425,6 +438,10 @@ "command": "coder.pingWorkspace:views", "when": "false" }, + { + "command": "coder.supportBundle:views", + "when": "false" + }, { "command": "coder.workspace.update", "when": "coder.workspace.updatable" @@ -535,12 +552,17 @@ { "command": "coder.pingWorkspace:views", "when": "coder.authenticated && viewItem =~ /\\+running/", - "group": "navigation" + "group": "navigation@1" }, { "command": "coder.speedTest:views", "when": "coder.authenticated && viewItem =~ /\\+running/", - "group": "navigation" + "group": "navigation@2" + }, + { + "command": "coder.supportBundle:views", + "when": "coder.authenticated && viewItem =~ /\\+running/", + "group": "navigation@3" } ], "statusBar/remoteIndicator": [ diff --git a/src/commands.ts b/src/commands.ts index 1824f3ac..eef2835d 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -3,6 +3,7 @@ import { type WorkspaceAgent, } from "coder/site/src/api/typesGenerated"; import * as fs from "node:fs/promises"; +import * as os from "node:os"; import * as path from "node:path"; import * as semver from "semver"; import * as vscode from "vscode"; @@ -22,7 +23,7 @@ import { type SecretsManager } from "./core/secretsManager"; import { type DeploymentManager } from "./deployment/deploymentManager"; import { CertificateError } from "./error/certificateError"; import { toError } from "./error/errorUtils"; -import { featureSetForVersion } from "./featureSet"; +import { type FeatureSet, featureSetForVersion } from "./featureSet"; import { type Logger } from "./logging/logger"; import { type LoginCoordinator } from "./login/loginCoordinator"; import { withCancellableProgress, withProgress } from "./progress"; @@ -196,15 +197,17 @@ export class Commands { const trimmedDuration = duration.trim(); const result = await withCancellableProgress( - async ({ signal }) => { + async ({ signal, progress }) => { + progress.report({ message: "Resolving CLI..." }); const env = await this.resolveCliEnv(client); + progress.report({ message: "Running..." }); return cliExec.speedtest(env, workspaceId, trimmedDuration, signal); }, { location: vscode.ProgressLocation.Notification, title: trimmedDuration - ? `Running speed test (${trimmedDuration})...` - : "Running speed test...", + ? `Speed test for ${workspaceId} (${trimmedDuration})` + : `Speed test for ${workspaceId}`, cancellable: true, }, ); @@ -228,6 +231,70 @@ export class Commands { ); } + public async supportBundle(item?: OpenableTreeItem): Promise { + const resolved = await this.resolveClientAndWorkspace(item); + if (!resolved) { + return; + } + + const { client, workspaceId } = resolved; + + const outputUri = await this.promptSupportBundlePath(); + if (!outputUri) { + return; + } + + const result = await withCancellableProgress( + async ({ signal, progress }) => { + progress.report({ message: "Resolving CLI..." }); + const env = await this.resolveCliEnv(client); + if (!env.featureSet.supportBundle) { + throw new Error( + "Support bundles require Coder CLI v2.10.0 or later. Please update your Coder deployment.", + ); + } + + progress.report({ message: "Collecting diagnostics..." }); + await cliExec.supportBundle(env, workspaceId, outputUri.fsPath, signal); + return outputUri; + }, + { + location: vscode.ProgressLocation.Notification, + title: `Creating support bundle for ${workspaceId}`, + cancellable: true, + }, + ); + + if (result.ok) { + const action = await vscode.window.showInformationMessage( + `Support bundle saved to ${result.value.fsPath}`, + "Reveal in File Explorer", + ); + if (action === "Reveal in File Explorer") { + await vscode.commands.executeCommand("revealFileInOS", result.value); + } + return; + } + + if (result.cancelled) { + return; + } + + this.logger.error("Support bundle failed", result.error); + vscode.window.showErrorMessage( + `Support bundle failed: ${toError(result.error).message}`, + ); + } + + private promptSupportBundlePath(): Thenable { + const defaultName = `coder-support-${Math.floor(Date.now() / 1000)}.zip`; + return vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(path.join(os.homedir(), defaultName)), + filters: { "Zip files": ["zip"] }, + title: "Save Support Bundle", + }); + } + /** * View the logs for the currently connected workspace. */ @@ -720,8 +787,10 @@ export class Commands { location: vscode.ProgressLocation.Notification, title: `Starting ping for ${workspaceId}...`, }, - async () => { + async (progress) => { + progress.report({ message: "Resolving CLI..." }); const env = await this.resolveCliEnv(client); + progress.report({ message: "Starting..." }); cliExec.ping(env, workspaceId); }, ); @@ -763,7 +832,9 @@ export class Commands { } /** Resolve a CliEnv, preferring a locally cached binary over a network fetch. */ - private async resolveCliEnv(client: CoderApi): Promise { + private async resolveCliEnv( + client: CoderApi, + ): Promise { const baseUrl = client.getAxiosInstance().defaults.baseURL; if (!baseUrl) { throw new Error("You are not logged in"); @@ -780,7 +851,7 @@ export class Commands { const configDir = this.pathResolver.getGlobalConfigDir(safeHost); const configs = vscode.workspace.getConfiguration(); const auth = resolveCliAuth(configs, featureSet, baseUrl, configDir); - return { binary, configs, auth }; + return { binary, configs, auth, featureSet }; } /** diff --git a/src/core/cliExec.ts b/src/core/cliExec.ts index 8067ce72..22455928 100644 --- a/src/core/cliExec.ts +++ b/src/core/cliExec.ts @@ -104,6 +104,32 @@ export async function speedtest( } } +/** + * Run `coder support bundle` and save the output zip to the given path. + */ +export async function supportBundle( + env: CliEnv, + workspaceName: string, + outputPath: string, + signal?: AbortSignal, +): Promise { + const globalFlags = getGlobalFlags(env.configs, env.auth); + const args = [ + ...globalFlags, + "support", + "bundle", + workspaceName, + "--output-file", + outputPath, + "--yes", + ]; + try { + await execFileAsync(env.binary, args, { signal }); + } catch (error) { + throw cliError(error); + } +} + /** * Run `coder ping` in a PTY terminal with Ctrl+C support. */ diff --git a/src/extension.ts b/src/extension.ts index 6533cfad..8531d929 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -323,6 +323,14 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { "coder.speedTest:views", commands.speedTest.bind(commands), ), + vscode.commands.registerCommand( + "coder.supportBundle", + commands.supportBundle.bind(commands), + ), + vscode.commands.registerCommand( + "coder.supportBundle:views", + commands.supportBundle.bind(commands), + ), ); const remote = new Remote(serviceContainer, commands, ctx); diff --git a/src/featureSet.ts b/src/featureSet.ts index 76c596ce..09ff4574 100644 --- a/src/featureSet.ts +++ b/src/featureSet.ts @@ -7,6 +7,7 @@ export interface FeatureSet { buildReason: boolean; keyringAuth: boolean; keyringTokenRead: boolean; + supportBundle: boolean; } /** @@ -47,5 +48,7 @@ export function featureSetForVersion( keyringAuth: versionAtLeast(version, "2.29.0"), // `coder login token` for reading tokens from the keyring keyringTokenRead: versionAtLeast(version, "2.31.0"), + // `coder support bundle` (officially released/unhidden in 2.10.0) + supportBundle: versionAtLeast(version, "2.10.0"), }; } diff --git a/test/unit/core/cliExec.test.ts b/test/unit/core/cliExec.test.ts index 0adc9839..93af9f4e 100644 --- a/test/unit/core/cliExec.test.ts +++ b/test/unit/core/cliExec.test.ts @@ -12,12 +12,21 @@ import type { CliEnv } from "@/core/cliExec"; describe("cliExec", () => { const tmp = path.join(os.tmpdir(), "vscode-coder-tests-cliExec"); + let echoArgsBin: string; beforeAll(async () => { await fs.rm(tmp, { recursive: true, force: true }); await fs.mkdir(tmp, { recursive: true }); + const code = `process.argv.slice(2).forEach(a => console.log(a));`; + echoArgsBin = await writeExecutable(tmp, "echo-args", code); }); + function setup(auth: CliEnv["auth"], binary = echoArgsBin) { + const configs = new MockConfigurationProvider(); + const env: CliEnv = { binary, auth, configs }; + return { configs, env }; + } + /** JS code for a fake CLI that writes a fixed string to stdout. */ function echoBin(output: string): string { return `process.stdout.write(${JSON.stringify(output)});`; @@ -118,63 +127,20 @@ describe("cliExec", () => { }); describe("speedtest", () => { - let echoArgsBin: string; - - beforeAll(async () => { - const code = `process.argv.slice(2).forEach(a => console.log(a));`; - echoArgsBin = await writeExecutable(tmp, "echo-args", code); - }); - - function setup(auth: CliEnv["auth"], binary = echoArgsBin) { - const configs = new MockConfigurationProvider(); - const env: CliEnv = { binary, auth, configs }; - return { configs, env }; - } - - it("passes global-config auth flags", async () => { - const { env } = setup({ - mode: "global-config", - configDir: "/tmp/test-config", - }); - const result = await cliExec.speedtest(env, "owner/workspace"); - const args = result.trim().split("\n"); - expect(args).toEqual([ - "--global-config", - "/tmp/test-config", - "speedtest", - "owner/workspace", - "--output", - "json", - ]); - }); - - it("passes url auth flags", async () => { - const { env } = setup({ - mode: "url", - url: "http://localhost:3000", - }); - const result = await cliExec.speedtest(env, "owner/workspace"); - const args = result.trim().split("\n"); - expect(args).toEqual([ - "--url", - "http://localhost:3000", - "speedtest", - "owner/workspace", - "--output", - "json", - ]); - }); - - it("passes duration flag", async () => { - const { env } = setup({ + it("passes global, header, and command-specific flags", async () => { + const { configs, env } = setup({ mode: "url", url: "http://localhost:3000", }); - const result = await cliExec.speedtest(env, "owner/workspace", "10s"); - const args = result.trim().split("\n"); + configs.set("coder.headerCommand", "my-header-cmd"); + const args = (await cliExec.speedtest(env, "owner/workspace", "10s")) + .trim() + .split("\n"); expect(args).toEqual([ "--url", "http://localhost:3000", + "--header-command", + "'my-header-cmd'", "speedtest", "owner/workspace", "--output", @@ -184,30 +150,6 @@ describe("cliExec", () => { ]); }); - it("passes header command", async () => { - const { configs, env } = setup({ - mode: "url", - url: "http://localhost:3000", - }); - configs.set("coder.headerCommand", "my-header-cmd"); - const result = await cliExec.speedtest(env, "owner/workspace"); - const args = result.trim().split("\n"); - expect(args).toContain("--header-command"); - }); - - it("throws when binary does not exist", async () => { - const { env } = setup( - { - mode: "global-config", - configDir: "/tmp", - }, - "/nonexistent/binary", - ); - await expect(cliExec.speedtest(env, "owner/workspace")).rejects.toThrow( - "ENOENT", - ); - }); - it("surfaces stderr instead of full command line on failure", async () => { const code = [ `process.stderr.write("invalid argument for -t flag\\n");`, @@ -221,6 +163,51 @@ describe("cliExec", () => { }); }); + describe("supportBundle", () => { + it("passes global, header, and command-specific flags", async () => { + // Use a binary that writes args to the --output-file path + // so we can verify them after the void-returning function completes. + const code = [ + `const args = process.argv.slice(2);`, + `const idx = args.indexOf("--output-file");`, + `if (idx !== -1) { require("fs").writeFileSync(args[idx+1], args.join("\\n")); }`, + ].join("\n"); + const bin = await writeExecutable(tmp, "sb-echo-args", code); + const outputPath = path.join(tmp, "sb-args-output.zip"); + const { configs, env } = setup( + { mode: "url", url: "http://localhost:3000" }, + bin, + ); + configs.set("coder.headerCommand", "my-header-cmd"); + await cliExec.supportBundle(env, "owner/workspace", outputPath); + const args = (await fs.readFile(outputPath, "utf-8")).trim().split("\n"); + expect(args).toEqual([ + "--url", + "http://localhost:3000", + "--header-command", + "'my-header-cmd'", + "support", + "bundle", + "owner/workspace", + "--output-file", + outputPath, + "--yes", + ]); + }); + + it("surfaces stderr on failure", async () => { + const code = [ + `process.stderr.write("workspace not found\\n");`, + `process.exitCode = 1;`, + ].join("\n"); + const bin = await writeExecutable(tmp, "sb-err", code); + const { env } = setup({ mode: "global-config", configDir: "/tmp" }, bin); + await expect( + cliExec.supportBundle(env, "owner/workspace", "/tmp/bundle.zip"), + ).rejects.toThrow("workspace not found"); + }); + }); + describe("isGoDuration", () => { it.each([ "5s", diff --git a/test/unit/featureSet.test.ts b/test/unit/featureSet.test.ts index 3beeac3b..c7bee26c 100644 --- a/test/unit/featureSet.test.ts +++ b/test/unit/featureSet.test.ts @@ -1,60 +1,58 @@ import * as semver from "semver"; import { describe, expect, it } from "vitest"; -import { featureSetForVersion } from "@/featureSet"; +import { type FeatureSet, featureSetForVersion } from "@/featureSet"; + +function expectFlag( + flag: keyof FeatureSet, + below: string[], + atOrAbove: string[], +) { + for (const v of below) { + expect(featureSetForVersion(semver.parse(v))[flag]).toBeFalsy(); + } + for (const v of atOrAbove) { + expect(featureSetForVersion(semver.parse(v))[flag]).toBeTruthy(); + } + expect( + featureSetForVersion(semver.parse("0.0.0-devel+abc123"))[flag], + ).toBeTruthy(); +} describe("check version support", () => { - it("has logs", () => { - ["v1.3.3+e491217", "v2.3.3+e491217", "v2.3.9+e491217"].forEach( - (v: string) => { - expect( - featureSetForVersion(semver.parse(v)).proxyLogDirectory, - ).toBeFalsy(); - }, - ); - ["v2.4.0+e491217", "v5.3.4+e491217", "v5.0.4+e491217"].forEach( - (v: string) => { - expect( - featureSetForVersion(semver.parse(v)).proxyLogDirectory, - ).toBeTruthy(); - }, + it("proxy log directory", () => { + expectFlag( + "proxyLogDirectory", + ["v1.3.3+e491217", "v2.3.3+e491217", "v2.3.9+e491217"], + ["v2.4.0+e491217", "v5.3.4+e491217", "v5.0.4+e491217"], ); }); it("wildcard ssh", () => { - ["v1.3.3+e491217", "v2.3.3+e491217"].forEach((v: string) => { - expect(featureSetForVersion(semver.parse(v)).wildcardSSH).toBeFalsy(); - }); - ["v2.19.0", "v2.19.1", "v2.20.0+e491217", "v5.0.4+e491217"].forEach( - (v: string) => { - expect(featureSetForVersion(semver.parse(v)).wildcardSSH).toBeTruthy(); - }, + expectFlag( + "wildcardSSH", + ["v1.3.3+e491217", "v2.3.3+e491217"], + ["v2.19.0", "v2.19.1", "v2.20.0+e491217", "v5.0.4+e491217"], ); }); it("keyring auth", () => { - ["v2.28.0", "v2.28.9", "v1.0.0", "v2.3.3+e491217"].forEach((v: string) => { - expect(featureSetForVersion(semver.parse(v)).keyringAuth).toBeFalsy(); - }); - ["v2.29.0", "v2.29.1", "v2.30.0", "v3.0.0"].forEach((v: string) => { - expect(featureSetForVersion(semver.parse(v)).keyringAuth).toBeTruthy(); - }); - // devel prerelease should enable keyring - expect( - featureSetForVersion(semver.parse("0.0.0-devel+abc123")).keyringAuth, - ).toBeTruthy(); + expectFlag( + "keyringAuth", + ["v2.28.0", "v2.28.9", "v1.0.0", "v2.3.3+e491217"], + ["v2.29.0", "v2.29.1", "v2.30.0", "v3.0.0"], + ); }); it("keyring token read", () => { - ["v2.30.0", "v2.29.0", "v2.28.0", "v1.0.0"].forEach((v: string) => { - expect( - featureSetForVersion(semver.parse(v)).keyringTokenRead, - ).toBeFalsy(); - }); - ["v2.31.0", "v2.31.1", "v2.32.0", "v3.0.0"].forEach((v: string) => { - expect( - featureSetForVersion(semver.parse(v)).keyringTokenRead, - ).toBeTruthy(); - }); - expect( - featureSetForVersion(semver.parse("0.0.0-devel+abc123")).keyringTokenRead, - ).toBeTruthy(); + expectFlag( + "keyringTokenRead", + ["v2.30.0", "v2.29.0", "v2.28.0", "v1.0.0"], + ["v2.31.0", "v2.31.1", "v2.32.0", "v3.0.0"], + ); + }); + it("support bundle", () => { + expectFlag( + "supportBundle", + ["v2.9.0", "v2.9.9", "v1.0.0", "v2.3.3+e491217"], + ["v2.10.0", "v2.10.1", "v2.11.0", "v3.0.0"], + ); }); });