Skip to content

Commit 5599ed5

Browse files
committed
refactor: derive safeHostname internally from url instead of passing both
Make url the single source of truth for deployment identity. fetchBinary, configure, and clearCredentials now derive safeHostname via toSafeHost() internally, eliminating redundant parameters and preventing inconsistency. BinaryResolver and CliCredentialManager methods take url string instead of Deployment object.
1 parent 2af85ac commit 5599ed5

File tree

11 files changed

+316
-220
lines changed

11 files changed

+316
-220
lines changed

src/commands.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -214,13 +214,12 @@ export class Commands {
214214
this.logger.debug("Logging out");
215215

216216
const deployment = this.deploymentManager.getCurrentDeployment();
217-
const safeHostname = deployment?.safeHostname;
218217

219218
await this.deploymentManager.clearDeployment();
220219

221-
if (safeHostname) {
222-
await this.cliManager.clearCredentials(safeHostname);
223-
await this.secretsManager.clearAllAuthData(safeHostname);
220+
if (deployment) {
221+
await this.cliManager.clearCredentials(deployment.url);
222+
await this.secretsManager.clearAllAuthData(deployment.safeHostname);
224223
}
225224

226225
vscode.window
@@ -287,7 +286,10 @@ export class Commands {
287286

288287
if (selected.hostnames.length === 1) {
289288
const selectedHostname = selected.hostnames[0];
290-
await this.cliManager.clearCredentials(selectedHostname);
289+
const auth = await this.secretsManager.getSessionAuth(selectedHostname);
290+
if (auth?.url) {
291+
await this.cliManager.clearCredentials(auth.url);
292+
}
291293
await this.secretsManager.clearAllAuthData(selectedHostname);
292294
this.logger.info("Removed credentials for", selectedHostname);
293295
vscode.window.showInformationMessage(
@@ -306,7 +308,10 @@ export class Commands {
306308
if (confirm === "Remove All") {
307309
await Promise.all(
308310
selected.hostnames.map(async (h) => {
309-
await this.cliManager.clearCredentials(h);
311+
const auth = await this.secretsManager.getSessionAuth(h);
312+
if (auth?.url) {
313+
await this.cliManager.clearCredentials(auth.url);
314+
}
310315
await this.secretsManager.clearAllAuthData(h);
311316
}),
312317
);
@@ -455,7 +460,6 @@ export class Commands {
455460
const safeHost = toSafeHost(baseUrl);
456461
const binary = await this.cliManager.fetchBinary(
457462
this.extensionClient,
458-
safeHost,
459463
);
460464

461465
const version = semver.parse(await cliUtils.version(binary));

src/core/cliCredentialManager.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ import type { Logger } from "../logging/logger";
99

1010
const execFileAsync = promisify(execFile);
1111

12+
/**
13+
* Resolves a CLI binary path for a given deployment URL, fetching/downloading
14+
* if needed. Returns the path or throws if unavailable.
15+
*/
16+
export type BinaryResolver = (url: string) => Promise<string>;
17+
1218
/**
1319
* Returns true on platforms where the OS keyring is supported (macOS, Windows).
1420
*/
@@ -18,9 +24,15 @@ export function isKeyringSupported(): boolean {
1824

1925
/**
2026
* Delegates credential storage to the Coder CLI to keep the credentials in sync.
27+
*
28+
* For operations that don't have a binary path available (readToken, deleteToken),
29+
* uses the injected BinaryResolver to fetch/locate the CLI binary.
2130
*/
2231
export class CliCredentialManager {
23-
constructor(private readonly logger: Logger) {}
32+
constructor(
33+
private readonly logger: Logger,
34+
private readonly resolveBinary: BinaryResolver,
35+
) {}
2436

2537
/**
2638
* Store a token by running:
@@ -29,7 +41,7 @@ export class CliCredentialManager {
2941
* The token is passed via environment variable so it never appears in
3042
* process argument lists.
3143
*/
32-
async storeToken(
44+
public async storeToken(
3345
binPath: string,
3446
url: string,
3547
token: string,
@@ -56,13 +68,21 @@ export class CliCredentialManager {
5668
* Read a token by running:
5769
* <bin> login token --url <url>
5870
*
59-
* Returns trimmed stdout, or undefined on any failure.
71+
* Resolves the CLI binary automatically. Returns trimmed stdout,
72+
* or undefined if the binary can't be resolved or the CLI returns no token.
6073
*/
61-
async readToken(
62-
binPath: string,
74+
public async readToken(
6375
url: string,
6476
configs: Pick<WorkspaceConfiguration, "get">,
6577
): Promise<string | undefined> {
78+
let binPath: string;
79+
try {
80+
binPath = await this.resolveBinary(url);
81+
} catch (error) {
82+
this.logger.warn("Could not resolve CLI binary for token read:", error);
83+
return undefined;
84+
}
85+
6686
const args = [...getHeaderArgs(configs), "login", "token", "--url", url];
6787
try {
6888
const { stdout } = await execFileAsync(binPath, args);
@@ -73,4 +93,32 @@ export class CliCredentialManager {
7393
return undefined;
7494
}
7595
}
96+
97+
/**
98+
* Delete a token by running:
99+
* <bin> logout --url <url>
100+
*
101+
* Resolves the CLI binary automatically. Best-effort: logs warnings
102+
* on failure but never throws.
103+
*/
104+
async deleteToken(
105+
url: string,
106+
configs: Pick<WorkspaceConfiguration, "get">,
107+
): Promise<void> {
108+
let binPath: string;
109+
try {
110+
binPath = await this.resolveBinary(url);
111+
} catch (error) {
112+
this.logger.warn("Could not resolve CLI binary for token delete:", error);
113+
return;
114+
}
115+
116+
const args = [...getHeaderArgs(configs), "logout", "--url", url, "--yes"];
117+
try {
118+
await execFileAsync(binPath, args);
119+
this.logger.info("Deleted token via CLI for", url);
120+
} catch (error) {
121+
this.logger.warn("Failed to delete token via CLI:", error);
122+
}
123+
}
76124
}

src/core/cliManager.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ import * as semver from "semver";
1010
import * as vscode from "vscode";
1111

1212
import { errToStr } from "../api/api-helper";
13-
import { shouldUseKeyring } from "../cliConfig";
13+
import { isKeyringEnabled, shouldUseKeyring } from "../cliConfig";
1414
import * as pgp from "../pgp";
15+
import { toSafeHost } from "../util";
1516
import { vscodeProposed } from "../vscodeProposed";
1617

1718
import { BinaryLock } from "./binaryLock";
@@ -49,10 +50,12 @@ export class CliManager {
4950
* unable to download a working binary, whether because of network issues or
5051
* downloads being disabled.
5152
*/
52-
public async fetchBinary(
53-
restClient: Api,
54-
safeHostname: string,
55-
): Promise<string> {
53+
public async fetchBinary(restClient: Api): Promise<string> {
54+
const baseUrl = restClient.getAxiosInstance().defaults.baseURL;
55+
if (!baseUrl) {
56+
throw new Error("REST client has no base URL configured");
57+
}
58+
const safeHostname = toSafeHost(baseUrl);
5659
const cfg = vscode.workspace.getConfiguration("coder");
5760
// Settings can be undefined when set to their defaults (true in this case),
5861
// so explicitly check against false.
@@ -719,7 +722,6 @@ export class CliManager {
719722
* authentication) but the URL must be a non-empty string.
720723
*/
721724
public async configure(
722-
safeHostname: string,
723725
url: string,
724726
token: string,
725727
featureSet: FeatureSet,
@@ -728,6 +730,7 @@ export class CliManager {
728730
if (!url) {
729731
throw new Error("URL is required to configure the CLI");
730732
}
733+
const safeHostname = toSafeHost(url);
731734

732735
const configs = vscode.workspace.getConfiguration();
733736
if (shouldUseKeyring(configs, featureSet)) {
@@ -765,12 +768,17 @@ export class CliManager {
765768
}
766769

767770
/**
768-
* Remove file-based credentials for a deployment. Keyring entries are not
769-
* removed here because deleting requires the CLI binary, which may not be
770-
* available at logout time. This is fine: stale keyring entries are harmless
771-
* since the CLI overwrites them on next `coder login`.
771+
* Remove credentials for a deployment. Clears both file-based credentials
772+
* and keyring entries (via `coder logout`). Keyring deletion is best-effort:
773+
* if it fails, file cleanup still runs.
772774
*/
773-
public async clearCredentials(safeHostname: string): Promise<void> {
775+
public async clearCredentials(url: string): Promise<void> {
776+
const safeHostname = toSafeHost(url);
777+
const configs = vscode.workspace.getConfiguration();
778+
if (isKeyringEnabled(configs)) {
779+
await this.cliCredentialManager.deleteToken(url, configs);
780+
}
781+
774782
const tokenPath = this.pathResolver.getSessionTokenPath(safeHostname);
775783
const urlPath = this.pathResolver.getUrlPath(safeHostname);
776784
await Promise.all([

src/core/container.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as vscode from "vscode";
22

3+
import { CoderApi } from "../api/coderApi";
34
import { type Logger } from "../logging/logger";
45
import { LoginCoordinator } from "../login/loginCoordinator";
56

@@ -36,7 +37,13 @@ export class ServiceContainer implements vscode.Disposable {
3637
context.globalState,
3738
this.logger,
3839
);
39-
this.cliCredentialManager = new CliCredentialManager(this.logger);
40+
this.cliCredentialManager = new CliCredentialManager(
41+
this.logger,
42+
async (url) => {
43+
const client = CoderApi.create(url, "", this.logger);
44+
return this.cliManager.fetchBinary(client);
45+
},
46+
);
4047
this.cliManager = new CliManager(
4148
this.logger,
4249
this.pathResolver,
@@ -48,7 +55,6 @@ export class ServiceContainer implements vscode.Disposable {
4855
this.mementoManager,
4956
this.logger,
5057
this.cliCredentialManager,
51-
this.cliManager,
5258
context.extension.id,
5359
);
5460
}

src/login/loginCoordinator.ts

Lines changed: 5 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import * as vscode from "vscode";
44

55
import { CoderApi } from "../api/coderApi";
66
import { needToken } from "../api/utils";
7-
import { isKeyringEnabled } from "../cliConfig";
87
import { CertificateError } from "../error/certificateError";
98
import { OAuthAuthorizer } from "../oauth/authorizer";
109
import { buildOAuthTokenData } from "../oauth/utils";
@@ -14,7 +13,6 @@ import { vscodeProposed } from "../vscodeProposed";
1413
import type { User } from "coder/site/src/api/typesGenerated";
1514

1615
import type { CliCredentialManager } from "../core/cliCredentialManager";
17-
import type { CliManager } from "../core/cliManager";
1816
import type { MementoManager } from "../core/mementoManager";
1917
import type { OAuthTokenData, SecretsManager } from "../core/secretsManager";
2018
import type { Deployment } from "../deployment/types";
@@ -43,7 +41,6 @@ export class LoginCoordinator implements vscode.Disposable {
4341
private readonly mementoManager: MementoManager,
4442
private readonly logger: Logger,
4543
private readonly cliCredentialManager: CliCredentialManager,
46-
private readonly cliManager: CliManager,
4744
extensionId: string,
4845
) {
4946
this.oauthAuthorizer = new OAuthAuthorizer(
@@ -306,52 +303,15 @@ export class LoginCoordinator implements vscode.Disposable {
306303
}
307304

308305
/**
309-
* Read a token from the CLI keyring. Fetches the CLI binary first (using
310-
* an unauthenticated client) so the binary is available for keyring reads.
311-
* Returns undefined if the keyring is disabled, the binary can't be fetched,
312-
* or the CLI returns no token.
306+
* Read a token from the CLI keyring.
313307
*/
314308
private async getCliKeyringToken(
315309
deployment: Deployment,
316310
): Promise<string | undefined> {
317-
if (!isKeyringEnabled(vscode.workspace.getConfiguration())) {
318-
return undefined;
319-
}
320-
try {
321-
const binPath = await this.ensureBinaryForKeyring(
322-
deployment.url,
323-
deployment.safeHostname,
324-
);
325-
if (!binPath) {
326-
return undefined;
327-
}
328-
return await this.cliCredentialManager.readToken(
329-
binPath,
330-
deployment.url,
331-
vscode.workspace.getConfiguration(),
332-
);
333-
} catch (error) {
334-
this.logger.warn("Failed to read token from CLI keyring", error);
335-
return undefined;
336-
}
337-
}
338-
339-
/**
340-
* Fetch or locate a CLI binary for the given deployment. Uses an
341-
* unauthenticated client since getBuildInfo and binary downloads
342-
* don't require auth.
343-
*/
344-
private async ensureBinaryForKeyring(
345-
url: string,
346-
safeHostname: string,
347-
): Promise<string | undefined> {
348-
try {
349-
const client = CoderApi.create(url, "", this.logger);
350-
return await this.cliManager.fetchBinary(client, safeHostname);
351-
} catch (error) {
352-
this.logger.warn("Could not fetch CLI binary for keyring read:", error);
353-
return undefined;
354-
}
311+
return this.cliCredentialManager.readToken(
312+
deployment.url,
313+
vscode.workspace.getConfiguration(),
314+
);
355315
}
356316

357317
/**

src/remote/remote.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -215,21 +215,15 @@ export class Remote {
215215
if (
216216
this.extensionContext.extensionMode === vscode.ExtensionMode.Production
217217
) {
218-
binaryPath = await this.cliManager.fetchBinary(
219-
workspaceClient,
220-
parts.safeHostname,
221-
);
218+
binaryPath = await this.cliManager.fetchBinary(workspaceClient);
222219
} else {
223220
try {
224221
// In development, try to use `/tmp/coder` as the binary path.
225222
// This is useful for debugging with a custom bin!
226223
binaryPath = path.join(os.tmpdir(), "coder");
227224
await fs.stat(binaryPath);
228225
} catch {
229-
binaryPath = await this.cliManager.fetchBinary(
230-
workspaceClient,
231-
parts.safeHostname,
232-
);
226+
binaryPath = await this.cliManager.fetchBinary(workspaceClient);
233227
}
234228
}
235229

@@ -248,7 +242,6 @@ export class Remote {
248242
// Write token to keyring or file (after CLI version is known)
249243
if (baseUrlRaw && token !== undefined) {
250244
await this.cliManager.configure(
251-
parts.safeHostname,
252245
baseUrlRaw,
253246
token,
254247
featureSet,
@@ -265,7 +258,6 @@ export class Remote {
265258
if (auth?.url) {
266259
try {
267260
await this.cliManager.configure(
268-
parts.safeHostname,
269261
auth.url,
270262
auth.token,
271263
featureSet,

test/mocks/testHelpers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,7 @@ export function createMockCliCredentialManager(): CliCredentialManager {
380380
return {
381381
storeToken: vi.fn().mockResolvedValue(undefined),
382382
readToken: vi.fn().mockResolvedValue(undefined),
383+
deleteToken: vi.fn().mockResolvedValue(undefined),
383384
} as unknown as CliCredentialManager;
384385
}
385386

0 commit comments

Comments
 (0)