Skip to content

Commit 59f545c

Browse files
committed
fix: use platform-specific name for temp/cleanup in shared directories
Always download to the platform-specific binary name (coder-linux-amd64) for temp files, old backups, signatures, and lock files. After a successful download, rename the result to the user's configured file name while still holding the lock to avoid races with other windows. Scoped rmOld to only clean files prefixed with the binary basename so it does not accidentally remove unrelated files when the target directory is shared (e.g. /usr/bin). Additional improvements: - Rename cliUtils.name() to fullName() to pair with simpleName() - Replace ResolvedBinary kind "file"/"dir" with source "file-path"/"directory" to clarify that stat always describes the binary, not the directory - Fix binaryDestination description: file paths are version-checked and updated on mismatch, not used without downloading - Update resolveBinaryPath JSDoc to remove misleading "as-is" language - Add fallback path to error handler so file-path destinations check the user's original binary when the download path has no usable binary - Simplify handleAnyBinaryFailure with tryCandidate closure and deferred findOldBinaries to avoid unnecessary I/O when earlier candidates match - On error recovery, rename fallback to resolved.binPath so the binary always ends up where the user expects - Add "starting download" back to the no-binary-found log message - Improve test coverage: file destination error fallback, simple name error fallback, platform-specific file destination, old backup restore - Unify test structure: shared disableSignatureVerification in top-level beforeEach, shared withFailedDownload helper, Binary Resolution group - Remove flaky lock/progress file assertions from concurrent tests
1 parent 6714fe1 commit 59f545c

File tree

6 files changed

+337
-269
lines changed

6 files changed

+337
-269
lines changed

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 path to the Coder CLI binary or the directory containing it. When set to a file path (e.g., `/usr/bin/coder`), the extension uses it directly without downloading. 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.",
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: 95 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ import type { CliCredentialManager } from "./cliCredentialManager";
3030
import type { PathResolver } from "./pathResolver";
3131

3232
type ResolvedBinary =
33-
| { binPath: string; kind: "file" | "dir"; stat: Stats }
34-
| { binPath: string; kind: "not-found" };
33+
| { binPath: string; stat: Stats; source: "file-path" | "directory" }
34+
| { binPath: string; source: "not-found" };
3535

3636
export class CliManager {
3737
private readonly binaryLock: BinaryLock;
@@ -51,7 +51,7 @@ export class CliManager {
5151
public async locateBinary(url: string): Promise<string> {
5252
const safeHostname = toSafeHost(url);
5353
const resolved = await this.resolveBinaryPath(safeHostname);
54-
if (resolved.kind === "not-found") {
54+
if (resolved.source === "not-found") {
5555
throw new Error(`No CLI binary found at ${resolved.binPath}`);
5656
}
5757
return resolved.binPath;
@@ -60,9 +60,10 @@ export class CliManager {
6060
/**
6161
* Resolve the CLI binary path from the configured cache path.
6262
*
63-
* Returns "file" when the cache path is an existing file (use as-is),
64-
* "dir" when a binary was found inside the directory, or "not-found"
65-
* with the platform-specific path for the caller to download into.
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.
6667
*/
6768
private async resolveBinaryPath(
6869
safeHostname: string,
@@ -71,29 +72,29 @@ export class CliManager {
7172
const cacheStat = await cliUtils.stat(cachePath);
7273

7374
if (cacheStat?.isFile()) {
74-
return { binPath: cachePath, kind: "file", stat: cacheStat };
75+
return { binPath: cachePath, stat: cacheStat, source: "file-path" };
7576
}
7677

77-
const fullNamePath = path.join(cachePath, cliUtils.name());
78+
const fullNamePath = path.join(cachePath, cliUtils.fullName());
7879

7980
// Path does not exist yet; return the platform-specific path to download.
8081
if (!cacheStat) {
81-
return { binPath: fullNamePath, kind: "not-found" };
82+
return { binPath: fullNamePath, source: "not-found" };
8283
}
8384

8485
// Directory exists; check platform-specific name, then simple name.
8586
const fullStat = await cliUtils.stat(fullNamePath);
8687
if (fullStat) {
87-
return { binPath: fullNamePath, kind: "dir", stat: fullStat };
88+
return { binPath: fullNamePath, stat: fullStat, source: "directory" };
8889
}
8990

9091
const simpleNamePath = path.join(cachePath, cliUtils.simpleName());
9192
const simpleStat = await cliUtils.stat(simpleNamePath);
9293
if (simpleStat) {
93-
return { binPath: simpleNamePath, kind: "dir", stat: simpleStat };
94+
return { binPath: simpleNamePath, stat: simpleStat, source: "directory" };
9495
}
9596

96-
return { binPath: fullNamePath, kind: "not-found" };
97+
return { binPath: fullNamePath, source: "not-found" };
9798
}
9899

99100
/**
@@ -134,10 +135,12 @@ export class CliManager {
134135
}
135136

136137
const resolved = await this.resolveBinaryPath(safeHostname);
137-
this.output.debug("Resolved binary path", resolved.binPath, resolved.kind);
138+
this.output.debug(
139+
`Resolved binary: ${resolved.binPath} (${resolved.source})`,
140+
);
138141

139142
// Check existing binary version when one was found.
140-
if (resolved.kind !== "not-found") {
143+
if (resolved.source !== "not-found") {
141144
this.output.debug(
142145
"Existing binary size is",
143146
prettyBytes(resolved.stat.size),
@@ -164,20 +167,19 @@ export class CliManager {
164167
);
165168
}
166169
} else {
167-
this.output.info("No existing binary found");
170+
this.output.info("No existing binary found, starting download");
168171
}
169172

170173
if (!enableDownloads) {
171174
this.output.warn("Unable to download CLI because downloads are disabled");
172175
throw new Error("Unable to download CLI because downloads are disabled");
173176
}
174177

175-
// File destinations download to the same path. Directory destinations
176-
// always use the platform-specific binary name.
177-
const downloadBinPath =
178-
resolved.kind === "file"
179-
? resolved.binPath
180-
: path.join(path.dirname(resolved.binPath), cliUtils.name());
178+
// Always download using the platform-specific name.
179+
const downloadBinPath = path.join(
180+
path.dirname(resolved.binPath),
181+
cliUtils.fullName(),
182+
);
181183

182184
// Create the `bin` folder if it doesn't exist
183185
await fs.mkdir(path.dirname(downloadBinPath), { recursive: true });
@@ -195,6 +197,7 @@ export class CliManager {
195197
this.output.debug("Acquired download lock");
196198

197199
// If we waited for another process, re-check if binary is now ready
200+
let needsDownload = true;
198201
if (lockResult.waited) {
199202
const latestBuildInfo = await restClient.getBuildInfo();
200203
this.output.debug("Got latest server version", latestBuildInfo.version);
@@ -207,32 +210,52 @@ export class CliManager {
207210
this.output.debug(
208211
"Using existing binary since it matches the latest server version",
209212
);
210-
return downloadBinPath;
213+
needsDownload = false;
214+
} else {
215+
const latestParsedVersion = semver.parse(latestBuildInfo.version);
216+
if (!latestParsedVersion) {
217+
throw new Error(
218+
`Got invalid version from deployment: ${latestBuildInfo.version}`,
219+
);
220+
}
221+
latestVersion = latestParsedVersion;
211222
}
223+
}
212224

213-
// Parse the latest version for download
214-
const latestParsedVersion = semver.parse(latestBuildInfo.version);
215-
if (!latestParsedVersion) {
216-
throw new Error(
217-
`Got invalid version from deployment: ${latestBuildInfo.version}`,
218-
);
219-
}
220-
latestVersion = latestParsedVersion;
225+
if (needsDownload) {
226+
await this.performBinaryDownload(
227+
restClient,
228+
latestVersion,
229+
downloadBinPath,
230+
progressLogPath,
231+
);
221232
}
222233

223-
return await this.performBinaryDownload(
224-
restClient,
225-
latestVersion,
226-
downloadBinPath,
227-
progressLogPath,
228-
);
234+
// Rename to user-configured file path while we hold the lock.
235+
if (
236+
resolved.source === "file-path" &&
237+
downloadBinPath !== resolved.binPath
238+
) {
239+
this.output.info(
240+
"Renaming downloaded binary to",
241+
path.basename(resolved.binPath),
242+
);
243+
await fs.rename(downloadBinPath, resolved.binPath);
244+
return resolved.binPath;
245+
}
246+
return downloadBinPath;
229247
} catch (error) {
230-
// Unified error handling - check for fallback binaries and prompt user
231-
return await this.handleAnyBinaryFailure(
248+
const fallback = await this.handleAnyBinaryFailure(
232249
error,
233250
downloadBinPath,
234251
buildInfo.version,
252+
resolved.binPath !== downloadBinPath ? resolved.binPath : undefined,
235253
);
254+
// Move the fallback to the expected path if needed.
255+
if (fallback !== resolved.binPath) {
256+
await fs.rename(fallback, resolved.binPath);
257+
}
258+
return resolved.binPath;
236259
} finally {
237260
if (lockResult) {
238261
await lockResult.release();
@@ -321,54 +344,59 @@ export class CliManager {
321344
}
322345

323346
/**
324-
* Unified handler for any binary-related failure.
325-
* Checks for existing or old binaries and prompts user once.
347+
* Try fallback binaries after a download failure, prompting the user once
348+
* if the best candidate is a version mismatch.
326349
*/
327350
private async handleAnyBinaryFailure(
328351
error: unknown,
329352
binPath: string,
330353
expectedVersion: string,
354+
fallbackBinPath?: string,
331355
): Promise<string> {
332356
const message =
333357
error instanceof cliUtils.FileLockError
334358
? "Unable to update the Coder CLI binary because it's in use"
335359
: "Failed to update CLI binary";
336360

337-
// Try existing binary first
338-
const existingCheck = await this.checkBinaryVersion(
339-
binPath,
340-
expectedVersion,
341-
);
342-
if (existingCheck.version) {
343-
// Perfect match - use without prompting
344-
if (existingCheck.matches) {
345-
return binPath;
361+
// Returns the path if usable, undefined if not found.
362+
// Throws the original error if the user declines a mismatch.
363+
const tryCandidate = async (
364+
candidate: string,
365+
): Promise<string | undefined> => {
366+
const check = await this.checkBinaryVersion(candidate, expectedVersion);
367+
if (!check.version) {
368+
return undefined;
346369
}
347-
// Version mismatch - prompt user
348-
if (await this.promptUseExistingBinary(existingCheck.version, message)) {
349-
return binPath;
370+
if (
371+
!check.matches &&
372+
!(await this.promptUseExistingBinary(check.version, message))
373+
) {
374+
throw error;
375+
}
376+
return candidate;
377+
};
378+
379+
const primary = await tryCandidate(binPath);
380+
if (primary) {
381+
return primary;
382+
}
383+
384+
if (fallbackBinPath) {
385+
const fallback = await tryCandidate(fallbackBinPath);
386+
if (fallback) {
387+
return fallback;
350388
}
351-
throw error;
352389
}
353390

354-
// Try .old-* binaries as fallback
391+
// Last resort: try the most recent .old-* backup.
355392
const oldBinaries = await cliUtils.findOldBinaries(binPath);
356393
if (oldBinaries.length > 0) {
357-
const oldCheck = await this.checkBinaryVersion(
358-
oldBinaries[0],
359-
expectedVersion,
360-
);
361-
if (
362-
oldCheck.version &&
363-
(oldCheck.matches ||
364-
(await this.promptUseExistingBinary(oldCheck.version, message)))
365-
) {
366-
await fs.rename(oldBinaries[0], binPath);
367-
return binPath;
394+
const old = await tryCandidate(oldBinaries[0]);
395+
if (old) {
396+
return old;
368397
}
369398
}
370399

371-
// No fallback available or user declined - re-throw original error
372400
throw error;
373401
}
374402

@@ -392,7 +420,7 @@ export class CliManager {
392420
}
393421

394422
// Figure out where to get the binary.
395-
const binName = cliUtils.name();
423+
const binName = cliUtils.fullName();
396424
const configSource = cfg.get<string>("binarySource");
397425
const binSource = configSource?.trim() ? configSource : "/bin/" + binName;
398426
this.output.info("Downloading binary from", binSource);

src/core/cliUtils.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,18 @@ export interface RemovalResult {
4141
*/
4242
export async function rmOld(binPath: string): Promise<RemovalResult[]> {
4343
const binDir = path.dirname(binPath);
44+
const binBase = path.basename(binPath);
4445
try {
4546
const files = await fs.readdir(binDir);
4647
const results: RemovalResult[] = [];
4748
for (const file of files) {
4849
const fileName = path.basename(file);
4950
if (
50-
fileName.includes(".old-") ||
51-
fileName.includes(".temp-") ||
52-
fileName.endsWith(".asc") ||
53-
fileName.endsWith(".progress.log")
51+
fileName.startsWith(binBase) &&
52+
(fileName.includes(".old-") ||
53+
fileName.includes(".temp-") ||
54+
fileName.endsWith(".asc") ||
55+
fileName.endsWith(".progress.log"))
5456
) {
5557
try {
5658
await fs.rm(path.join(binDir, file), { force: true });
@@ -139,7 +141,7 @@ export async function eTag(binPath: string): Promise<string> {
139141
/**
140142
* Return the platform-specific binary name (e.g. "coder-linux-amd64").
141143
*/
142-
export function name(): string {
144+
export function fullName(): string {
143145
const os = goos();
144146
const arch = goarch();
145147
let binName = `coder-${os}-${arch}`;
@@ -153,7 +155,7 @@ export function name(): string {
153155
/**
154156
* Return the simple binary name ("coder" or "coder.exe" on Windows).
155157
* This is the name used by package managers, as opposed to the
156-
* platform-specific name returned by name().
158+
* platform-specific name returned by fullName().
157159
*/
158160
export function simpleName(): string {
159161
return goos() === "windows" ? "coder.exe" : "coder";

test/unit/core/cliManager.concurrent.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ vi.mock("@/core/cliUtils", async () => {
3333
...actual,
3434
goos: vi.fn(),
3535
goarch: vi.fn(),
36-
name: vi.fn(),
36+
fullName: vi.fn(),
3737
};
3838
});
3939

@@ -48,7 +48,7 @@ vi.mock("@/core/cliExec", async () => {
4848
function setupCliUtilsMocks(version: string) {
4949
vi.mocked(cliUtils.goos).mockReturnValue("linux");
5050
vi.mocked(cliUtils.goarch).mockReturnValue("amd64");
51-
vi.mocked(cliUtils.name).mockReturnValue("coder-linux-amd64");
51+
vi.mocked(cliUtils.fullName).mockReturnValue("coder-linux-amd64");
5252
vi.mocked(cliExec.version).mockResolvedValue(version);
5353
vi.mocked(pgp.readPublicKeys).mockResolvedValue([]);
5454
}
@@ -122,7 +122,7 @@ describe("CliManager Concurrent Downloads", () => {
122122
expect(result).toBe(binaryPath);
123123
}
124124

125-
// Verify binary exists and lock/progress files are cleaned up
125+
// Verify binary exists, and lock/progress files are cleaned up
126126
await expect(fs.access(binaryPath)).resolves.toBeUndefined();
127127
await expect(fs.access(binaryPath + ".lock")).rejects.toThrow();
128128
await expect(fs.access(binaryPath + ".progress.log")).rejects.toThrow();
@@ -161,7 +161,7 @@ describe("CliManager Concurrent Downloads", () => {
161161
expect(result).toBe(binaryPath);
162162
}
163163

164-
// Binary should be updated to 2.0.0, lock/progress files cleaned up
164+
// Binary should be updated to 2.0.0, and lock/progress files are cleaned up
165165
await expect(fs.access(binaryPath)).resolves.toBeUndefined();
166166
const finalContent = await fs.readFile(binaryPath, "utf8");
167167
expect(finalContent).toContain("v2.0.0");

0 commit comments

Comments
 (0)