Skip to content

Commit 33af7b9

Browse files
committed
Upgrade to latest Coder
We need this to create standalone clients so we can support multiple deployments. Currently the plugin always uses the url and token for the current deployment, but you might be trying to launch a previous workspace that belongs to a different deployment (from the recents projects menu for example). Also, lots of the code relies on the url and token being stored and accessed globally (storage.getURL(), storage.getSessionToken()) so avoid those as much as possible (only use when creating a client, basically).
1 parent 916d769 commit 33af7b9

8 files changed

+368
-262
lines changed

src/api.ts

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { Api } from "coder/site/src/api/api"
2+
import fs from "fs/promises"
3+
import * as https from "https"
4+
import * as os from "os"
5+
import * as vscode from "vscode"
6+
import { CertificateError } from "./error"
7+
import { Storage } from "./storage"
8+
9+
// expandPath will expand ${userHome} in the input string.
10+
const expandPath = (input: string): string => {
11+
const userHome = os.homedir()
12+
return input.replace(/\${userHome}/g, userHome)
13+
}
14+
15+
/**
16+
* Create an sdk instance using the provided URL and token and hook it up to
17+
* configuration. The token may be undefined if some other form of
18+
* authentication is being used.
19+
*/
20+
export async function makeCoderSdk(baseUrl: string, token: string | undefined, storage: Storage): Promise<Api> {
21+
const restClient = new Api()
22+
restClient.setHost(baseUrl)
23+
if (token) {
24+
restClient.setSessionToken(token)
25+
}
26+
27+
restClient.getAxiosInstance().interceptors.request.use(async (config) => {
28+
// Add headers from the header command.
29+
Object.entries(await storage.getHeaders(baseUrl)).forEach(([key, value]) => {
30+
config.headers[key] = value
31+
})
32+
33+
// Configure TLS.
34+
const cfg = vscode.workspace.getConfiguration()
35+
const insecure = Boolean(cfg.get("coder.insecure"))
36+
const certFile = expandPath(String(cfg.get("coder.tlsCertFile") ?? "").trim())
37+
const keyFile = expandPath(String(cfg.get("coder.tlsKeyFile") ?? "").trim())
38+
const caFile = expandPath(String(cfg.get("coder.tlsCaFile") ?? "").trim())
39+
40+
config.httpsAgent = new https.Agent({
41+
cert: certFile === "" ? undefined : await fs.readFile(certFile),
42+
key: keyFile === "" ? undefined : await fs.readFile(keyFile),
43+
ca: caFile === "" ? undefined : await fs.readFile(caFile),
44+
// rejectUnauthorized defaults to true, so we need to explicitly set it to false
45+
// if we want to allow self-signed certificates.
46+
rejectUnauthorized: !insecure,
47+
})
48+
49+
return config
50+
})
51+
52+
// Wrap certificate errors.
53+
restClient.getAxiosInstance().interceptors.response.use(
54+
(r) => r,
55+
async (err) => {
56+
throw await CertificateError.maybeWrap(err, baseUrl, storage)
57+
},
58+
)
59+
60+
return restClient
61+
}

src/commands.ts

+114-66
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import axios from "axios"
2-
import { getAuthenticatedUser, getWorkspaces, updateWorkspaceVersion } from "coder/site/src/api/api"
3-
import { Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated"
1+
import { Api } from "coder/site/src/api/api"
2+
import { getErrorMessage } from "coder/site/src/api/errors"
3+
import { User, Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated"
44
import * as vscode from "vscode"
55
import { extractAgents } from "./api-helper"
66
import { CertificateError } from "./error"
@@ -11,6 +11,7 @@ import { OpenableTreeItem } from "./workspacesProvider"
1111
export class Commands {
1212
public constructor(
1313
private readonly vscodeProposed: typeof vscode,
14+
private readonly restClient: Api,
1415
private readonly storage: Storage,
1516
) {}
1617

@@ -82,7 +83,9 @@ export class Commands {
8283
if (!url) {
8384
return
8485
}
86+
this.restClient.setHost(url)
8587

88+
let user: User | undefined
8689
let token: string | undefined = args.length >= 2 ? args[1] : undefined
8790
if (!token) {
8891
const opened = await vscode.env.openExternal(vscode.Uri.parse(`${url}/cli-auth`))
@@ -97,74 +100,72 @@ export class Commands {
97100
placeHolder: "Copy your API key from the opened browser page.",
98101
value: await this.storage.getSessionToken(),
99102
ignoreFocusOut: true,
100-
validateInput: (value) => {
101-
return axios
102-
.get("/api/v2/users/me", {
103-
baseURL: url,
104-
headers: {
105-
"Coder-Session-Token": value,
106-
},
107-
})
108-
.then(() => {
109-
return undefined
110-
})
111-
.catch((err) => {
112-
if (err instanceof CertificateError) {
113-
err.showNotification()
114-
115-
return {
116-
message: err.x509Err || err.message,
117-
severity: vscode.InputBoxValidationSeverity.Error,
118-
}
119-
}
120-
// This could be something like the header command erroring or an
121-
// invalid session token.
122-
const message =
123-
err?.response?.data?.detail || err?.message || err?.response?.status || "no response from the server"
103+
validateInput: async (value) => {
104+
this.restClient.setSessionToken(value)
105+
try {
106+
user = await this.restClient.getAuthenticatedUser()
107+
if (!user) {
108+
throw new Error("Failed to get authenticated user")
109+
}
110+
} catch (err) {
111+
// For certificate errors show both a notification and add to the
112+
// text under the input box, since users sometimes miss the
113+
// notification.
114+
if (err instanceof CertificateError) {
115+
err.showNotification()
116+
124117
return {
125-
message: "Failed to authenticate: " + message,
118+
message: err.x509Err || err.message,
126119
severity: vscode.InputBoxValidationSeverity.Error,
127120
}
128-
})
121+
}
122+
// This could be something like the header command erroring or an
123+
// invalid session token.
124+
const message = getErrorMessage(err, "no response from the server")
125+
return {
126+
message: "Failed to authenticate: " + message,
127+
severity: vscode.InputBoxValidationSeverity.Error,
128+
}
129+
}
129130
},
130131
})
131132
}
132-
if (!token) {
133+
if (!token || !user) {
133134
return
134135
}
135136

137+
// Store these to be used in later sessions and in the cli.
136138
await this.storage.setURL(url)
137139
await this.storage.setSessionToken(token)
138-
try {
139-
const user = await getAuthenticatedUser()
140-
if (!user) {
141-
throw new Error("Failed to get authenticated user")
142-
}
143-
await vscode.commands.executeCommand("setContext", "coder.authenticated", true)
144-
if (user.roles.find((role) => role.name === "owner")) {
145-
await vscode.commands.executeCommand("setContext", "coder.isOwner", true)
146-
}
147-
vscode.window
148-
.showInformationMessage(
149-
`Welcome to Coder, ${user.username}!`,
150-
{
151-
detail: "You can now use the Coder extension to manage your Coder instance.",
152-
},
153-
"Open Workspace",
154-
)
155-
.then((action) => {
156-
if (action === "Open Workspace") {
157-
vscode.commands.executeCommand("coder.open")
158-
}
159-
})
160-
vscode.commands.executeCommand("coder.refreshWorkspaces")
161-
} catch (error) {
162-
vscode.window.showErrorMessage("Failed to authenticate with Coder: " + error)
140+
141+
await vscode.commands.executeCommand("setContext", "coder.authenticated", true)
142+
if (user.roles.find((role) => role.name === "owner")) {
143+
await vscode.commands.executeCommand("setContext", "coder.isOwner", true)
163144
}
145+
146+
vscode.window
147+
.showInformationMessage(
148+
`Welcome to Coder, ${user.username}!`,
149+
{
150+
detail: "You can now use the Coder extension to manage your Coder instance.",
151+
},
152+
"Open Workspace",
153+
)
154+
.then((action) => {
155+
if (action === "Open Workspace") {
156+
vscode.commands.executeCommand("coder.open")
157+
}
158+
})
159+
160+
// Fetch workspaces for the new deployment.
161+
vscode.commands.executeCommand("coder.refreshWorkspaces")
164162
}
165163

166-
// viewLogs opens the workspace logs.
164+
/**
165+
* View the logs for the currently logged-in deployment.
166+
*/
167167
public async viewLogs(): Promise<void> {
168+
// TODO: This will need to be refactored for multi-deployment support.
168169
if (!this.storage.workspaceLogPath) {
169170
vscode.window.showInformationMessage("No logs available.", this.storage.workspaceLogPath || "<unset>")
170171
return
@@ -174,48 +175,81 @@ export class Commands {
174175
await vscode.window.showTextDocument(doc)
175176
}
176177

178+
/**
179+
* Log out from the currently logged-in deployment.
180+
*/
177181
public async logout(): Promise<void> {
182+
// Clear from the REST client. An empty url will indicate to other parts of
183+
// the code that we are logged out.
184+
this.restClient.setHost("")
185+
this.restClient.setSessionToken("")
186+
187+
// Clear from memory.
178188
await this.storage.setURL(undefined)
179189
await this.storage.setSessionToken(undefined)
190+
180191
await vscode.commands.executeCommand("setContext", "coder.authenticated", false)
181192
vscode.window.showInformationMessage("You've been logged out of Coder!", "Login").then((action) => {
182193
if (action === "Login") {
183194
vscode.commands.executeCommand("coder.login")
184195
}
185196
})
197+
198+
// This will result in clearing the workspace list.
186199
vscode.commands.executeCommand("coder.refreshWorkspaces")
187200
}
188201

202+
/**
203+
* Create a new workspace for the currently logged-in deployment.
204+
*
205+
* Must only be called if currently logged in.
206+
*/
189207
public async createWorkspace(): Promise<void> {
190-
const uri = this.storage.getURL() + "/templates"
208+
const uri = this.storage.getUrl() + "/templates"
191209
await vscode.commands.executeCommand("vscode.open", uri)
192210
}
193211

212+
/**
213+
* Open a link to the workspace in the Coder dashboard.
214+
*
215+
* Must only be called if currently logged in.
216+
*/
194217
public async navigateToWorkspace(workspace: OpenableTreeItem) {
195218
if (workspace) {
196-
const uri = this.storage.getURL() + `/@${workspace.workspaceOwner}/${workspace.workspaceName}`
219+
const uri = this.storage.getUrl() + `/@${workspace.workspaceOwner}/${workspace.workspaceName}`
197220
await vscode.commands.executeCommand("vscode.open", uri)
198221
} else if (this.storage.workspace) {
199-
const uri = this.storage.getURL() + `/@${this.storage.workspace.owner_name}/${this.storage.workspace.name}`
222+
const uri = this.storage.getUrl() + `/@${this.storage.workspace.owner_name}/${this.storage.workspace.name}`
200223
await vscode.commands.executeCommand("vscode.open", uri)
201224
} else {
202225
vscode.window.showInformationMessage("No workspace found.")
203226
}
204227
}
205228

229+
/**
230+
* Open a link to the workspace settings in the Coder dashboard.
231+
*
232+
* Must only be called if currently logged in.
233+
*/
206234
public async navigateToWorkspaceSettings(workspace: OpenableTreeItem) {
207235
if (workspace) {
208-
const uri = this.storage.getURL() + `/@${workspace.workspaceOwner}/${workspace.workspaceName}/settings`
236+
const uri = this.storage.getUrl() + `/@${workspace.workspaceOwner}/${workspace.workspaceName}/settings`
209237
await vscode.commands.executeCommand("vscode.open", uri)
210238
} else if (this.storage.workspace) {
211239
const uri =
212-
this.storage.getURL() + `/@${this.storage.workspace.owner_name}/${this.storage.workspace.name}/settings`
240+
this.storage.getUrl() + `/@${this.storage.workspace.owner_name}/${this.storage.workspace.name}/settings`
213241
await vscode.commands.executeCommand("vscode.open", uri)
214242
} else {
215243
vscode.window.showInformationMessage("No workspace found.")
216244
}
217245
}
218246

247+
/**
248+
* Open a workspace or agent that is showing in the sidebar.
249+
*
250+
* This essentially just builds the host name and passes it to the VS Code
251+
* Remote SSH extension.
252+
*/
219253
public async openFromSidebar(treeItem: OpenableTreeItem) {
220254
if (treeItem) {
221255
await openWorkspace(
@@ -228,6 +262,11 @@ export class Commands {
228262
}
229263
}
230264

265+
/**
266+
* Open a workspace from the currently logged-in deployment.
267+
*
268+
* This must only be called if the REST client is logged in.
269+
*/
231270
public async open(...args: unknown[]): Promise<void> {
232271
let workspaceOwner: string
233272
let workspaceName: string
@@ -243,9 +282,10 @@ export class Commands {
243282
let lastWorkspaces: readonly Workspace[]
244283
quickPick.onDidChangeValue((value) => {
245284
quickPick.busy = true
246-
getWorkspaces({
247-
q: value,
248-
})
285+
this.restClient
286+
.getWorkspaces({
287+
q: value,
288+
})
249289
.then((workspaces) => {
250290
lastWorkspaces = workspaces.workspaces
251291
const items: vscode.QuickPickItem[] = workspaces.workspaces.map((workspace) => {
@@ -348,8 +388,12 @@ export class Commands {
348388
await openWorkspace(workspaceOwner, workspaceName, workspaceAgent, folderPath, openRecent)
349389
}
350390

391+
/**
392+
* Update the current workspace. If there is no active workspace connection,
393+
* this is a no-op.
394+
*/
351395
public async updateWorkspace(): Promise<void> {
352-
if (!this.storage.workspace) {
396+
if (!this.storage.workspace || !this.storage.restClient) {
353397
return
354398
}
355399
const action = await this.vscodeProposed.window.showInformationMessage(
@@ -362,11 +406,15 @@ export class Commands {
362406
"Update",
363407
)
364408
if (action === "Update") {
365-
await updateWorkspaceVersion(this.storage.workspace)
409+
await this.storage.restClient.updateWorkspaceVersion(this.storage.workspace)
366410
}
367411
}
368412
}
369413

414+
/**
415+
* Given a workspace, build the host name, find a directory to open, and pass
416+
* both to the Remote SSH plugin.
417+
*/
370418
async function openWorkspace(
371419
workspaceOwner: string,
372420
workspaceName: string,

0 commit comments

Comments
 (0)