Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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