Skip to content
Merged
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@
from published versions since it shows up in the VS Code extension changelog
tab and is confusing to users. Add it back between releases if needed. -->

## Unreleased

### Added

- `coder.binaryDestination` now accepts a full file path (e.g. `/usr/bin/coder`) in addition
to a directory. The extension checks the binary's version against the server and downloads a
replacement when needed. When set to a directory, the simple name (`coder` / `coder.exe`) is
tried as a fallback after the platform-specific name, so package-manager-installed CLIs work
without symlinking.

### Fixed

- Cleanup of old/temp files in shared directories like `/usr/bin` is now scoped to the binary's
own basename, preventing accidental removal of unrelated files.

## [v1.14.3](https://github.com/coder/vscode-coder/releases/tag/v1.14.3) 2026-03-30

### Added
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
"default": ""
},
"coder.binaryDestination": {
"markdownDescription": "The full path of the directory into which the Coder CLI will be downloaded. Defaults to the value of `CODER_BINARY_DESTINATION` if not set, otherwise the extension's global storage directory.",
"markdownDescription": "The path to the Coder CLI binary or the directory containing it. When set to a file path (e.g., `/usr/bin/coder`), the extension checks its version and downloads a replacement if it does not match the server (and downloads are enabled). When set to a directory, the extension looks for the CLI inside it (downloading if enabled). Defaults to the value of `CODER_BINARY_DESTINATION` if not set, otherwise the extension's global storage directory.",
"type": "string",
"default": ""
},
Expand Down
243 changes: 160 additions & 83 deletions src/core/cliManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import globalAxios, {
type AxiosInstance,
type AxiosRequestConfig,
} from "axios";
import { createWriteStream, type WriteStream } from "node:fs";
import { createWriteStream, type WriteStream, type Stats } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import prettyBytes from "pretty-bytes";
Expand All @@ -29,6 +29,10 @@ import type { Logger } from "../logging/logger";
import type { CliCredentialManager } from "./cliCredentialManager";
import type { PathResolver } from "./pathResolver";

type ResolvedBinary =
| { binPath: string; stat: Stats; source: "file-path" | "directory" }
| { binPath: string; source: "not-found" };

export class CliManager {
private readonly binaryLock: BinaryLock;

Expand All @@ -46,15 +50,51 @@ export class CliManager {
*/
public async locateBinary(url: string): Promise<string> {
const safeHostname = toSafeHost(url);
const binPath = path.join(
this.pathResolver.getBinaryCachePath(safeHostname),
cliUtils.name(),
);
const stat = await cliUtils.stat(binPath);
if (!stat) {
throw new Error(`No CLI binary found at ${binPath}`);
const resolved = await this.resolveBinaryPath(safeHostname);
if (resolved.source === "not-found") {
throw new Error(`No CLI binary found at ${resolved.binPath}`);
}
return resolved.binPath;
}

/**
* Resolve the CLI binary path from the configured cache path.
*
* Returns "file-path" when the cache path is an existing file (checked for
* version match and updated if needed), "directory" when a binary was found
* inside the directory, or "not-found" with the platform-specific path for
* the caller to download into.
*/
private async resolveBinaryPath(
safeHostname: string,
): Promise<ResolvedBinary> {
const cachePath = this.pathResolver.getBinaryCachePath(safeHostname);
const cacheStat = await cliUtils.stat(cachePath);

if (cacheStat?.isFile()) {
return { binPath: cachePath, stat: cacheStat, source: "file-path" };
}

const fullNamePath = path.join(cachePath, cliUtils.fullName());

// Path does not exist yet; return the platform-specific path to download.
if (!cacheStat) {
return { binPath: fullNamePath, source: "not-found" };
}

// Directory exists; check platform-specific name, then simple name.
const fullStat = await cliUtils.stat(fullNamePath);
if (fullStat) {
return { binPath: fullNamePath, stat: fullStat, source: "directory" };
}

const simpleNamePath = path.join(cachePath, cliUtils.simpleName());
const simpleStat = await cliUtils.stat(simpleNamePath);
if (simpleStat) {
return { binPath: simpleNamePath, stat: simpleStat, source: "directory" };
}
return binPath;

return { binPath: fullNamePath, source: "not-found" };
}

/**
Expand Down Expand Up @@ -94,82 +134,87 @@ export class CliManager {
);
}

// Check if there is an existing binary and whether it looks valid. If it
// is valid and matches the server, or if it does not match the server but
// downloads are disabled, we can return early.
const binPath = path.join(
this.pathResolver.getBinaryCachePath(safeHostname),
cliUtils.name(),
const resolved = await this.resolveBinaryPath(safeHostname);
this.output.debug(
`Resolved binary: ${resolved.binPath} (${resolved.source})`,
);
this.output.debug("Using binary path", binPath);
const stat = await cliUtils.stat(binPath);
if (stat === undefined) {
this.output.info("No existing binary found, starting download");
} else {
this.output.debug("Existing binary size is", prettyBytes(stat.size));

let existingVersion: string | null = null;
if (resolved.source !== "not-found") {
this.output.debug(
"Existing binary size is",
prettyBytes(resolved.stat.size),
);
try {
const version = await cliVersion(binPath);
this.output.debug("Existing binary version is", version);
// If we have the right version we can avoid the request entirely.
if (version === buildInfo.version) {
this.output.debug(
"Using existing binary since it matches the server version",
);
return binPath;
} else if (!enableDownloads) {
this.output.info(
"Using existing binary even though it does not match the server version because downloads are disabled",
);
return binPath;
}
this.output.info(
"Downloading since existing binary does not match the server version",
);
existingVersion = await cliVersion(resolved.binPath);
this.output.debug("Existing binary version is", existingVersion);
} catch (error) {
this.output.warn(
"Unable to get version of existing binary. Downloading new binary instead",
"Unable to get version of existing binary, downloading instead",
error,
);
}
} else {
this.output.info("No existing binary found, starting download");
}

if (existingVersion === buildInfo.version) {
this.output.debug("Existing binary matches server version");
return resolved.binPath;
}

if (!enableDownloads) {
if (existingVersion) {
this.output.info(
"Using existing binary despite version mismatch because downloads are disabled",
);
return resolved.binPath;
}
this.output.warn("Unable to download CLI because downloads are disabled");
throw new Error("Unable to download CLI because downloads are disabled");
}

if (existingVersion) {
this.output.info(
"Downloading since existing binary does not match the server version",
);
}

// Always download using the platform-specific name.
const downloadBinPath = path.join(
path.dirname(resolved.binPath),
cliUtils.fullName(),
);

// Create the `bin` folder if it doesn't exist
await fs.mkdir(path.dirname(binPath), { recursive: true });
const progressLogPath = binPath + ".progress.log";
await fs.mkdir(path.dirname(downloadBinPath), { recursive: true });
const progressLogPath = downloadBinPath + ".progress.log";

let lockResult:
| { release: () => Promise<void>; waited: boolean }
| undefined;
let latestVersion = parsedVersion;
try {
lockResult = await this.binaryLock.acquireLockOrWait(
binPath,
downloadBinPath,
progressLogPath,
);
this.output.debug("Acquired download lock");

// If we waited for another process, re-check if binary is now ready
// Another process may have finished the download while we waited.
if (lockResult.waited) {
const latestBuildInfo = await restClient.getBuildInfo();
this.output.debug("Got latest server version", latestBuildInfo.version);

const recheckAfterWait = await this.checkBinaryVersion(
binPath,
downloadBinPath,
latestBuildInfo.version,
);
if (recheckAfterWait.matches) {
this.output.debug(
"Using existing binary since it matches the latest server version",
);
return binPath;
this.output.debug("Binary already matches server version after wait");
return await this.renameToFinalPath(resolved, downloadBinPath);
}

// Parse the latest version for download
const latestParsedVersion = semver.parse(latestBuildInfo.version);
if (!latestParsedVersion) {
throw new Error(
Expand All @@ -179,19 +224,25 @@ export class CliManager {
latestVersion = latestParsedVersion;
}

return await this.performBinaryDownload(
await this.performBinaryDownload(
restClient,
latestVersion,
binPath,
downloadBinPath,
progressLogPath,
);
return await this.renameToFinalPath(resolved, downloadBinPath);
} catch (error) {
// Unified error handling - check for fallback binaries and prompt user
return await this.handleAnyBinaryFailure(
const fallback = await this.handleAnyBinaryFailure(
error,
binPath,
downloadBinPath,
buildInfo.version,
resolved.binPath !== downloadBinPath ? resolved.binPath : undefined,
);
// Move the fallback to the expected path if needed.
if (fallback !== resolved.binPath) {
await fs.rename(fallback, resolved.binPath);
}
return resolved.binPath;
} finally {
if (lockResult) {
await lockResult.release();
Expand Down Expand Up @@ -224,6 +275,27 @@ export class CliManager {
}
}

/**
* Rename the downloaded binary to the user-configured file path if needed.
*/
private async renameToFinalPath(
resolved: ResolvedBinary,
downloadBinPath: string,
): Promise<string> {
if (
resolved.source === "file-path" &&
downloadBinPath !== resolved.binPath
) {
this.output.info(
"Renaming downloaded binary to",
path.basename(resolved.binPath),
);
await fs.rename(downloadBinPath, resolved.binPath);
return resolved.binPath;
}
return downloadBinPath;
}

/**
* Prompt the user to use an existing binary version.
*/
Expand Down Expand Up @@ -280,54 +352,59 @@ export class CliManager {
}

/**
* Unified handler for any binary-related failure.
* Checks for existing or old binaries and prompts user once.
* Try fallback binaries after a download failure, prompting the user once
* if the best candidate is a version mismatch.
*/
private async handleAnyBinaryFailure(
error: unknown,
binPath: string,
expectedVersion: string,
fallbackBinPath?: string,
): Promise<string> {
const message =
error instanceof cliUtils.FileLockError
? "Unable to update the Coder CLI binary because it's in use"
: "Failed to update CLI binary";

// Try existing binary first
const existingCheck = await this.checkBinaryVersion(
binPath,
expectedVersion,
);
if (existingCheck.version) {
// Perfect match - use without prompting
if (existingCheck.matches) {
return binPath;
// Returns the path if usable, undefined if not found.
// Throws the original error if the user declines a mismatch.
const tryCandidate = async (
candidate: string,
): Promise<string | undefined> => {
const check = await this.checkBinaryVersion(candidate, expectedVersion);
if (!check.version) {
return undefined;
}
// Version mismatch - prompt user
if (await this.promptUseExistingBinary(existingCheck.version, message)) {
return binPath;
if (
!check.matches &&
!(await this.promptUseExistingBinary(check.version, message))
) {
throw error;
}
return candidate;
};

const primary = await tryCandidate(binPath);
if (primary) {
return primary;
}

if (fallbackBinPath) {
const fallback = await tryCandidate(fallbackBinPath);
if (fallback) {
return fallback;
}
throw error;
}

// Try .old-* binaries as fallback
// Last resort: most recent .old-* backup (deferred to avoid IO when unnecessary).
const oldBinaries = await cliUtils.findOldBinaries(binPath);
if (oldBinaries.length > 0) {
const oldCheck = await this.checkBinaryVersion(
oldBinaries[0],
expectedVersion,
);
if (
oldCheck.version &&
(oldCheck.matches ||
(await this.promptUseExistingBinary(oldCheck.version, message)))
) {
await fs.rename(oldBinaries[0], binPath);
return binPath;
const old = await tryCandidate(oldBinaries[0]);
if (old) {
return old;
}
}

// No fallback available or user declined - re-throw original error
throw error;
}

Expand All @@ -351,7 +428,7 @@ export class CliManager {
}

// Figure out where to get the binary.
const binName = cliUtils.name();
const binName = cliUtils.fullName();
const configSource = cfg.get<string>("binarySource");
const binSource = configSource?.trim() ? configSource : "/bin/" + binName;
this.output.info("Downloading binary from", binSource);
Expand Down
Loading