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 @@ -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
231 changes: 150 additions & 81 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 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: fullNamePath, source: "not-found" };
}

/**
Expand Down Expand Up @@ -94,104 +134,128 @@ 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));

// Check existing binary version when one was found.
if (resolved.source !== "not-found") {
this.output.debug(
"Existing binary size is",
prettyBytes(resolved.stat.size),
);
try {
const version = await cliVersion(binPath);
const version = await cliVersion(resolved.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;
this.output.debug("Existing binary matches server version");
return resolved.binPath;
} else if (!enableDownloads) {
this.output.info(
"Using existing binary even though it does not match the server version because downloads are disabled",
"Using existing binary despite version mismatch because downloads are disabled",
);
return binPath;
return resolved.binPath;
}
this.output.info(
"Downloading since existing binary does not match the server version",
);
} 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 (!enableDownloads) {
this.output.warn("Unable to download CLI because downloads are disabled");
throw new Error("Unable to download CLI because downloads are disabled");
}

// 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
let needsDownload = true;
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;
needsDownload = false;
} else {
const latestParsedVersion = semver.parse(latestBuildInfo.version);
if (!latestParsedVersion) {
throw new Error(
`Got invalid version from deployment: ${latestBuildInfo.version}`,
);
}
latestVersion = latestParsedVersion;
}
}

// Parse the latest version for download
const latestParsedVersion = semver.parse(latestBuildInfo.version);
if (!latestParsedVersion) {
throw new Error(
`Got invalid version from deployment: ${latestBuildInfo.version}`,
);
}
latestVersion = latestParsedVersion;
if (needsDownload) {
await this.performBinaryDownload(
restClient,
latestVersion,
downloadBinPath,
progressLogPath,
);
}

return await this.performBinaryDownload(
restClient,
latestVersion,
binPath,
progressLogPath,
);
// Rename to user-configured file path while we hold the lock.
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;
} 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 @@ -280,54 +344,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;
}
if (
!check.matches &&
!(await this.promptUseExistingBinary(check.version, message))
) {
throw error;
}
// Version mismatch - prompt user
if (await this.promptUseExistingBinary(existingCheck.version, message)) {
return binPath;
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: try the most recent .old-* backup.
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 +420,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