diff --git a/src/browser/utils/openInEditor.ts b/src/browser/utils/openInEditor.ts index 9fe16b4bef..ad979f30c0 100644 --- a/src/browser/utils/openInEditor.ts +++ b/src/browser/utils/openInEditor.ts @@ -1,4 +1,4 @@ -import { readPersistedState } from "@/browser/hooks/usePersistedState"; +import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { getEditorDeepLink, getDockerDeepLink, @@ -23,6 +23,8 @@ export interface OpenInEditorResult { // Browser mode: window.api is not set (only exists in Electron via preload) const isBrowserMode = typeof window !== "undefined" && !window.api; +const VS_CODE_EXTENSION_INSTALL_ATTEMPTED_KEY = "vsCodeExtensionInstallAttempted"; + // Helper for opening URLs - allows testing in Node environment function openUrl(url: string): void { if (typeof window !== "undefined" && window.open) { @@ -98,6 +100,37 @@ export async function openInEditor(args: { }): Promise { const editorConfig = readPersistedState(EDITOR_CONFIG_KEY, DEFAULT_EDITOR_CONFIG); + const extensionEditor = + editorConfig.editor === "vscode" || editorConfig.editor === "cursor" + ? editorConfig.editor + : null; + + // Browser mode runs RPCs on the remote Mux host, so extension installation cannot + // satisfy local editor requirements there. + if (!isBrowserMode && extensionEditor) { + const extensionInstallAttemptedKey = `${VS_CODE_EXTENSION_INSTALL_ATTEMPTED_KEY}:${extensionEditor}`; + + if (!readPersistedState(extensionInstallAttemptedKey, false)) { + // Only attempt (and mark as attempted) when the API is actually available. + // If api is null (startup/reconnect), skip so the next open can retry. + if (args.api) { + // Mark as attempted immediately to prevent duplicate install RPCs from rapid opens. + updatePersistedState(extensionInstallAttemptedKey, true); + + // Install once in the background so the existing open flow stays non-blocking. + // In tests we often pass partial API mocks, so this must never throw even when + // installVsCodeExtension is missing. + try { + args.api.general.installVsCodeExtension({ editor: extensionEditor }).catch(() => { + /* silently ignore background install errors */ + }); + } catch { + // Silently ignore — partial API objects may not expose this method. + } + } + } + } + const isSSH = isSSHRuntime(args.runtimeConfig); const isDocker = isDockerRuntime(args.runtimeConfig); diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index aaf85c584e..032a486ea5 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -1940,6 +1940,16 @@ export const general = { }), output: ResultSchema(z.void(), z.string()), }, + installVsCodeExtension: { + input: z.object({ + editor: z.enum(["vscode", "cursor"]), + }), + output: z.object({ + installed: z.boolean(), + alreadyInstalled: z.boolean(), + error: z.string().nullish(), + }), + }, getLogPath: { input: z.void(), output: z.object({ path: z.string() }), diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 35dbe3be0d..eaeb54ae42 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1631,6 +1631,12 @@ export const router = (authToken?: string) => { input.editorConfig ); }), + installVsCodeExtension: t + .input(schemas.general.installVsCodeExtension.input) + .output(schemas.general.installVsCodeExtension.output) + .handler(async ({ context, input }) => { + return context.editorService.installVsCodeExtension(input.editor); + }), }, secrets: { get: t diff --git a/src/node/services/editorService.test.ts b/src/node/services/editorService.test.ts index cbf92488ab..f86925d773 100644 --- a/src/node/services/editorService.test.ts +++ b/src/node/services/editorService.test.ts @@ -1,8 +1,93 @@ import { describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { delimiter, join } from "path"; import type { Config } from "@/node/config"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import { EditorService } from "./editorService"; +interface FakeCliScriptOptions { + listOutput: string; + listExitCode?: number; + installExitCode?: number; + installLogPath: string; +} + +function createFakeCliScript(options: FakeCliScriptOptions): string { + const listExitCode = options.listExitCode ?? 0; + const installExitCode = options.installExitCode ?? 0; + const listLines = options.listOutput + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + if (process.platform === "win32") { + const listOutput = + listLines.length > 0 + ? listLines.map((line) => ` echo ${line}`).join("\n") + : " rem no output"; + + return `@echo off +if "%1"=="--list-extensions" ( +${listOutput} + exit /b ${listExitCode} +) +if "%1"=="--install-extension" ( + echo %*>>"${options.installLogPath}" + exit /b ${installExitCode} +) +exit /b 1 +`; + } + + const listOutput = + listLines.length > 0 ? listLines.map((line) => ` printf '%s\\n' "${line}"`).join("\n") : " :"; + + return `#!/usr/bin/env sh +if [ "$1" = "--list-extensions" ]; then +${listOutput} + exit ${listExitCode} +fi +if [ "$1" = "--install-extension" ]; then + printf '%s\\n' "$*" >> "${options.installLogPath}" + exit ${installExitCode} +fi +exit 1 +`; +} + +async function readFileIfExists(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch { + return ""; + } +} + +async function withCliInPath( + cliName: "code" | "cursor", + scriptFactory: (cliDir: string) => string, + run: (cliDir: string) => Promise +): Promise { + const cliDir = await mkdtemp(join(tmpdir(), "mux-editor-service-test-")); + const originalPath = process.env.PATH; + const executableName = process.platform === "win32" ? `${cliName}.cmd` : cliName; + const executablePath = join(cliDir, executableName); + + try { + await writeFile(executablePath, scriptFactory(cliDir), { mode: 0o755 }); + process.env.PATH = originalPath ? `${cliDir}${delimiter}${originalPath}` : cliDir; + await run(cliDir); + } finally { + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + await rm(cliDir, { recursive: true, force: true }); + } +} + describe("EditorService", () => { test("rejects non-custom editors (renderer must use deep links)", async () => { const editorService = new EditorService({} as Config); @@ -72,4 +157,68 @@ describe("EditorService", () => { expect(result.error).toContain("Invalid custom editor command"); } }); + + describe("installVsCodeExtension", () => { + test("returns alreadyInstalled when coder.mux is already present", async () => { + const editorService = new EditorService({} as Config); + + await withCliInPath( + "code", + (cliDir) => + createFakeCliScript({ + listOutput: "coder.mux\nms-python.python", + installLogPath: join(cliDir, "install.log"), + }), + async (cliDir) => { + const result = await editorService.installVsCodeExtension("vscode"); + + expect(result).toEqual({ installed: true, alreadyInstalled: true }); + expect(await readFileIfExists(join(cliDir, "install.log"))).toBe(""); + } + ); + }); + + test("installs coder.mux via cursor CLI when extension is missing", async () => { + const editorService = new EditorService({} as Config); + + await withCliInPath( + "cursor", + (cliDir) => + createFakeCliScript({ + listOutput: "ms-python.python", + installLogPath: join(cliDir, "install.log"), + }), + async (cliDir) => { + const result = await editorService.installVsCodeExtension("cursor"); + + expect(result).toEqual({ installed: true, alreadyInstalled: false }); + expect(await readFileIfExists(join(cliDir, "install.log"))).toContain( + "--install-extension coder.mux" + ); + } + ); + }); + + test("returns an error result instead of throwing when CLI commands fail", async () => { + const editorService = new EditorService({} as Config); + + await withCliInPath( + "code", + (cliDir) => + createFakeCliScript({ + listOutput: "", + listExitCode: 2, + installLogPath: join(cliDir, "install.log"), + }), + async () => { + const result = await editorService.installVsCodeExtension("vscode"); + + expect(result.installed).toBe(false); + expect(result.alreadyInstalled).toBe(false); + expect(result.error).toBeDefined(); + expect(typeof result.error).toBe("string"); + } + ); + }); + }); }); diff --git a/src/node/services/editorService.ts b/src/node/services/editorService.ts index ffb95fe989..18353a5387 100644 --- a/src/node/services/editorService.ts +++ b/src/node/services/editorService.ts @@ -1,10 +1,13 @@ -import { spawn, spawnSync } from "child_process"; +import { exec as childProcessExec, spawn, spawnSync } from "child_process"; import * as fsPromises from "fs/promises"; +import { promisify } from "util"; import type { Config } from "@/node/config"; import { isDockerRuntime, isSSHRuntime, isDevcontainerRuntime } from "@/common/types/runtime"; import { log } from "@/node/services/log"; import { getErrorMessage } from "@/common/utils/errors"; +const execAsync = promisify(childProcessExec); + /** * Quote a string for safe use in shell commands. * @@ -147,6 +150,45 @@ export class EditorService { } } + async installVsCodeExtension( + editor: "vscode" | "cursor" + ): Promise<{ installed: boolean; alreadyInstalled: boolean; error?: string }> { + try { + const cli = editor === "cursor" ? "cursor" : "code"; + const extensionId = "coder.mux"; + + const { stdout } = await execAsync(`${cli} --list-extensions`, { + timeout: 10_000, + windowsHide: true, + }); + const extensionListOutput = String(stdout); + const alreadyInstalled = extensionListOutput + .split(/\r?\n/) + .some((line) => line.trim().toLowerCase() === extensionId); + + if (alreadyInstalled) { + return { installed: true, alreadyInstalled: true }; + } + + await execAsync(`${cli} --install-extension ${extensionId}`, { + timeout: 30_000, + windowsHide: true, + }); + + return { installed: true, alreadyInstalled: false }; + } catch (error) { + log.debug("Failed to install VS Code extension", { + editor, + error: String(error), + }); + return { + installed: false, + alreadyInstalled: false, + error: String(error), + }; + } + } + /** * Check if a command is available in the system PATH. * Inherits enriched PATH from process.env (set by initShellEnv at startup).