diff --git a/src/commands.ts b/src/commands.ts index 3506d822..d24df729 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -6,7 +6,7 @@ import { makeCoderSdk, needToken } from "./api" import { extractAgents } from "./api-helper" import { CertificateError } from "./error" import { Storage } from "./storage" -import { AuthorityPrefix, toSafeHost } from "./util" +import { toRemoteAuthority, toSafeHost } from "./util" import { OpenableTreeItem } from "./workspacesProvider" export class Commands { @@ -499,6 +499,26 @@ export class Commands { await openWorkspace(baseUrl, workspaceOwner, workspaceName, workspaceAgent, folderPath, openRecent) } + /** + * Open a devcontainer from a workspace belonging to the currently logged-in deployment. + * + * Throw if not logged into a deployment. + */ + public async openDevContainer(...args: string[]): Promise { + const baseUrl = this.restClient.getAxiosInstance().defaults.baseURL + if (!baseUrl) { + throw new Error("You are not logged in") + } + + const workspaceOwner = args[0] as string + const workspaceName = args[1] as string + const workspaceAgent = undefined // args[2] is reserved, but we do not support multiple agents yet. + const devContainerName = args[3] as string + const devContainerFolder = args[4] as string + + await openDevContainer(baseUrl, workspaceOwner, workspaceName, workspaceAgent, devContainerName, devContainerFolder) + } + /** * Update the current workspace. If there is no active workspace connection, * this is a no-op. @@ -536,10 +556,7 @@ async function openWorkspace( ) { // A workspace can have multiple agents, but that's handled // when opening a workspace unless explicitly specified. - let remoteAuthority = `ssh-remote+${AuthorityPrefix}.${toSafeHost(baseUrl)}--${workspaceOwner}--${workspaceName}` - if (workspaceAgent) { - remoteAuthority += `.${workspaceAgent}` - } + const remoteAuthority = toRemoteAuthority(baseUrl, workspaceOwner, workspaceName, workspaceAgent) let newWindow = true // Open in the existing window if no workspaces are open. @@ -598,3 +615,32 @@ async function openWorkspace( reuseWindow: !newWindow, }) } + +async function openDevContainer( + baseUrl: string, + workspaceOwner: string, + workspaceName: string, + workspaceAgent: string | undefined, + devContainerName: string, + devContainerFolder: string, +) { + const remoteAuthority = toRemoteAuthority(baseUrl, workspaceOwner, workspaceName, workspaceAgent) + + const devContainer = Buffer.from(JSON.stringify({ containerName: devContainerName }), "utf-8").toString("hex") + const devContainerAuthority = `attached-container+${devContainer}@${remoteAuthority}` + + let newWindow = true + if (!vscode.workspace.workspaceFolders?.length) { + newWindow = false + } + + await vscode.commands.executeCommand( + "vscode.openFolder", + vscode.Uri.from({ + scheme: "vscode-remote", + authority: devContainerAuthority, + path: devContainerFolder, + }), + newWindow, + ) +} diff --git a/src/extension.ts b/src/extension.ts index e5e2799a..0c04bf18 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -111,6 +111,61 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { await storage.configureCli(toSafeHost(url), url, token) vscode.commands.executeCommand("coder.open", owner, workspace, agent, folder, openRecent) + } else if (uri.path === "/openDevContainer") { + const workspaceOwner = params.get("owner") + const workspaceName = params.get("workspace") + const workspaceAgent = params.get("agent") + const devContainerName = params.get("devContainerName") + const devContainerFolder = params.get("devContainerFolder") + + if (!workspaceOwner) { + throw new Error("workspace owner must be specified as a query parameter") + } + + if (!workspaceName) { + throw new Error("workspace name must be specified as a query parameter") + } + + if (!devContainerName) { + throw new Error("dev container name must be specified as a query parameter") + } + + if (!devContainerFolder) { + throw new Error("dev container folder must be specified as a query parameter") + } + + // We are not guaranteed that the URL we currently have is for the URL + // this workspace belongs to, or that we even have a URL at all (the + // queries will default to localhost) so ask for it if missing. + // Pre-populate in case we do have the right URL so the user can just + // hit enter and move on. + const url = await commands.maybeAskUrl(params.get("url"), storage.getUrl()) + if (url) { + restClient.setHost(url) + await storage.setUrl(url) + } else { + throw new Error("url must be provided or specified as a query parameter") + } + + // If the token is missing we will get a 401 later and the user will be + // prompted to sign in again, so we do not need to ensure it is set now. + // For non-token auth, we write a blank token since the `vscodessh` + // command currently always requires a token file. However, if there is + // a query parameter for non-token auth go ahead and use it anyway; all + // that really matters is the file is created. + const token = needToken() ? params.get("token") : (params.get("token") ?? "") + + // Store on disk to be used by the cli. + await storage.configureCli(toSafeHost(url), url, token) + + vscode.commands.executeCommand( + "coder.openDevContainer", + workspaceOwner, + workspaceName, + workspaceAgent, + devContainerName, + devContainerFolder, + ) } else { throw new Error(`Unknown path ${uri.path}`) } @@ -123,6 +178,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { vscode.commands.registerCommand("coder.login", commands.login.bind(commands)) vscode.commands.registerCommand("coder.logout", commands.logout.bind(commands)) vscode.commands.registerCommand("coder.open", commands.open.bind(commands)) + vscode.commands.registerCommand("coder.openDevContainer", commands.openDevContainer.bind(commands)) vscode.commands.registerCommand("coder.openFromSidebar", commands.openFromSidebar.bind(commands)) vscode.commands.registerCommand("coder.workspace.update", commands.updateWorkspace.bind(commands)) vscode.commands.registerCommand("coder.createWorkspace", commands.createWorkspace.bind(commands)) diff --git a/src/util.ts b/src/util.ts index fd5af748..8253f152 100644 --- a/src/util.ts +++ b/src/util.ts @@ -61,6 +61,19 @@ export function parseRemoteAuthority(authority: string): AuthorityParts | null { } } +export function toRemoteAuthority( + baseUrl: string, + workspaceOwner: string, + workspaceName: string, + workspaceAgent: string | undefined, +): string { + let remoteAuthority = `ssh-remote+${AuthorityPrefix}.${toSafeHost(baseUrl)}--${workspaceOwner}--${workspaceName}` + if (workspaceAgent) { + remoteAuthority += `.${workspaceAgent}` + } + return remoteAuthority +} + /** * Given a URL, return the host in a format that is safe to write. */