diff --git a/vscode/package.json b/vscode/package.json index 877fe09e37..14a25fc193 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -22,7 +22,8 @@ "development" ], "activationEvents": [ - "onCommand:mux.openWorkspace" + "onCommand:mux.openWorkspace", + "onCommand:mux.configureConnection" ], "main": "./out/extension.js", "contributes": { @@ -30,8 +31,34 @@ { "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", @@ -54,3 +81,4 @@ "license": "AGPL-3.0-only", "packageManager": "bun@1.1.42" } + diff --git a/vscode/src/api/client.ts b/vscode/src/api/client.ts new file mode 100644 index 0000000000..53872f95ed --- /dev/null +++ b/vscode/src/api/client.ts @@ -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; + +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); +} diff --git a/vscode/src/api/connectionCheck.ts b/vscode/src/api/connectionCheck.ts new file mode 100644 index 0000000000..766962a8fe --- /dev/null +++ b/vscode/src/api/connectionCheck.ts @@ -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( + promise: Promise, + timeoutMs: number, + label: string +): Promise { + assert(timeoutMs > 0, "timeoutMs must be positive"); + + return new Promise((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 { + 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 { + 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) }; + } +} diff --git a/vscode/src/api/discovery.ts b/vscode/src/api/discovery.ts new file mode 100644 index 0000000000..91f9bdb38e --- /dev/null +++ b/vscode/src/api/discovery.ts @@ -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("connectionMode"); + + if (value === "auto" || value === "server-only" || value === "file-only") { + return value; + } + + return "auto"; +} + +export async function discoverServerConfig( + context: vscode.ExtensionContext +): Promise { + const config = vscode.workspace.getConfiguration("mux"); + const serverUrlOverrideRaw = config.get("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 { + await context.secrets.store(SERVER_AUTH_TOKEN_SECRET_KEY, authToken); +} + +export async function clearAuthTokenOverride(context: vscode.ExtensionContext): Promise { + await context.secrets.delete(SERVER_AUTH_TOKEN_SECRET_KEY); +} diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index cbeb1110c3..099a17186d 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -1,8 +1,178 @@ import * as vscode from "vscode"; -import { getAllWorkspaces, WorkspaceWithContext } from "./muxConfig"; -import { openWorkspace } from "./workspaceOpener"; + import { formatRelativeTime } from "mux/browser/utils/ui/dateTime"; +import { getAllWorkspacesFromFiles, getAllWorkspacesFromApi, WorkspaceWithContext } from "./muxConfig"; +import { checkAuth, checkServerReachable } from "./api/connectionCheck"; +import { createApiClient } from "./api/client"; +import { + clearAuthTokenOverride, + discoverServerConfig, + getConnectionModeSetting, + storeAuthTokenOverride, + type ConnectionMode, +} from "./api/discovery"; +import { openWorkspace } from "./workspaceOpener"; + +let sessionPreferredMode: "api" | "file" | null = null; +let didShowFallbackPrompt = false; + +const ACTION_FIX_CONNECTION_CONFIG = "Fix connection config"; +const ACTION_USE_LOCAL_FILES = "Use local file access"; +const ACTION_CANCEL = "Cancel"; + +type ApiConnectionFailure = + | { kind: "unreachable"; baseUrl: string; error: string } + | { kind: "unauthorized"; baseUrl: string; error: string } + | { kind: "error"; baseUrl: string; error: string }; + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function describeFailure(failure: ApiConnectionFailure): string { + switch (failure.kind) { + case "unreachable": + return `mux server is not reachable at ${failure.baseUrl}`; + case "unauthorized": + return `mux server rejected the auth token at ${failure.baseUrl}`; + case "error": + return `mux server connection failed at ${failure.baseUrl}`; + } +} + +function getWarningSuffix(failure: ApiConnectionFailure): string { + if (failure.kind === "unauthorized") { + return "Using local file access while mux is running can cause inconsistencies."; + } + return "Using local file access can cause inconsistencies."; +} + +async function tryGetWorkspacesFromApi( + context: vscode.ExtensionContext +): Promise<{ workspaces: WorkspaceWithContext[] } | { failure: ApiConnectionFailure }> { + try { + const discovery = await discoverServerConfig(context); + const client = createApiClient({ baseUrl: discovery.baseUrl, authToken: discovery.authToken }); + + const reachable = await checkServerReachable(discovery.baseUrl); + if (reachable.status !== "ok") { + return { + failure: { + kind: "unreachable", + baseUrl: discovery.baseUrl, + error: reachable.error, + }, + }; + } + + const auth = await checkAuth(client); + if (auth.status === "unauthorized") { + return { + failure: { + kind: "unauthorized", + baseUrl: discovery.baseUrl, + error: auth.error, + }, + }; + } + if (auth.status !== "ok") { + return { + failure: { + kind: "error", + baseUrl: discovery.baseUrl, + error: auth.error, + }, + }; + } + + const workspaces = await getAllWorkspacesFromApi(client); + return { workspaces }; + } catch (error) { + return { + failure: { + kind: "error", + baseUrl: "unknown", + error: formatError(error), + }, + }; + } +} + +async function getWorkspacesForCommand( + context: vscode.ExtensionContext +): Promise { + const modeSetting: ConnectionMode = getConnectionModeSetting(); + + if (modeSetting === "file-only" || sessionPreferredMode === "file") { + sessionPreferredMode = "file"; + return getAllWorkspacesFromFiles(); + } + + const apiResult = await tryGetWorkspacesFromApi(context); + if ("workspaces" in apiResult) { + sessionPreferredMode = "api"; + return apiResult.workspaces; + } + + const failure = apiResult.failure; + + if (modeSetting === "server-only") { + const selection = await vscode.window.showErrorMessage( + `mux: ${describeFailure(failure)}. (${failure.error})`, + ACTION_FIX_CONNECTION_CONFIG + ); + + if (selection === ACTION_FIX_CONNECTION_CONFIG) { + await configureConnectionCommand(context); + } + + return null; + } + + // modeSetting is auto. + if (didShowFallbackPrompt) { + sessionPreferredMode = "file"; + void vscode.window.showWarningMessage( + `mux: ${describeFailure(failure)}. Falling back to local file access. Run "mux: Configure Connection" to fix.` + ); + return getAllWorkspacesFromFiles(); + } + + const selection = await vscode.window.showWarningMessage( + `mux: ${describeFailure(failure)}. ${getWarningSuffix(failure)}`, + ACTION_FIX_CONNECTION_CONFIG, + ACTION_USE_LOCAL_FILES, + ACTION_CANCEL + ); + + if (!selection || selection === ACTION_CANCEL) { + return null; + } + + didShowFallbackPrompt = true; + + if (selection === ACTION_USE_LOCAL_FILES) { + sessionPreferredMode = "file"; + return getAllWorkspacesFromFiles(); + } + + await configureConnectionCommand(context); + + const retry = await tryGetWorkspacesFromApi(context); + if ("workspaces" in retry) { + sessionPreferredMode = "api"; + return retry.workspaces; + } + + // Still can't connect; fall back without prompting again. + sessionPreferredMode = "file"; + void vscode.window.showWarningMessage( + `mux: ${describeFailure(retry.failure)}. Falling back to local file access. (${retry.failure.error})` + ); + return getAllWorkspacesFromFiles(); +} + /** * Get the icon for a runtime type * - local (project-dir): $(folder) - simple folder, uses project directly @@ -68,9 +238,12 @@ function createWorkspaceQuickPickItem( /** * Command: Open a mux workspace */ -async function openWorkspaceCommand() { +async function openWorkspaceCommand(context: vscode.ExtensionContext) { // Get all workspaces, this is intentionally not cached. - const workspaces = await getAllWorkspaces(); + const workspaces = await getWorkspacesForCommand(context); + if (!workspaces) { + return; + } if (workspaces.length === 0) { const selection = await vscode.window.showInformationMessage( @@ -138,17 +311,118 @@ async function openWorkspaceCommand() { await openWorkspace(selected.workspace); } +async function configureConnectionCommand(context: vscode.ExtensionContext): Promise { + const config = vscode.workspace.getConfiguration("mux"); + + // Small loop so users can set/clear both URL + token in one command. + // Keep UX minimal: no nested quick picks or extra commands. + for (;;) { + const currentUrl = config.get("serverUrl")?.trim() ?? ""; + const hasToken = (await context.secrets.get("mux.serverAuthToken")) !== undefined; + + const pick = await vscode.window.showQuickPick( + [ + { + label: "Set server URL", + description: currentUrl ? `Current: ${currentUrl}` : "Current: auto-discover", + }, + ...(currentUrl + ? ([{ label: "Clear server URL override", description: "Use env/lockfile/default" }] as const) + : ([] as const)), + { + label: "Set auth token", + description: hasToken ? "Current: set" : "Current: none", + }, + ...(hasToken ? ([{ label: "Clear auth token" }] as const) : ([] as const)), + { label: "Done" }, + ], + { placeHolder: "Configure mux server connection" } + ); + + if (!pick || pick.label === "Done") { + return; + } + + if (pick.label === "Set server URL") { + const value = await vscode.window.showInputBox({ + title: "mux server URL", + value: currentUrl, + prompt: "Example: http://127.0.0.1:3000 (leave blank for auto-discovery)", + validateInput(input) { + const trimmed = input.trim(); + if (!trimmed) { + return null; + } + try { + const url = new URL(trimmed); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return "URL must start with http:// or https://"; + } + return null; + } catch { + return "Invalid URL"; + } + }, + }); + + if (value === undefined) { + continue; + } + + const trimmed = value.trim(); + await config.update( + "serverUrl", + trimmed ? trimmed : undefined, + vscode.ConfigurationTarget.Global + ); + continue; + } + + if (pick.label === "Clear server URL override") { + await config.update("serverUrl", undefined, vscode.ConfigurationTarget.Global); + continue; + } + + if (pick.label === "Set auth token") { + const token = await vscode.window.showInputBox({ + title: "mux server auth token", + prompt: "Paste the mux server auth token", + password: true, + validateInput(input) { + return input.trim().length > 0 ? null : "Token cannot be empty"; + }, + }); + + if (token === undefined) { + continue; + } + + await storeAuthTokenOverride(context, token.trim()); + continue; + } + + if (pick.label === "Clear auth token") { + await clearAuthTokenOverride(context); + continue; + } + } +} + /** * Activate the extension */ export function activate(context: vscode.ExtensionContext) { - // Register the openWorkspace command - const disposable = vscode.commands.registerCommand("mux.openWorkspace", openWorkspaceCommand); + context.subscriptions.push( + vscode.commands.registerCommand("mux.openWorkspace", () => openWorkspaceCommand(context)) + ); - context.subscriptions.push(disposable); + context.subscriptions.push( + vscode.commands.registerCommand("mux.configureConnection", () => configureConnectionCommand(context)) + ); } /** * Deactivate the extension */ export function deactivate() {} + diff --git a/vscode/src/muxConfig.ts b/vscode/src/muxConfig.ts index 60899f7c94..a33113a75e 100644 --- a/vscode/src/muxConfig.ts +++ b/vscode/src/muxConfig.ts @@ -1,41 +1,25 @@ -import * as path from "path"; -import * as os from "os"; import { Config } from "mux/node/config"; -import type { WorkspaceMetadata } from "mux/common/types/workspace"; +import type { FrontendWorkspaceMetadata, WorkspaceActivitySnapshot } from "mux/common/types/workspace"; import { type ExtensionMetadata, readExtensionMetadata } from "mux/node/utils/extensionMetadata"; -import { getProjectName } from "mux/node/utils/runtime/helpers"; import { createRuntime } from "mux/node/runtime/runtimeFactory"; +import type { ApiClient } from "./api/client"; + /** * Workspace with extension metadata for display in VS Code extension. - * Combines workspace metadata from main app with extension-specific data. */ -export interface WorkspaceWithContext extends WorkspaceMetadata { - projectPath: string; +export interface WorkspaceWithContext extends FrontendWorkspaceMetadata { extensionMetadata?: ExtensionMetadata; } -/** - * Get all workspaces from mux config, enriched with extension metadata. - * Uses main app's Config class to read workspace metadata, then enriches - * with extension-specific data (recency, streaming status). - */ -export async function getAllWorkspaces(): Promise { - const config = new Config(); - const workspaces = await config.getAllWorkspaceMetadata(); - const extensionMeta = readExtensionMetadata(); - - console.log(`[mux] Read ${extensionMeta.size} entries from extension metadata`); - - // Enrich with extension metadata +function enrichAndSort( + workspaces: FrontendWorkspaceMetadata[], + extensionMeta: Map +): WorkspaceWithContext[] { const enriched: WorkspaceWithContext[] = workspaces.map((ws) => { - const meta = extensionMeta.get(ws.id); - if (meta) { - console.log(`[mux] ${ws.id}: recency=${meta.recency}, streaming=${meta.streaming}`); - } return { ...ws, - extensionMetadata: meta, + extensionMetadata: extensionMeta.get(ws.id), }; }); @@ -53,11 +37,35 @@ export async function getAllWorkspaces(): Promise { return enriched; } +export async function getAllWorkspacesFromFiles(): Promise { + const config = new Config(); + const workspaces = await config.getAllWorkspaceMetadata(); + const extensionMeta = readExtensionMetadata(); + return enrichAndSort(workspaces, extensionMeta); +} + +export async function getAllWorkspacesFromApi(client: ApiClient): Promise { + const workspaces = await client.workspace.list(); + const activityById: Record = await client.workspace.activity.list(); + + const extensionMeta = new Map(); + for (const [workspaceId, activity] of Object.entries(activityById)) { + extensionMeta.set(workspaceId, { + recency: activity.recency, + streaming: activity.streaming, + lastModel: activity.lastModel, + }); + } + + return enrichAndSort(workspaces, extensionMeta); +} + /** - * Get the workspace path for local or SSH workspaces - * Uses Runtime to compute path using main app's logic + * Get the workspace path for local or SSH workspaces. + * Uses Runtime to compute path using main app's logic. */ export function getWorkspacePath(workspace: WorkspaceWithContext): string { const runtime = createRuntime(workspace.runtimeConfig, { projectPath: workspace.projectPath }); return runtime.getWorkspacePath(workspace.projectPath, workspace.name); } +