Skip to content
Open
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@
"default": ""
},
"coder.proxyLogDirectory": {
"markdownDescription": "If set, the Coder CLI will output extra SSH information into this directory, which can be helpful for debugging connectivity issues.",
"markdownDescription": "Directory where the Coder CLI outputs SSH connection logs for debugging. Defaults to the value of `CODER_SSH_LOG_DIR` if not set, otherwise the extension's global storage directory.",
"type": "string",
"default": ""
},
Expand Down
66 changes: 24 additions & 42 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,51 +150,33 @@ export class Commands {
return this.openFile(this.workspaceLogPath);
}

const logDir = vscode.workspace
.getConfiguration()
.get<string>("coder.proxyLogDirectory");
if (logDir) {
try {
const files = await fs.readdir(logDir);
// Sort explicitly since fs.readdir order is platform-dependent
const logFiles = files
.filter((f) => f.endsWith(".log"))
.sort((a, b) => a.localeCompare(b))
.reverse();

if (logFiles.length === 0) {
vscode.window.showInformationMessage(
"No log files found in the configured log directory.",
);
return;
}
const logDir = this.pathResolver.getProxyLogPath();
try {
const files = await fs.readdir(logDir);
// Sort explicitly since fs.readdir order is platform-dependent
const logFiles = files
.filter((f) => f.endsWith(".log"))
.sort((a, b) => a.localeCompare(b))
.reverse();

if (logFiles.length === 0) {
vscode.window.showInformationMessage(
"No log files found in the log directory.",
);
return;
}

const selected = await vscode.window.showQuickPick(logFiles, {
title: "Select a log file to view",
});
const selected = await vscode.window.showQuickPick(logFiles, {
title: "Select a log file to view",
});

if (selected) {
await this.openFile(path.join(logDir, selected));
}
} catch (error) {
vscode.window.showErrorMessage(
`Failed to read log directory: ${error instanceof Error ? error.message : String(error)}`,
);
if (selected) {
await this.openFile(path.join(logDir, selected));
}
} else {
vscode.window
.showInformationMessage(
"No logs available. Make sure to set coder.proxyLogDirectory to get logs.",
"Open Settings",
)
.then((action) => {
if (action === "Open Settings") {
vscode.commands.executeCommand(
"workbench.action.openSettings",
"coder.proxyLogDirectory",
);
}
});
} catch (error) {
vscode.window.showErrorMessage(
`Failed to read log directory: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

Expand Down
46 changes: 32 additions & 14 deletions src/core/pathResolver.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as path from "node:path";
import * as vscode from "vscode";

import { expandPath } from "../util";

export class PathResolver {
constructor(
private readonly basePath: string,
Expand Down Expand Up @@ -30,15 +32,12 @@ export class PathResolver {
* The caller must ensure this directory exists before use.
*/
public getBinaryCachePath(safeHostname: string): string {
const settingPath = vscode.workspace
.getConfiguration()
.get<string>("coder.binaryDestination")
?.trim();
const binaryPath =
settingPath || process.env.CODER_BINARY_DESTINATION?.trim();
return binaryPath
? path.normalize(binaryPath)
: path.join(this.getGlobalConfigDir(safeHostname), "bin");
return (
PathResolver.resolveOverride(
"coder.binaryDestination",
"CODER_BINARY_DESTINATION",
) || path.join(this.getGlobalConfigDir(safeHostname), "bin")
);
}

/**
Expand All @@ -51,14 +50,19 @@ export class PathResolver {
}

/**
* Return the path where log data from the connection is stored.
* Return the proxy log directory from the `coder.proxyLogDirectory` setting
* or the `CODER_SSH_LOG_DIR` environment variable, falling back to the `log`
* subdirectory inside the extension's global storage path.
*
* The CLI will write files here named after the process PID.
*
* Note: This directory is not currently used.
*/
public getLogPath(): string {
return path.join(this.basePath, "log");
public getProxyLogPath(): string {
return (
PathResolver.resolveOverride(
"coder.proxyLogDirectory",
"CODER_SSH_LOG_DIR",
) || path.join(this.basePath, "log")
);
}

/**
Expand Down Expand Up @@ -117,4 +121,18 @@ export class PathResolver {
public getCodeLogDir(): string {
return this.codeLogPath;
}

/**
* Read a path from a VS Code setting then an environment variable, returning
* the first non-empty value after trimming, tilde/variable expansion, and
* normalization. Returns an empty string when neither source provides a path.
*/
private static resolveOverride(setting: string, envVar: string): string {
const fromSetting = expandPath(
vscode.workspace.getConfiguration().get<string>(setting)?.trim() ?? "",
);
const resolved =
fromSetting || expandPath(process.env[envVar]?.trim() ?? "");
return resolved ? path.normalize(resolved) : "";
}
}
13 changes: 3 additions & 10 deletions src/remote/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ import { OAuthSessionManager } from "../oauth/sessionManager";
import {
AuthorityPrefix,
escapeCommandArg,
expandPath,
parseRemoteAuthority,
} from "../util";
import { vscodeProposed } from "../vscodeProposed";
Expand Down Expand Up @@ -701,22 +700,16 @@ export class Remote {
}

/**
* Return the --log-dir argument value for the ProxyCommand. It may be an
* empty string if the setting is not set or the cli does not support it.
* Return the --log-dir argument value for the ProxyCommand, or an empty
* string when the CLI does not support it.
*
* Value defined in the "coder.sshFlags" setting is not considered.
*/
private getLogDir(featureSet: FeatureSet): string {
if (!featureSet.proxyLogDirectory) {
return "";
}
// If the proxyLogDirectory is not set in the extension settings we don't send one.
return expandPath(
String(
vscode.workspace.getConfiguration().get("coder.proxyLogDirectory") ??
"",
).trim(),
);
return this.pathResolver.getProxyLogPath();
}

/**
Expand Down
70 changes: 69 additions & 1 deletion test/unit/core/pathResolver.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as path from "path";
import { beforeEach, describe, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { PathResolver } from "@/core/pathResolver";

Expand Down Expand Up @@ -28,6 +28,60 @@ describe("PathResolver", () => {
expectPathsEqual(pathResolver.getUrlPath(""), path.join(basePath, "url"));
});

describe("getProxyLogPath", () => {
const defaultLogPath = path.join(basePath, "log");

it.each([
{ setting: "/custom/log/dir", expected: "/custom/log/dir" },
{ setting: "", expected: defaultLogPath },
{ setting: " ", expected: defaultLogPath },
{ setting: undefined, expected: defaultLogPath },
])(
"should return $expected when setting is '$setting'",
({ setting, expected }) => {
if (setting !== undefined) {
mockConfig.set("coder.proxyLogDirectory", setting);
}
expectPathsEqual(pathResolver.getProxyLogPath(), expected);
},
);

it("should expand tilde and ${userHome} in configured path", () => {
mockConfig.set("coder.proxyLogDirectory", "~/logs");
expect(pathResolver.getProxyLogPath()).not.toContain("~");

mockConfig.set("coder.proxyLogDirectory", "${userHome}/logs");
expect(pathResolver.getProxyLogPath()).not.toContain("${userHome}");
});

it("should normalize configured path", () => {
mockConfig.set("coder.proxyLogDirectory", "/custom/../log/./dir");
expectPathsEqual(pathResolver.getProxyLogPath(), "/log/dir");
});

it("should use CODER_SSH_LOG_DIR environment variable with proper precedence", () => {
// Use the global storage when the environment variable and setting are unset/blank
vi.stubEnv("CODER_SSH_LOG_DIR", "");
mockConfig.set("coder.proxyLogDirectory", "");
expectPathsEqual(pathResolver.getProxyLogPath(), defaultLogPath);

// Test environment variable takes precedence over global storage
vi.stubEnv("CODER_SSH_LOG_DIR", " /env/log/path ");
expectPathsEqual(pathResolver.getProxyLogPath(), "/env/log/path");

// Test setting takes precedence over environment variable
mockConfig.set("coder.proxyLogDirectory", " /setting/log/path ");
expectPathsEqual(pathResolver.getProxyLogPath(), "/setting/log/path");
});

it("should expand tilde in CODER_SSH_LOG_DIR", () => {
vi.stubEnv("CODER_SSH_LOG_DIR", "~/logs");
const result = pathResolver.getProxyLogPath();
expect(result).not.toContain("~");
expect(result).toContain("logs");
});
});

describe("getBinaryCachePath", () => {
it("should use custom binary destination when configured", () => {
mockConfig.set("coder.binaryDestination", "/custom/binary/path");
Expand All @@ -54,6 +108,20 @@ describe("PathResolver", () => {
);
});

it("should expand tilde in configured path", () => {
mockConfig.set("coder.binaryDestination", "~/bin");
const result = pathResolver.getBinaryCachePath("deployment");
expect(result).not.toContain("~");
expect(result).toContain("bin");
});

it("should expand tilde in CODER_BINARY_DESTINATION", () => {
vi.stubEnv("CODER_BINARY_DESTINATION", "~/bin");
const result = pathResolver.getBinaryCachePath("deployment");
expect(result).not.toContain("~");
expect(result).toContain("bin");
});

it("should use CODER_BINARY_DESTINATION environment variable with proper precedence", () => {
// Use the global storage when the environment variable and setting are unset/blank
vi.stubEnv("CODER_BINARY_DESTINATION", "");
Expand Down