Skip to content

Commit c2aa5ca

Browse files
authored
feat: allow binaryDestination to accept a full path to the CLI binary (#879)
The `coder.binaryDestination` setting now accepts a file path (e.g. `/usr/bin/coder`) in addition to a directory path. When set to a file, the extension checks its version against the server and downloads a replacement if needed, renaming it to the configured path under the lock. When set to a directory, it now also looks for the simple name (`coder` / `coder.exe`) as a fallback after the platform-specific name, so package-manager-installed CLIs work without symlinking. Cleanup operations in shared directories (like `/usr/bin`) are scoped to only files prefixed with the binary's own basename, preventing accidental removal of unrelated files. Resolves #861
1 parent 4dc85e1 commit c2aa5ca

File tree

7 files changed

+455
-217
lines changed

7 files changed

+455
-217
lines changed

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,21 @@
55
from published versions since it shows up in the VS Code extension changelog
66
tab and is confusing to users. Add it back between releases if needed. -->
77

8+
## Unreleased
9+
10+
### Added
11+
12+
- `coder.binaryDestination` now accepts a full file path (e.g. `/usr/bin/coder`) in addition
13+
to a directory. The extension checks the binary's version against the server and downloads a
14+
replacement when needed. When set to a directory, the simple name (`coder` / `coder.exe`) is
15+
tried as a fallback after the platform-specific name, so package-manager-installed CLIs work
16+
without symlinking.
17+
18+
### Fixed
19+
20+
- Cleanup of old/temp files in shared directories like `/usr/bin` is now scoped to the binary's
21+
own basename, preventing accidental removal of unrelated files.
22+
823
## [v1.14.3](https://github.com/coder/vscode-coder/releases/tag/v1.14.3) 2026-03-30
924

1025
### Added

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
"default": ""
6363
},
6464
"coder.binaryDestination": {
65-
"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.",
65+
"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.",
6666
"type": "string",
6767
"default": ""
6868
},

src/core/cliManager.ts

Lines changed: 160 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import globalAxios, {
22
type AxiosInstance,
33
type AxiosRequestConfig,
44
} from "axios";
5-
import { createWriteStream, type WriteStream } from "node:fs";
5+
import { createWriteStream, type WriteStream, type Stats } from "node:fs";
66
import fs from "node:fs/promises";
77
import path from "node:path";
88
import prettyBytes from "pretty-bytes";
@@ -29,6 +29,10 @@ import type { Logger } from "../logging/logger";
2929
import type { CliCredentialManager } from "./cliCredentialManager";
3030
import type { PathResolver } from "./pathResolver";
3131

32+
type ResolvedBinary =
33+
| { binPath: string; stat: Stats; source: "file-path" | "directory" }
34+
| { binPath: string; source: "not-found" };
35+
3236
export class CliManager {
3337
private readonly binaryLock: BinaryLock;
3438

@@ -46,15 +50,51 @@ export class CliManager {
4650
*/
4751
public async locateBinary(url: string): Promise<string> {
4852
const safeHostname = toSafeHost(url);
49-
const binPath = path.join(
50-
this.pathResolver.getBinaryCachePath(safeHostname),
51-
cliUtils.name(),
52-
);
53-
const stat = await cliUtils.stat(binPath);
54-
if (!stat) {
55-
throw new Error(`No CLI binary found at ${binPath}`);
53+
const resolved = await this.resolveBinaryPath(safeHostname);
54+
if (resolved.source === "not-found") {
55+
throw new Error(`No CLI binary found at ${resolved.binPath}`);
56+
}
57+
return resolved.binPath;
58+
}
59+
60+
/**
61+
* Resolve the CLI binary path from the configured cache path.
62+
*
63+
* Returns "file-path" when the cache path is an existing file (checked for
64+
* version match and updated if needed), "directory" when a binary was found
65+
* inside the directory, or "not-found" with the platform-specific path for
66+
* the caller to download into.
67+
*/
68+
private async resolveBinaryPath(
69+
safeHostname: string,
70+
): Promise<ResolvedBinary> {
71+
const cachePath = this.pathResolver.getBinaryCachePath(safeHostname);
72+
const cacheStat = await cliUtils.stat(cachePath);
73+
74+
if (cacheStat?.isFile()) {
75+
return { binPath: cachePath, stat: cacheStat, source: "file-path" };
76+
}
77+
78+
const fullNamePath = path.join(cachePath, cliUtils.fullName());
79+
80+
// Path does not exist yet; return the platform-specific path to download.
81+
if (!cacheStat) {
82+
return { binPath: fullNamePath, source: "not-found" };
83+
}
84+
85+
// Directory exists; check platform-specific name, then simple name.
86+
const fullStat = await cliUtils.stat(fullNamePath);
87+
if (fullStat) {
88+
return { binPath: fullNamePath, stat: fullStat, source: "directory" };
89+
}
90+
91+
const simpleNamePath = path.join(cachePath, cliUtils.simpleName());
92+
const simpleStat = await cliUtils.stat(simpleNamePath);
93+
if (simpleStat) {
94+
return { binPath: simpleNamePath, stat: simpleStat, source: "directory" };
5695
}
57-
return binPath;
96+
97+
return { binPath: fullNamePath, source: "not-found" };
5898
}
5999

60100
/**
@@ -94,82 +134,87 @@ export class CliManager {
94134
);
95135
}
96136

97-
// Check if there is an existing binary and whether it looks valid. If it
98-
// is valid and matches the server, or if it does not match the server but
99-
// downloads are disabled, we can return early.
100-
const binPath = path.join(
101-
this.pathResolver.getBinaryCachePath(safeHostname),
102-
cliUtils.name(),
137+
const resolved = await this.resolveBinaryPath(safeHostname);
138+
this.output.debug(
139+
`Resolved binary: ${resolved.binPath} (${resolved.source})`,
103140
);
104-
this.output.debug("Using binary path", binPath);
105-
const stat = await cliUtils.stat(binPath);
106-
if (stat === undefined) {
107-
this.output.info("No existing binary found, starting download");
108-
} else {
109-
this.output.debug("Existing binary size is", prettyBytes(stat.size));
141+
142+
let existingVersion: string | null = null;
143+
if (resolved.source !== "not-found") {
144+
this.output.debug(
145+
"Existing binary size is",
146+
prettyBytes(resolved.stat.size),
147+
);
110148
try {
111-
const version = await cliVersion(binPath);
112-
this.output.debug("Existing binary version is", version);
113-
// If we have the right version we can avoid the request entirely.
114-
if (version === buildInfo.version) {
115-
this.output.debug(
116-
"Using existing binary since it matches the server version",
117-
);
118-
return binPath;
119-
} else if (!enableDownloads) {
120-
this.output.info(
121-
"Using existing binary even though it does not match the server version because downloads are disabled",
122-
);
123-
return binPath;
124-
}
125-
this.output.info(
126-
"Downloading since existing binary does not match the server version",
127-
);
149+
existingVersion = await cliVersion(resolved.binPath);
150+
this.output.debug("Existing binary version is", existingVersion);
128151
} catch (error) {
129152
this.output.warn(
130-
"Unable to get version of existing binary. Downloading new binary instead",
153+
"Unable to get version of existing binary, downloading instead",
131154
error,
132155
);
133156
}
157+
} else {
158+
this.output.info("No existing binary found, starting download");
159+
}
160+
161+
if (existingVersion === buildInfo.version) {
162+
this.output.debug("Existing binary matches server version");
163+
return resolved.binPath;
134164
}
135165

136166
if (!enableDownloads) {
167+
if (existingVersion) {
168+
this.output.info(
169+
"Using existing binary despite version mismatch because downloads are disabled",
170+
);
171+
return resolved.binPath;
172+
}
137173
this.output.warn("Unable to download CLI because downloads are disabled");
138174
throw new Error("Unable to download CLI because downloads are disabled");
139175
}
140176

177+
if (existingVersion) {
178+
this.output.info(
179+
"Downloading since existing binary does not match the server version",
180+
);
181+
}
182+
183+
// Always download using the platform-specific name.
184+
const downloadBinPath = path.join(
185+
path.dirname(resolved.binPath),
186+
cliUtils.fullName(),
187+
);
188+
141189
// Create the `bin` folder if it doesn't exist
142-
await fs.mkdir(path.dirname(binPath), { recursive: true });
143-
const progressLogPath = binPath + ".progress.log";
190+
await fs.mkdir(path.dirname(downloadBinPath), { recursive: true });
191+
const progressLogPath = downloadBinPath + ".progress.log";
144192

145193
let lockResult:
146194
| { release: () => Promise<void>; waited: boolean }
147195
| undefined;
148196
let latestVersion = parsedVersion;
149197
try {
150198
lockResult = await this.binaryLock.acquireLockOrWait(
151-
binPath,
199+
downloadBinPath,
152200
progressLogPath,
153201
);
154202
this.output.debug("Acquired download lock");
155203

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

161209
const recheckAfterWait = await this.checkBinaryVersion(
162-
binPath,
210+
downloadBinPath,
163211
latestBuildInfo.version,
164212
);
165213
if (recheckAfterWait.matches) {
166-
this.output.debug(
167-
"Using existing binary since it matches the latest server version",
168-
);
169-
return binPath;
214+
this.output.debug("Binary already matches server version after wait");
215+
return await this.renameToFinalPath(resolved, downloadBinPath);
170216
}
171217

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

182-
return await this.performBinaryDownload(
227+
await this.performBinaryDownload(
183228
restClient,
184229
latestVersion,
185-
binPath,
230+
downloadBinPath,
186231
progressLogPath,
187232
);
233+
return await this.renameToFinalPath(resolved, downloadBinPath);
188234
} catch (error) {
189-
// Unified error handling - check for fallback binaries and prompt user
190-
return await this.handleAnyBinaryFailure(
235+
const fallback = await this.handleAnyBinaryFailure(
191236
error,
192-
binPath,
237+
downloadBinPath,
193238
buildInfo.version,
239+
resolved.binPath !== downloadBinPath ? resolved.binPath : undefined,
194240
);
241+
// Move the fallback to the expected path if needed.
242+
if (fallback !== resolved.binPath) {
243+
await fs.rename(fallback, resolved.binPath);
244+
}
245+
return resolved.binPath;
195246
} finally {
196247
if (lockResult) {
197248
await lockResult.release();
@@ -224,6 +275,27 @@ export class CliManager {
224275
}
225276
}
226277

278+
/**
279+
* Rename the downloaded binary to the user-configured file path if needed.
280+
*/
281+
private async renameToFinalPath(
282+
resolved: ResolvedBinary,
283+
downloadBinPath: string,
284+
): Promise<string> {
285+
if (
286+
resolved.source === "file-path" &&
287+
downloadBinPath !== resolved.binPath
288+
) {
289+
this.output.info(
290+
"Renaming downloaded binary to",
291+
path.basename(resolved.binPath),
292+
);
293+
await fs.rename(downloadBinPath, resolved.binPath);
294+
return resolved.binPath;
295+
}
296+
return downloadBinPath;
297+
}
298+
227299
/**
228300
* Prompt the user to use an existing binary version.
229301
*/
@@ -280,54 +352,59 @@ export class CliManager {
280352
}
281353

282354
/**
283-
* Unified handler for any binary-related failure.
284-
* Checks for existing or old binaries and prompts user once.
355+
* Try fallback binaries after a download failure, prompting the user once
356+
* if the best candidate is a version mismatch.
285357
*/
286358
private async handleAnyBinaryFailure(
287359
error: unknown,
288360
binPath: string,
289361
expectedVersion: string,
362+
fallbackBinPath?: string,
290363
): Promise<string> {
291364
const message =
292365
error instanceof cliUtils.FileLockError
293366
? "Unable to update the Coder CLI binary because it's in use"
294367
: "Failed to update CLI binary";
295368

296-
// Try existing binary first
297-
const existingCheck = await this.checkBinaryVersion(
298-
binPath,
299-
expectedVersion,
300-
);
301-
if (existingCheck.version) {
302-
// Perfect match - use without prompting
303-
if (existingCheck.matches) {
304-
return binPath;
369+
// Returns the path if usable, undefined if not found.
370+
// Throws the original error if the user declines a mismatch.
371+
const tryCandidate = async (
372+
candidate: string,
373+
): Promise<string | undefined> => {
374+
const check = await this.checkBinaryVersion(candidate, expectedVersion);
375+
if (!check.version) {
376+
return undefined;
305377
}
306-
// Version mismatch - prompt user
307-
if (await this.promptUseExistingBinary(existingCheck.version, message)) {
308-
return binPath;
378+
if (
379+
!check.matches &&
380+
!(await this.promptUseExistingBinary(check.version, message))
381+
) {
382+
throw error;
383+
}
384+
return candidate;
385+
};
386+
387+
const primary = await tryCandidate(binPath);
388+
if (primary) {
389+
return primary;
390+
}
391+
392+
if (fallbackBinPath) {
393+
const fallback = await tryCandidate(fallbackBinPath);
394+
if (fallback) {
395+
return fallback;
309396
}
310-
throw error;
311397
}
312398

313-
// Try .old-* binaries as fallback
399+
// Last resort: most recent .old-* backup (deferred to avoid IO when unnecessary).
314400
const oldBinaries = await cliUtils.findOldBinaries(binPath);
315401
if (oldBinaries.length > 0) {
316-
const oldCheck = await this.checkBinaryVersion(
317-
oldBinaries[0],
318-
expectedVersion,
319-
);
320-
if (
321-
oldCheck.version &&
322-
(oldCheck.matches ||
323-
(await this.promptUseExistingBinary(oldCheck.version, message)))
324-
) {
325-
await fs.rename(oldBinaries[0], binPath);
326-
return binPath;
402+
const old = await tryCandidate(oldBinaries[0]);
403+
if (old) {
404+
return old;
327405
}
328406
}
329407

330-
// No fallback available or user declined - re-throw original error
331408
throw error;
332409
}
333410

@@ -351,7 +428,7 @@ export class CliManager {
351428
}
352429

353430
// Figure out where to get the binary.
354-
const binName = cliUtils.name();
431+
const binName = cliUtils.fullName();
355432
const configSource = cfg.get<string>("binarySource");
356433
const binSource = configSource?.trim() ? configSource : "/bin/" + binName;
357434
this.output.info("Downloading binary from", binSource);

0 commit comments

Comments
 (0)