Skip to content
35 changes: 34 additions & 1 deletion src/browser/utils/openInEditor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readPersistedState } from "@/browser/hooks/usePersistedState";
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
import {
getEditorDeepLink,
getDockerDeepLink,
Expand All @@ -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) {
Expand Down Expand Up @@ -98,6 +100,37 @@ export async function openInEditor(args: {
}): Promise<OpenInEditorResult> {
const editorConfig = readPersistedState<EditorConfig>(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);

Expand Down
10 changes: 10 additions & 0 deletions src/common/orpc/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }),
Expand Down
6 changes: 6 additions & 0 deletions src/node/orpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
149 changes: 149 additions & 0 deletions src/node/services/editorService.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
try {
return await readFile(path, "utf8");
} catch {
return "";
}
}

async function withCliInPath(
cliName: "code" | "cursor",
scriptFactory: (cliDir: string) => string,
run: (cliDir: string) => Promise<void>
): Promise<void> {
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);
Expand Down Expand Up @@ -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");
}
);
});
});
});
44 changes: 43 additions & 1 deletion src/node/services/editorService.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -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).
Expand Down
Loading