Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,43 @@
"development"
],
"activationEvents": [
"onCommand:mux.openWorkspace"
"onCommand:mux.openWorkspace",
"onCommand:mux.configureConnection"
],
"main": "./out/extension.js",
"contributes": {
"commands": [
{
"command": "mux.openWorkspace",
"title": "mux: Open Workspace"
},
{
"command": "mux.configureConnection",
"title": "mux: Configure Connection"
}
],
"configuration": {
"title": "mux",
"properties": {
"mux.connectionMode": {
"type": "string",
"enum": [
"auto",
"server-only",
"file-only"
],
"default": "auto",
"scope": "machine",
"description": "How the mux VS Code extension connects to mux (prefer server, require server, or use local files)."
},
"mux.serverUrl": {
"type": "string",
"default": "",
"scope": "machine",
"description": "Override the mux server URL (leave empty to auto-discover). Example: http://127.0.0.1:3000"
}
}
]
}
},
"scripts": {
"vscode:prepublish": "bun run compile",
Expand All @@ -54,3 +81,4 @@
"license": "AGPL-3.0-only",
"packageManager": "[email protected]"
}

52 changes: 52 additions & 0 deletions vscode/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { RPCLink } from "@orpc/client/fetch";
import type { RouterClient } from "@orpc/server";
import assert from "node:assert";

import { createClient } from "mux/common/orpc/client";
import type { AppRouter } from "mux/node/orpc/router";

export type ApiClient = RouterClient<AppRouter>;

export interface ApiClientConfig {
baseUrl: string;
authToken?: string | undefined;
}

function normalizeBaseUrl(baseUrl: string): string {
assert(baseUrl.length > 0, "baseUrl must be non-empty");

const parsed = new URL(baseUrl);
assert(
parsed.protocol === "http:" || parsed.protocol === "https:",
`Unsupported baseUrl protocol: ${parsed.protocol}`
);

// URL.toString() includes a trailing slash for naked origins.
return parsed.toString().replace(/\/$/, "");
}

export function createApiClient(config: ApiClientConfig): ApiClient {
assert(typeof config.baseUrl === "string", "baseUrl must be a string");

const normalizedBaseUrl = normalizeBaseUrl(config.baseUrl);

const link = new RPCLink({
url: `${normalizedBaseUrl}/orpc`,
async fetch(request, init) {
const headers = new Headers(request.headers);
if (config.authToken) {
headers.set("Authorization", `Bearer ${config.authToken}`);
}

return fetch(request.url, {
body: await request.blob(),
headers,
method: request.method,
signal: request.signal,
...init,
});
},
});

return createClient(link);
}
118 changes: 118 additions & 0 deletions vscode/src/api/connectionCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import assert from "node:assert";

import type { ApiClient } from "./client";

export type ServerReachabilityResult =
| { status: "ok" }
| {
status: "unreachable";
error: string;
};

export type AuthCheckResult =
| { status: "ok" }
| {
status: "unauthorized";
error: string;
}
| {
status: "error";
error: string;
};

function normalizeBaseUrl(baseUrl: string): string {
assert(baseUrl.length > 0, "baseUrl must be non-empty");
return baseUrl.replace(/\/$/, "");
}

async function promiseWithTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
label: string
): Promise<T> {
assert(timeoutMs > 0, "timeoutMs must be positive");

return new Promise<T>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);

promise
.then(resolve, reject)
.finally(() => {
clearTimeout(timeout);
});
});
}

function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

function isUnauthorizedError(error: unknown): boolean {
const msg = formatError(error).toLowerCase();
return (
msg.includes("unauthorized") ||
msg.includes("401") ||
msg.includes("auth token") ||
msg.includes("authentication")
);
}

export async function checkServerReachable(
baseUrl: string,
options?: { timeoutMs?: number }
): Promise<ServerReachabilityResult> {
const timeoutMs = options?.timeoutMs ?? 1_000;
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);

try {
const resp = await fetch(`${normalizedBaseUrl}/health`, {
method: "GET",
signal: controller.signal,
});

if (!resp.ok) {
return { status: "unreachable", error: `HTTP ${resp.status} from /health` };
}

const data = (await resp.json()) as { status?: unknown };
if (data.status !== "ok") {
return {
status: "unreachable",
error: "Unexpected /health response",
};
}

return { status: "ok" };
} catch (error) {
return {
status: "unreachable",
error: formatError(error),
};
} finally {
clearTimeout(timeout);
}
}

export async function checkAuth(
client: ApiClient,
options?: { timeoutMs?: number }
): Promise<AuthCheckResult> {
const timeoutMs = options?.timeoutMs ?? 1_000;

try {
// Used both as an auth check and a basic liveness check.
await promiseWithTimeout(client.general.ping("vscode"), timeoutMs, "API ping");
return { status: "ok" };
} catch (error) {
if (isUnauthorizedError(error)) {
return { status: "unauthorized", error: formatError(error) };
}

return { status: "error", error: formatError(error) };
}
}
115 changes: 115 additions & 0 deletions vscode/src/api/discovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import * as vscode from "vscode";
import assert from "node:assert";

import { getMuxHome } from "mux/common/constants/paths";
import { ServerLockfile } from "mux/node/services/serverLockfile";

export type ConnectionMode = "auto" | "server-only" | "file-only";

export interface DiscoveredServerConfig {
baseUrl: string;
authToken?: string | undefined;

// For debugging / UX messaging.
baseUrlSource: "settings" | "env" | "lockfile" | "default";
authTokenSource: "secret" | "env" | "lockfile" | "none";
}

const SERVER_AUTH_TOKEN_SECRET_KEY = "mux.serverAuthToken";

function normalizeBaseUrl(baseUrl: string): string {
assert(baseUrl.length > 0, "baseUrl must be non-empty");

const parsed = new URL(baseUrl);
assert(
parsed.protocol === "http:" || parsed.protocol === "https:",
`Unsupported baseUrl protocol: ${parsed.protocol}`
);

// URL.toString() includes a trailing slash for naked origins.
return parsed.toString().replace(/\/$/, "");
}

export function getConnectionModeSetting(): ConnectionMode {
const config = vscode.workspace.getConfiguration("mux");
const value = config.get<unknown>("connectionMode");

if (value === "auto" || value === "server-only" || value === "file-only") {
return value;
}

return "auto";
}

export async function discoverServerConfig(
context: vscode.ExtensionContext
): Promise<DiscoveredServerConfig> {
const config = vscode.workspace.getConfiguration("mux");
const serverUrlOverrideRaw = config.get<string>("serverUrl")?.trim();

let lockfileData: { baseUrl: string; token: string } | null = null;
try {
const lockfile = new ServerLockfile(getMuxHome());
const data = await lockfile.read();
if (data) {
lockfileData = { baseUrl: data.baseUrl, token: data.token };
}
} catch {
// Ignore lockfile errors; we'll fall back to defaults.
}

// Base URL precedence: settings -> env -> lockfile -> default.
const envBaseUrl = process.env.MUX_SERVER_URL?.trim();

let baseUrlSource: DiscoveredServerConfig["baseUrlSource"] = "default";
let baseUrlRaw = "http://localhost:3000";

if (serverUrlOverrideRaw) {
baseUrlSource = "settings";
baseUrlRaw = serverUrlOverrideRaw;
} else if (envBaseUrl) {
baseUrlSource = "env";
baseUrlRaw = envBaseUrl;
} else if (lockfileData) {
baseUrlSource = "lockfile";
baseUrlRaw = lockfileData.baseUrl;
}

const baseUrl = normalizeBaseUrl(baseUrlRaw);

// Auth token precedence: secret storage -> env -> lockfile (only if same baseUrl).
const secretToken = (await context.secrets.get(SERVER_AUTH_TOKEN_SECRET_KEY))?.trim();
const envToken = process.env.MUX_SERVER_AUTH_TOKEN?.trim();

let authTokenSource: DiscoveredServerConfig["authTokenSource"] = "none";
let authToken: string | undefined;

if (secretToken) {
authTokenSource = "secret";
authToken = secretToken;
} else if (envToken) {
authTokenSource = "env";
authToken = envToken;
} else if (lockfileData && normalizeBaseUrl(lockfileData.baseUrl) === baseUrl) {
authTokenSource = "lockfile";
authToken = lockfileData.token;
}

return {
baseUrl,
authToken,
baseUrlSource,
authTokenSource,
};
}

export async function storeAuthTokenOverride(
context: vscode.ExtensionContext,
authToken: string
): Promise<void> {
await context.secrets.store(SERVER_AUTH_TOKEN_SECRET_KEY, authToken);
}

export async function clearAuthTokenOverride(context: vscode.ExtensionContext): Promise<void> {
await context.secrets.delete(SERVER_AUTH_TOKEN_SECRET_KEY);
}
Loading